feat: add delete confirmation modal and fix unsafe file deletion
This commit is contained in:
@@ -233,9 +233,32 @@ async fn handle_torrent_action(
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse path: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
tracing::info!("Attempting to delete torrent and data at path: {}", path);
|
||||
if path.trim().is_empty() || path == "/" {
|
||||
return (StatusCode::BAD_REQUEST, "Safety check failed: Path is empty or root").into_response();
|
||||
let path_buf = std::path::Path::new(&path);
|
||||
|
||||
// 1.5 Get Default Download Directory (Sandbox Root)
|
||||
let root_xml = match client.call("directory.default", &[]).await {
|
||||
Ok(xml) => xml,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to get valid download root: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let root_path_str = match xmlrpc::parse_string_response(&root_xml) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to parse root path: {}", e)).into_response(),
|
||||
};
|
||||
|
||||
let root_path = std::path::Path::new(&root_path_str);
|
||||
|
||||
tracing::info!("Delete request: Path='{}', Root='{}'", path, root_path_str);
|
||||
|
||||
// SECURITY CHECK: Ensure path is inside root_path
|
||||
if !path_buf.starts_with(root_path) {
|
||||
tracing::error!("Security Risk: Attempted to delete path outside download directory: {}", path);
|
||||
return (StatusCode::FORBIDDEN, "Security Error: Cannot delete files outside default download directory").into_response();
|
||||
}
|
||||
|
||||
// SECURITY CHECK: Ensure we are not deleting the root itself
|
||||
if path_buf == root_path {
|
||||
return (StatusCode::BAD_REQUEST, "Security Error: Cannot delete the download root directory itself").into_response();
|
||||
}
|
||||
|
||||
// 2. Erase Torrent first (so rTorrent releases locks?)
|
||||
|
||||
@@ -275,6 +275,9 @@
|
||||
.z-\[100\] {
|
||||
z-index: 100;
|
||||
}
|
||||
.z-\[200\] {
|
||||
z-index: 200;
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -284,6 +287,9 @@
|
||||
.mt-1 {
|
||||
margin-top: calc(var(--spacing) * 1);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -305,6 +311,9 @@
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: calc(var(--spacing) * 8);
|
||||
}
|
||||
.mb-10 {
|
||||
margin-bottom: calc(var(--spacing) * 10);
|
||||
}
|
||||
@@ -703,6 +712,12 @@
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.bg-white\/5 {
|
||||
background-color: color-mix(in srgb, #fff 5%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
background-color: color-mix(in oklab, var(--color-white) 5%, transparent);
|
||||
}
|
||||
}
|
||||
.bg-white\/10 {
|
||||
background-color: color-mix(in srgb, #fff 10%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -809,6 +824,9 @@
|
||||
.py-2\.5 {
|
||||
padding-block: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.py-3 {
|
||||
padding-block: calc(var(--spacing) * 3);
|
||||
}
|
||||
.py-3\.5 {
|
||||
padding-block: calc(var(--spacing) * 3.5);
|
||||
}
|
||||
@@ -919,6 +937,9 @@
|
||||
.text-green-500 {
|
||||
color: var(--color-green-500);
|
||||
}
|
||||
.text-red-400 {
|
||||
color: var(--color-red-400);
|
||||
}
|
||||
.text-red-500 {
|
||||
color: var(--color-red-500);
|
||||
}
|
||||
@@ -972,12 +993,24 @@
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-blue-500\/20 {
|
||||
--tw-shadow-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 20%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 20%, transparent) var(--tw-shadow-alpha), transparent);
|
||||
}
|
||||
}
|
||||
.shadow-blue-500\/30 {
|
||||
--tw-shadow-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 30%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-500) 30%, transparent) var(--tw-shadow-alpha), transparent);
|
||||
}
|
||||
}
|
||||
.shadow-red-500\/20 {
|
||||
--tw-shadow-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-red-500) 20%, transparent) var(--tw-shadow-alpha), transparent);
|
||||
}
|
||||
}
|
||||
.ring-blue-500\/50 {
|
||||
--tw-ring-color: color-mix(in srgb, oklch(62.3% 0.214 259.815) 50%, transparent);
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
@@ -1118,6 +1151,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-blue-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-blue-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-gray-100 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -1142,6 +1182,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-red-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-red-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-red-900\/20 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use leptos::*;
|
||||
use shared::{Torrent, AppEvent, TorrentStatus, Theme};
|
||||
use crate::components::context_menu::ContextMenu;
|
||||
use crate::components::modal::Modal;
|
||||
use gloo_net::eventsource::futures::EventSource;
|
||||
use futures::StreamExt;
|
||||
|
||||
@@ -64,6 +65,10 @@ pub fn App() -> impl IntoView {
|
||||
let (cm_pos, set_cm_pos) = create_signal((0, 0));
|
||||
let (cm_target_hash, set_cm_target_hash) = create_signal(String::new());
|
||||
|
||||
// Delete Confirmation State
|
||||
let (show_delete_modal, set_show_delete_modal) = create_signal(false);
|
||||
let (pending_action, set_pending_action) = create_signal(Option::<(String, String)>::None); // (Action, Hash)
|
||||
|
||||
// Debug: Last Updated Timestamp
|
||||
let (last_updated, set_last_updated) = create_signal(0u64);
|
||||
|
||||
@@ -641,7 +646,62 @@ pub fn App() -> impl IntoView {
|
||||
visible=cm_visible.get()
|
||||
torrent_hash=cm_target_hash.get()
|
||||
on_close=Callback::from(move |_| set_cm_visible.set(false))
|
||||
on_action=Callback::from(move |(action, hash): (String, String)| {
|
||||
if action == "delete" || action == "delete_with_data" {
|
||||
set_pending_action.set(Some((action, hash)));
|
||||
set_show_delete_modal.set(true);
|
||||
} else {
|
||||
// Execute immediately for start/stop
|
||||
spawn_local(async move {
|
||||
let body = serde_json::json!({
|
||||
"hash": hash,
|
||||
"action": action
|
||||
});
|
||||
let _ = gloo_net::http::Request::post("/api/torrents/action")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
})
|
||||
/>
|
||||
|
||||
// Delete Confirmation Modal
|
||||
<Modal
|
||||
title="Confirm Deletion"
|
||||
visible=show_delete_modal
|
||||
is_danger=true
|
||||
confirm_text="Delete Forever"
|
||||
on_cancel=Callback::from(move |_| {
|
||||
set_show_delete_modal.set(false);
|
||||
set_pending_action.set(None);
|
||||
})
|
||||
on_confirm=Callback::from(move |_| {
|
||||
if let Some((action, hash)) = pending_action.get() {
|
||||
spawn_local(async move {
|
||||
let body = serde_json::json!({
|
||||
"hash": hash,
|
||||
"action": action
|
||||
});
|
||||
let _ = gloo_net::http::Request::post("/api/torrents/action")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
set_show_delete_modal.set(false);
|
||||
set_pending_action.set(None);
|
||||
})
|
||||
>
|
||||
<p>"Are you definitely sure you want to delete this torrent?"</p>
|
||||
<Show when=move || pending_action.get().map(|(a, _)| a == "delete_with_data").unwrap_or(false)>
|
||||
<p class="mt-2 text-red-400 font-bold">"⚠️ This will also permanently delete the downloaded files from the disk."</p>
|
||||
</Show>
|
||||
</Modal>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use leptos::*;
|
||||
use gloo_net::http::Request;
|
||||
|
||||
#[component]
|
||||
pub fn ContextMenu(
|
||||
@@ -7,27 +6,14 @@ pub fn ContextMenu(
|
||||
visible: bool,
|
||||
torrent_hash: String,
|
||||
on_close: Callback<()>,
|
||||
on_action: Callback<(String, String)>, // (Action, Hash)
|
||||
) -> impl IntoView {
|
||||
let handle_action = move |action: &str| {
|
||||
let hash = torrent_hash.clone();
|
||||
let action_str = action.to_string();
|
||||
|
||||
// Optimistic UI: Close immediately
|
||||
on_close.call(());
|
||||
|
||||
spawn_local(async move {
|
||||
let body = serde_json::json!({
|
||||
"hash": hash,
|
||||
"action": action_str
|
||||
});
|
||||
|
||||
let _ = Request::post("/api/torrents/action")
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
.unwrap()
|
||||
.send()
|
||||
.await;
|
||||
});
|
||||
on_close.call(()); // Always close menu
|
||||
on_action.call((action_str, hash)); // Delegate
|
||||
};
|
||||
|
||||
if !visible {
|
||||
@@ -89,8 +75,8 @@ pub fn ContextMenu(
|
||||
move |_| handle_action("delete_with_data")
|
||||
}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span>"Delete with Data"</span>
|
||||
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
<span>"Delete with Data"</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod context_menu;
|
||||
pub mod modal;
|
||||
|
||||
52
frontend/src/components/modal.rs
Normal file
52
frontend/src/components/modal.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use leptos::*;
|
||||
|
||||
#[component]
|
||||
pub fn Modal(
|
||||
#[prop(into)] title: String,
|
||||
children: Children,
|
||||
#[prop(into)] on_confirm: Callback<()>,
|
||||
#[prop(into)] on_cancel: Callback<()>,
|
||||
#[prop(into)] visible: Signal<bool>,
|
||||
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
|
||||
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
|
||||
#[prop(into, default = false)] is_danger: bool,
|
||||
) -> impl IntoView {
|
||||
let title = store_value(title);
|
||||
// Eagerly render children to a Fragment, which is Clone
|
||||
let child_view = store_value(children());
|
||||
let on_confirm = store_value(on_confirm);
|
||||
let on_cancel = store_value(on_cancel);
|
||||
let confirm_text = store_value(confirm_text);
|
||||
let cancel_text = store_value(cancel_text);
|
||||
|
||||
view! {
|
||||
<Show when=move || visible.get() fallback=|| ()>
|
||||
<div class="fixed inset-0 bg-black/80 backdrop-blur-md flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
||||
<div class="bg-[#16161c] p-6 rounded-t-2xl md:rounded-2xl w-full max-w-sm shadow-2xl border border-white/10 ring-1 ring-white/5 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
||||
<h3 class="text-xl font-bold text-white mb-4">{title.get_value()}</h3>
|
||||
|
||||
<div class="text-gray-400 mb-8">
|
||||
{child_view.with_value(|c| c.clone())}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-white/5 hover:bg-white/10 rounded-xl transition-all font-medium text-white"
|
||||
on:click=move |_| on_cancel.with_value(|cb| cb.call(()))
|
||||
>
|
||||
{cancel_text.get_value()}
|
||||
</button>
|
||||
<button
|
||||
class=format!("flex-1 px-4 py-3 rounded-xl transition-all font-bold text-white shadow-lg {}",
|
||||
if is_danger { "bg-red-500 hover:bg-red-600 shadow-red-500/20" } else { "bg-blue-500 hover:bg-blue-600 shadow-blue-500/20" }
|
||||
)
|
||||
on:click=move |_| on_confirm.with_value(|cb| cb.call(()))
|
||||
>
|
||||
{confirm_text.get_value()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user