From aa7bfaf6f5d2140b4fcc21fb9a905bb877179f4f Mon Sep 17 00:00:00 2001 From: spinline Date: Sat, 31 Jan 2026 13:17:48 +0300 Subject: [PATCH] feat: add delete confirmation modal and fix unsafe file deletion --- backend/src/main.rs | 29 ++++++++++-- frontend/public/tailwind.css | 47 +++++++++++++++++++ frontend/src/app.rs | 60 +++++++++++++++++++++++++ frontend/src/components/context_menu.rs | 24 +++------- frontend/src/components/mod.rs | 1 + frontend/src/components/modal.rs | 52 +++++++++++++++++++++ 6 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/modal.rs diff --git a/backend/src/main.rs b/backend/src/main.rs index afe7479..84fb7e6 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -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?) diff --git a/frontend/public/tailwind.css b/frontend/public/tailwind.css index 0ca7f2d..e0acdd9 100644 --- a/frontend/public/tailwind.css +++ b/frontend/public/tailwind.css @@ -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) { diff --git a/frontend/src/app.rs b/frontend/src/app.rs index 536d9b3..d4de18e 100644 --- a/frontend/src/app.rs +++ b/frontend/src/app.rs @@ -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 + +

"Are you definitely sure you want to delete this torrent?"

+ +

"⚠️ This will also permanently delete the downloaded files from the disk."

+
+
} }} diff --git a/frontend/src/components/context_menu.rs b/frontend/src/components/context_menu.rs index 7c31e96..03e74d3 100644 --- a/frontend/src/components/context_menu.rs +++ b/frontend/src/components/context_menu.rs @@ -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") } > - - "Delete with Data" + + "Delete with Data" diff --git a/frontend/src/components/mod.rs b/frontend/src/components/mod.rs index 5153491..86532a9 100644 --- a/frontend/src/components/mod.rs +++ b/frontend/src/components/mod.rs @@ -1 +1,2 @@ pub mod context_menu; +pub mod modal; diff --git a/frontend/src/components/modal.rs b/frontend/src/components/modal.rs new file mode 100644 index 0000000..524b050 --- /dev/null +++ b/frontend/src/components/modal.rs @@ -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, + #[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! { + +
+
+

{title.get_value()}

+ +
+ {child_view.with_value(|c| c.clone())} +
+ +
+ + +
+
+
+
+ } +}