diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index cde0f1d..44c81ab 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -71,6 +71,128 @@ pub async fn add_torrent_handler( } } +/// Helper function to handle secure deletion of torrent data +async fn delete_torrent_with_data( + client: &xmlrpc::RtorrentClient, + hash: &str, +) -> Result<&'static str, (StatusCode, String)> { + // 1. Get Base Path + let path_xml = client.call("d.base_path", &[hash]).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to call rTorrent: {}", e), + ) + })?; + + let path = xmlrpc::parse_string_response(&path_xml).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to parse path: {}", e), + ) + })?; + + // 1.5 Get Default Download Directory (Sandbox Root) + let root_xml = client.call("directory.default", &[]).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to get valid download root: {}", e), + ) + })?; + + let root_path_str = xmlrpc::parse_string_response(&root_xml).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to parse root path: {}", e), + ) + })?; + + // Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks + let root_path = std::fs::canonicalize(std::path::Path::new(&root_path_str)).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid download root configuration (on server): {}", e), + ) + })?; + + // Check if target path exists before trying to resolve it + let target_path_raw = std::path::Path::new(&path); + if !target_path_raw.exists() { + tracing::warn!( + "Data path not found: {:?}. Removing torrent only.", + target_path_raw + ); + // If file doesn't exist, we just remove the torrent entry + client.call("d.erase", &[hash]).await.map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to erase torrent: {}", e), + ) + })?; + + return Ok("Torrent removed (Data not found)"); + } + + let target_path = std::fs::canonicalize(target_path_raw).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Invalid data path: {}", e), + ) + })?; + + tracing::info!( + "Delete request: Target='{:?}', Root='{:?}'", + target_path, + root_path + ); + + // SECURITY CHECK: Ensure path is inside root_path + if !target_path.starts_with(&root_path) { + tracing::error!( + "Security Risk: Attempted to delete path outside download directory: {:?}", + target_path + ); + return Err(( + StatusCode::FORBIDDEN, + "Security Error: Cannot delete files outside default download directory".to_string(), + )); + } + + // SECURITY CHECK: Ensure we are not deleting the root itself + if target_path == root_path { + return Err(( + StatusCode::BAD_REQUEST, + "Security Error: Cannot delete the download root directory itself".to_string(), + )); + } + + // 2. Erase Torrent first + client.call("d.erase", &[hash]).await.map_err(|e| { + tracing::warn!("Failed to erase torrent entry: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to erase torrent: {}", e), + ) + })?; + + // 3. Delete Files via Native FS + let delete_result = if target_path.is_dir() { + std::fs::remove_dir_all(&target_path) + } else { + std::fs::remove_file(&target_path) + }; + + match delete_result { + Ok(_) => Ok("Torrent and data deleted"), + Err(e) => { + tracing::error!("Failed to delete data at {:?}: {}", target_path, e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to delete data: {}", e), + )) + } + } +} + pub async fn handle_torrent_action( State(state): State, Json(payload): Json, @@ -81,153 +203,14 @@ pub async fn handle_torrent_action( payload.hash ); + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + // Special handling for delete_with_data if payload.action == "delete_with_data" { - let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); - - // 1. Get Base Path - let path_xml = match client.call("d.base_path", &[&payload.hash]).await { - Ok(xml) => xml, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to call rTorrent: {}", e), - ) - .into_response() - } + return match delete_torrent_with_data(&client, &payload.hash).await { + Ok(msg) => (StatusCode::OK, msg).into_response(), + Err((status, msg)) => (status, msg).into_response(), }; - - let path = match xmlrpc::parse_string_response(&path_xml) { - Ok(p) => p, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to parse path: {}", e), - ) - .into_response() - } - }; - - // 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() - } - }; - - // Resolve Paths (Canonicalize) to prevent .. traversal and symlink attacks - let root_path = match std::fs::canonicalize(std::path::Path::new(&root_path_str)) { - Ok(p) => p, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Invalid download root configuration (on server): {}", e), - ) - .into_response() - } - }; - - // Check if target path exists before trying to resolve it - let target_path_raw = std::path::Path::new(&path); - if !target_path_raw.exists() { - tracing::warn!( - "Data path not found: {:?}. Removing torrent only.", - target_path_raw - ); - // If file doesn't exist, we just remove the torrent entry - if let Err(e) = client.call("d.erase", &[&payload.hash]).await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to erase torrent: {}", e), - ) - .into_response(); - } - return (StatusCode::OK, "Torrent removed (Data not found)").into_response(); - } - - let target_path = match std::fs::canonicalize(target_path_raw) { - Ok(p) => p, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Invalid data path: {}", e), - ) - .into_response() - } - }; - - tracing::info!( - "Delete request: Target='{:?}', Root='{:?}'", - target_path, - root_path - ); - - // SECURITY CHECK: Ensure path is inside root_path - if !target_path.starts_with(&root_path) { - tracing::error!( - "Security Risk: Attempted to delete path outside download directory: {:?}", - target_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 target_path == root_path { - return ( - StatusCode::BAD_REQUEST, - "Security Error: Cannot delete the download root directory itself", - ) - .into_response(); - } - - // 2. Erase Torrent first - if let Err(e) = client.call("d.erase", &[&payload.hash]).await { - tracing::warn!("Failed to erase torrent entry: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to erase torrent: {}", e), - ) - .into_response(); - } - - // 3. Delete Files via Native FS - let delete_result = if target_path.is_dir() { - std::fs::remove_dir_all(&target_path) - } else { - std::fs::remove_file(&target_path) - }; - - match delete_result { - Ok(_) => return (StatusCode::OK, "Torrent and data deleted").into_response(), - Err(e) => { - tracing::error!("Failed to delete data at {:?}: {}", target_path, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to delete data: {}", e), - ) - .into_response(); - } - } } let method = match payload.action.as_str() { @@ -237,7 +220,6 @@ pub async fn handle_torrent_action( _ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(), }; - let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); match client.call(method, &[&payload.hash]).await { Ok(_) => (StatusCode::OK, "Action executed").into_response(), Err(e) => { diff --git a/backend/src/sse.rs b/backend/src/sse.rs index a48c5c0..c4edfc7 100644 --- a/backend/src/sse.rs +++ b/backend/src/sse.rs @@ -1,35 +1,98 @@ use crate::xmlrpc::{parse_multicall_response, RtorrentClient, XmlRpcError}; +use crate::AppState; +use axum::extract::State; use axum::response::sse::{Event, Sse}; use futures::stream::{self, Stream}; use shared::{AppEvent, Torrent, TorrentStatus}; use std::convert::Infallible; use tokio_stream::StreamExt; -// Helper (should be moved to utils) -fn parse_size(s: &str) -> i64 { - s.parse().unwrap_or(0) +// Constants for rTorrent fields to ensure query and parser stay in sync +const RTORRENT_FIELDS: &[&str] = &[ + "", // 0: default (ignored) + "main", // 1: view + "d.hash=", // 0 -> row index starts after view + "d.name=", // 1 + "d.size_bytes=", // 2 + "d.bytes_done=", // 3 + "d.down.rate=", // 4 + "d.up.rate=", // 5 + "d.state=", // 6 + "d.complete=", // 7 + "d.message=", // 8 + "d.left_bytes=", // 9 + "d.creation_date=", // 10 + "d.hashing=", // 11 +]; + +fn parse_long(s: Option<&String>) -> i64 { + s.map(|v| v.parse().unwrap_or(0)).unwrap_or(0) +} + +fn parse_string(s: Option<&String>) -> String { + s.cloned().unwrap_or_default() +} + +/// Converts a raw row of strings from rTorrent XML-RPC into a generic Torrent struct +fn from_rtorrent_row(row: Vec) -> Torrent { + // Indexes correspond to the params list below (excluding the first two view/target args) + let hash = parse_string(row.get(0)); + let name = parse_string(row.get(1)); + let size = parse_long(row.get(2)); + let completed = parse_long(row.get(3)); + let down_rate = parse_long(row.get(4)); + let up_rate = parse_long(row.get(5)); + + let state = parse_long(row.get(6)); + let is_complete = parse_long(row.get(7)); + let message = parse_string(row.get(8)); + let left_bytes = parse_long(row.get(9)); + let added_date = parse_long(row.get(10)); + let is_hashing = parse_long(row.get(11)); + + let percent_complete = if size > 0 { + (completed as f64 / size as f64) * 100.0 + } else { + 0.0 + }; + + // Status Logic + let status = if !message.is_empty() { + TorrentStatus::Error + } else if is_hashing != 0 { + TorrentStatus::Checking + } else if state == 0 { + TorrentStatus::Paused + } else if is_complete != 0 { + TorrentStatus::Seeding + } else { + TorrentStatus::Downloading + }; + + // ETA Logic (seconds) + let eta = if down_rate > 0 && left_bytes > 0 { + left_bytes / down_rate + } else { + 0 + }; + + Torrent { + hash, + name, + size, + completed, + down_rate, + up_rate, + eta, + percent_complete, + status, + error_message: message, + added_date, + } } pub async fn fetch_torrents(client: &RtorrentClient) -> Result, XmlRpcError> { - // d.multicall2("", "main", ...) - let params = vec![ - "", - "main", - "d.hash=", - "d.name=", - "d.size_bytes=", - "d.bytes_done=", - "d.down.rate=", - "d.up.rate=", - "d.state=", // 6 - "d.complete=", // 7 - "d.message=", // 8 - "d.left_bytes=", // 9 - "d.creation_date=", // 10 - "d.hashing=", // 11 - ]; - - let xml = client.call("d.multicall2", ¶ms).await?; + let xml = client.call("d.multicall2", RTORRENT_FIELDS).await?; if xml.trim().is_empty() { return Err(XmlRpcError::Parse("Empty response from SCGI".to_string())); @@ -37,75 +100,11 @@ pub async fn fetch_torrents(client: &RtorrentClient) -> Result, Xml let rows = parse_multicall_response(&xml)?; - let torrents = rows - .into_iter() - .map(|row| { - // row map indexes: - // 0: hash, 1: name, 2: size, 3: completed, 4: down_rate, 5: up_rate - // 6: state, 7: complete, 8: message, 9: left_bytes, 10: added, 11: hashing - - let hash = row.get(0).cloned().unwrap_or_default(); - let name = row.get(1).cloned().unwrap_or_default(); - let size = parse_size(row.get(2).unwrap_or(&"0".to_string())); - let completed = parse_size(row.get(3).unwrap_or(&"0".to_string())); - let down_rate = parse_size(row.get(4).unwrap_or(&"0".to_string())); - let up_rate = parse_size(row.get(5).unwrap_or(&"0".to_string())); - - let state = parse_size(row.get(6).unwrap_or(&"0".to_string())); - let is_complete = parse_size(row.get(7).unwrap_or(&"0".to_string())); - let message = row.get(8).cloned().unwrap_or_default(); - let left_bytes = parse_size(row.get(9).unwrap_or(&"0".to_string())); - let added_date = parse_size(row.get(10).unwrap_or(&"0".to_string())); - let is_hashing = parse_size(row.get(11).unwrap_or(&"0".to_string())); - - let percent_complete = if size > 0 { - (completed as f64 / size as f64) * 100.0 - } else { - 0.0 - }; - - // Status Logic - let status = if !message.is_empty() { - TorrentStatus::Error - } else if is_hashing != 0 { - TorrentStatus::Checking - } else if state == 0 { - TorrentStatus::Paused - } else if is_complete != 0 { - TorrentStatus::Seeding - } else { - TorrentStatus::Downloading - }; - - // ETA Logic (seconds) - let eta = if down_rate > 0 && left_bytes > 0 { - left_bytes / down_rate - } else { - 0 - }; - - Torrent { - hash, - name, - size, - completed, - down_rate, - up_rate, - eta, - percent_complete, - status, - error_message: message, - added_date, - } - }) - .collect(); + let torrents = rows.into_iter().map(from_rtorrent_row).collect(); Ok(torrents) } -use crate::AppState; -use axum::extract::State; // Import from crate root - pub async fn sse_handler( State(state): State, ) -> Sse>> {