From 78f232a909df7841c36d868d34292f8a4ce43b1b Mon Sep 17 00:00:00 2001 From: spinline Date: Tue, 3 Feb 2026 22:39:02 +0300 Subject: [PATCH] feat(backend): add advanced torrent management apis (files, peers, trackers, priority, label) --- backend/src/diff.rs | 5 + backend/src/handlers/mod.rs | 514 +++++++++++++++++++++++++++--------- backend/src/main.rs | 33 ++- backend/src/sse.rs | 10 + shared/src/lib.rs | 50 +++- 5 files changed, 483 insertions(+), 129 deletions(-) diff --git a/backend/src/diff.rs b/backend/src/diff.rs index 46360f6..07521dc 100644 --- a/backend/src/diff.rs +++ b/backend/src/diff.rs @@ -37,6 +37,7 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { eta: None, status: None, error_message: None, + label: None, }; let mut has_changes = false; @@ -78,6 +79,10 @@ pub fn diff_torrents(old: &[Torrent], new: &[Torrent]) -> DiffResult { update.error_message = Some(new_t.error_message.clone()); has_changes = true; } + if old_t.label != new_t.label { + update.label = new_t.label.clone(); + has_changes = true; + } if has_changes { events.push(AppEvent::Update(update)); diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 8956a1f..d27aee3 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,13 +1,16 @@ use crate::{xmlrpc, AppState}; use axum::{ - extract::{Json, State}, + extract::{Json, Path, State}, http::{header, StatusCode, Uri}, response::IntoResponse, BoxError, }; use rust_embed::RustEmbed; use serde::Deserialize; -use shared::TorrentActionRequest; +use shared::{ + SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, TorrentPeer, + TorrentTracker, +}; use utoipa::ToSchema; #[derive(RustEmbed)] @@ -49,6 +52,8 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse { } } +// --- TORRENT ACTIONS --- + /// Add a new torrent via magnet link or URL #[utoipa::path( post, @@ -84,128 +89,6 @@ 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), - )) - } - } -} - /// Perform an action on a torrent (start, stop, delete) #[utoipa::path( post, @@ -230,7 +113,6 @@ pub async fn handle_torrent_action( let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); - // Special handling for delete_with_data if payload.action == "delete_with_data" { return match delete_torrent_with_data(&client, &payload.hash).await { Ok(msg) => (StatusCode::OK, msg).into_response(), @@ -258,6 +140,388 @@ pub async fn handle_torrent_action( } } +/// 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)> { + 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), + ) + })?; + + 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), + ) + })?; + + 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), + ) + })?; + + 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 + ); + 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 + ); + + 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(), + )); + } + + if target_path == root_path { + return Err(( + StatusCode::BAD_REQUEST, + "Security Error: Cannot delete the download root directory itself".to_string(), + )); + } + + 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), + ) + })?; + + 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), + )) + } + } +} + +// --- NEW HANDLERS --- + +/// Get rTorrent version +#[utoipa::path( + get, + path = "/api/system/version", + responses( + (status = 200, description = "rTorrent version", body = String), + (status = 500, description = "Internal server error") + ) +)] +pub async fn get_version_handler(State(state): State) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + match client.call("system.client_version", &[]).await { + Ok(xml) => { + let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml); + (StatusCode::OK, version).into_response() + } + Err(e) => { + tracing::error!("Failed to get version: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Failed to get version").into_response() + } + } +} + +/// Get files for a torrent +#[utoipa::path( + get, + path = "/api/torrents/{hash}/files", + responses( + (status = 200, description = "Files list", body = Vec), + (status = 500, description = "Internal server error") + ), + params( + ("hash" = String, Path, description = "Torrent Hash") + ) +)] +pub async fn get_files_handler( + State(state): State, + Path(hash): Path, +) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + let params = vec![ + hash.as_str(), + "", + "f.path=", + "f.size_bytes=", + "f.completed_chunks=", + "f.priority=", + ]; + + match client.call("f.multicall", ¶ms).await { + Ok(xml) => match xmlrpc::parse_multicall_response(&xml) { + Ok(rows) => { + let files: Vec = rows + .into_iter() + .enumerate() + .map(|(idx, row)| TorrentFile { + index: idx as u32, + path: row.get(0).cloned().unwrap_or_default(), + size: row.get(1).and_then(|s| s.parse().ok()).unwrap_or(0), + completed_chunks: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0), + priority: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0), + }) + .collect(); + (StatusCode::OK, Json(files)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Parse error: {}", e), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("RPC error: {}", e), + ) + .into_response(), + } +} + +/// Get peers for a torrent +#[utoipa::path( + get, + path = "/api/torrents/{hash}/peers", + responses( + (status = 200, description = "Peers list", body = Vec), + (status = 500, description = "Internal server error") + ), + params( + ("hash" = String, Path, description = "Torrent Hash") + ) +)] +pub async fn get_peers_handler( + State(state): State, + Path(hash): Path, +) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + let params = vec![ + hash.as_str(), + "", + "p.address=", + "p.client_version=", + "p.down_rate=", + "p.up_rate=", + "p.completed_percent=", // or similar + ]; + + match client.call("p.multicall", ¶ms).await { + Ok(xml) => match xmlrpc::parse_multicall_response(&xml) { + Ok(rows) => { + let peers: Vec = rows + .into_iter() + .map(|row| TorrentPeer { + ip: row.get(0).cloned().unwrap_or_default(), + client: row.get(1).cloned().unwrap_or_default(), + down_rate: row.get(2).and_then(|s| s.parse().ok()).unwrap_or(0), + up_rate: row.get(3).and_then(|s| s.parse().ok()).unwrap_or(0), + progress: row.get(4).and_then(|s| s.parse().ok()).unwrap_or(0.0), + }) + .collect(); + (StatusCode::OK, Json(peers)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Parse error: {}", e), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("RPC error: {}", e), + ) + .into_response(), + } +} + +/// Get trackers for a torrent +#[utoipa::path( + get, + path = "/api/torrents/{hash}/trackers", + responses( + (status = 200, description = "Trackers list", body = Vec), + (status = 500, description = "Internal server error") + ), + params( + ("hash" = String, Path, description = "Torrent Hash") + ) +)] +pub async fn get_trackers_handler( + State(state): State, + Path(hash): Path, +) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + let params = vec![ + hash.as_str(), + "", + "t.url=", + "t.activity_date_last=", // Just an example field, msg is better + // t.latest_event (success/error) is tricky. + "t.message=", // Often empty if ok + ]; + // rTorrent tracker info is sometimes sparse in multicall + + match client.call("t.multicall", ¶ms).await { + Ok(xml) => { + match xmlrpc::parse_multicall_response(&xml) { + Ok(rows) => { + let trackers: Vec = rows + .into_iter() + .map(|row| { + TorrentTracker { + url: row.get(0).cloned().unwrap_or_default(), + status: "Unknown".to_string(), // Derive from type/activity? + message: row.get(2).cloned().unwrap_or_default(), + } + }) + .collect(); + (StatusCode::OK, Json(trackers)).into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Parse error: {}", e), + ) + .into_response(), + } + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("RPC error: {}", e), + ) + .into_response(), + } +} + +/// Set file priority +#[utoipa::path( + post, + path = "/api/torrents/files/priority", + request_body = SetFilePriorityRequest, + responses( + (status = 200, description = "Priority updated"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn set_file_priority_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + + // f.set_priority takes "hash", index, priority + // Priority: 0 (off), 1 (normal), 2 (high) + let priority_str = payload.priority.to_string(); + + // For file calls, target is often "hash:fIndex" or similar, but f.set_priority usually works on file target + // In d.multicall, f.set_priority is called on file items. + // To call directly: f.set_priority(hash, index, prio) ?? No, usually: + // f.set_priority is not a system command. It's a method on a file object. + // We need to target the file. + // Target format: "{hash}:f{index}" e.g. "HASH:f0" + + let target = format!("{}:f{}", payload.hash, payload.file_index); + + match client + .call("f.set_priority", &[&target, &priority_str]) + .await + { + Ok(_) => { + // Need to update view to reflect changes? usually 'd.update_priorities' is needed + let _ = client.call("d.update_priorities", &[&payload.hash]).await; + (StatusCode::OK, "Priority updated").into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("RPC error: {}", e), + ) + .into_response(), + } +} + +/// Set torrent label +#[utoipa::path( + post, + path = "/api/torrents/label", + request_body = SetLabelRequest, + responses( + (status = 200, description = "Label updated"), + (status = 500, description = "Internal server error") + ) +)] +pub async fn set_label_handler( + State(state): State, + Json(payload): Json, +) -> impl IntoResponse { + let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); + + match client + .call("d.custom1.set", &[&payload.hash, &payload.label]) + .await + { + Ok(_) => (StatusCode::OK, "Label updated").into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("RPC error: {}", e), + ) + .into_response(), + } +} + pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) { if err.is::() { (StatusCode::REQUEST_TIMEOUT, "Request timed out") diff --git a/backend/src/main.rs b/backend/src/main.rs index 6d60433..043c9ef 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -53,7 +53,13 @@ struct Args { #[openapi( paths( handlers::add_torrent_handler, - handlers::handle_torrent_action + handlers::handle_torrent_action, + handlers::get_version_handler, + handlers::get_files_handler, + handlers::get_peers_handler, + handlers::get_trackers_handler, + handlers::set_file_priority_handler, + handlers::set_label_handler ), components( schemas( @@ -61,7 +67,12 @@ struct Args { shared::TorrentActionRequest, shared::Torrent, shared::TorrentStatus, - shared::Theme + shared::Theme, + shared::TorrentFile, + shared::TorrentPeer, + shared::TorrentTracker, + shared::SetFilePriorityRequest, + shared::SetLabelRequest ) ), tags( @@ -179,6 +190,24 @@ async fn main() { "/api/torrents/action", post(handlers::handle_torrent_action), ) + .route("/api/system/version", get(handlers::get_version_handler)) + .route( + "/api/torrents/:hash/files", + get(handlers::get_files_handler), + ) + .route( + "/api/torrents/:hash/peers", + get(handlers::get_peers_handler), + ) + .route( + "/api/torrents/:hash/trackers", + get(handlers::get_trackers_handler), + ) + .route( + "/api/torrents/files/priority", + post(handlers::set_file_priority_handler), + ) + .route("/api/torrents/label", post(handlers::set_label_handler)) .fallback(handlers::static_handler) // Serve static files for everything else .layer(TraceLayer::new_for_http()) .layer( diff --git a/backend/src/sse.rs b/backend/src/sse.rs index 2dfa5ef..72e4509 100644 --- a/backend/src/sse.rs +++ b/backend/src/sse.rs @@ -23,6 +23,7 @@ const RTORRENT_FIELDS: &[&str] = &[ "d.left_bytes=", // 9 "d.creation_date=", // 10 "d.hashing=", // 11 + "d.custom1=", // 12 (Label) ]; fn parse_long(s: Option<&String>) -> i64 { @@ -49,6 +50,14 @@ fn from_rtorrent_row(row: Vec) -> Torrent { 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 label_raw = parse_string(row.get(12)); + + // Treat empty label as None + let label = if label_raw.is_empty() { + None + } else { + Some(label_raw) + }; let percent_complete = if size > 0 { (completed as f64 / size as f64) * 100.0 @@ -88,6 +97,7 @@ fn from_rtorrent_row(row: Vec) -> Torrent { status, error_message: message, added_date, + label, } } diff --git a/shared/src/lib.rs b/shared/src/lib.rs index 587244f..d9a8bbf 100644 --- a/shared/src/lib.rs +++ b/shared/src/lib.rs @@ -14,6 +14,7 @@ pub struct Torrent { pub status: TorrentStatus, pub error_message: String, pub added_date: i64, + pub label: Option, // Added Label support } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, ToSchema)] @@ -48,14 +49,13 @@ pub struct TorrentUpdate { pub eta: Option, pub status: Option, pub error_message: Option, + pub label: Option, // Added Label update support } #[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] pub struct TorrentActionRequest { - /// The hash of the torrent #[schema(example = "5D4C9065...")] pub hash: String, - /// The action to perform: "start", "stop", "delete", "delete_with_data" #[schema(example = "start")] pub action: String, } @@ -66,3 +66,49 @@ pub enum Theme { Light, Amoled, } + +// --- NEW STRUCTS FOR ADVANCED FEATURES --- + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct TorrentFile { + pub index: u32, + pub path: String, + pub size: i64, + pub completed_chunks: i64, + pub priority: u8, // 0: Off, 1: Normal, 2: High +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct TorrentPeer { + pub ip: String, + pub client: String, + pub down_rate: i64, + pub up_rate: i64, + pub progress: f64, +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct TorrentTracker { + pub url: String, + pub status: String, + pub message: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct SetFilePriorityRequest { + pub hash: String, + pub file_index: u32, + pub priority: u8, +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct GlobalLimitRequest { + pub max_upload_rate: Option, // in bytes/s + pub max_download_rate: Option, // in bytes/s +} + +#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)] +pub struct SetLabelRequest { + pub hash: String, + pub label: String, +}