use crate::{ xmlrpc::{self, RpcParam}, AppState, }; use axum::{ extract::{Json, Path, State}, http::{header, StatusCode, Uri}, response::IntoResponse, BoxError, }; use rust_embed::RustEmbed; use serde::Deserialize; use shared::{ GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, TorrentPeer, TorrentTracker, }; use utoipa::ToSchema; #[derive(RustEmbed)] #[folder = "../frontend/dist"] pub struct Asset; #[derive(Deserialize, ToSchema)] pub struct AddTorrentRequest { /// Magnet link or Torrent file URL #[schema(example = "magnet:?xt=urn:btih:...")] uri: String, } pub async fn static_handler(uri: Uri) -> impl IntoResponse { let mut path = uri.path().trim_start_matches('/').to_string(); if path.is_empty() { path = "index.html".to_string(); } match Asset::get(&path) { Some(content) => { let mime = mime_guess::from_path(&path).first_or_octet_stream(); ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response() } None => { if path.contains('.') { return StatusCode::NOT_FOUND.into_response(); } // Fallback to index.html for SPA routing match Asset::get("index.html") { Some(content) => { let mime = mime_guess::from_path("index.html").first_or_octet_stream(); ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response() } None => StatusCode::NOT_FOUND.into_response(), } } } } // --- TORRENT ACTIONS --- /// Add a new torrent via magnet link or URL #[utoipa::path( post, path = "/api/torrents/add", request_body = AddTorrentRequest, responses( (status = 200, description = "Torrent added successfully"), (status = 500, description = "Internal server error or rTorrent fault") ) )] pub async fn add_torrent_handler( State(state): State, Json(payload): Json, ) -> StatusCode { tracing::info!( "Received add_torrent request. URI length: {}", payload.uri.len() ); let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); let params = vec![RpcParam::from(""), RpcParam::from(payload.uri.as_str())]; match client.call("load.start", ¶ms).await { Ok(response) => { tracing::debug!("rTorrent response to load.start: {}", response); if response.contains("faultCode") { tracing::error!("rTorrent returned fault: {}", response); return StatusCode::INTERNAL_SERVER_ERROR; } let _ = state .event_bus .send(shared::AppEvent::Notification(shared::SystemNotification { level: shared::NotificationLevel::Success, message: "Torrent added successfully".to_string(), })); StatusCode::OK } Err(e) => { tracing::error!("Failed to add torrent: {}", e); let _ = state .event_bus .send(shared::AppEvent::Notification(shared::SystemNotification { level: shared::NotificationLevel::Error, message: format!("Failed to add torrent: {}", e), })); StatusCode::INTERNAL_SERVER_ERROR } } } /// Perform an action on a torrent (start, stop, delete) #[utoipa::path( post, path = "/api/torrents/action", request_body = TorrentActionRequest, responses( (status = 200, description = "Action executed successfully"), (status = 400, description = "Invalid action or request"), (status = 403, description = "Forbidden: Security risk detected"), (status = 500, description = "Internal server error") ) )] pub async fn handle_torrent_action( State(state): State, Json(payload): Json, ) -> impl IntoResponse { tracing::info!( "Received action: {} for hash: {}", payload.action, payload.hash ); 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) => { let _ = state.event_bus.send(shared::AppEvent::Notification( shared::SystemNotification { level: shared::NotificationLevel::Success, message: format!("Torrent deleted with data: {}", payload.hash), }, )); (StatusCode::OK, msg).into_response() } Err((status, msg)) => (status, msg).into_response(), }; } let method = match payload.action.as_str() { "start" => "d.start", "stop" => "d.stop", "delete" => "d.erase", _ => return (StatusCode::BAD_REQUEST, "Invalid action").into_response(), }; let params = vec![RpcParam::from(payload.hash.as_str())]; match client.call(method, ¶ms).await { Ok(_) => { let _ = state .event_bus .send(shared::AppEvent::Notification(shared::SystemNotification { level: shared::NotificationLevel::Info, message: format!("Action '{}' executed on torrent", payload.action), })); (StatusCode::OK, "Action executed").into_response() } Err(e) => { tracing::error!("RPC error: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, "Failed to execute action", ) .into_response() } } } /// 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 params_hash = vec![RpcParam::from(hash)]; // 1. Get Base Path let path_xml = client .call("d.base_path", ¶ms_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 = tokio::fs::canonicalize(std::path::Path::new(&root_path_str)) .await .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 !tokio::fs::try_exists(target_path_raw) .await .unwrap_or(false) { 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", ¶ms_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 = tokio::fs::canonicalize(target_path_raw) .await .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", ¶ms_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() { tokio::fs::remove_dir_all(&target_path).await } else { tokio::fs::remove_file(&target_path).await }; 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![ RpcParam::from(hash.as_str()), RpcParam::from(""), RpcParam::from("f.path="), RpcParam::from("f.size_bytes="), RpcParam::from("f.completed_chunks="), RpcParam::from("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![ RpcParam::from(hash.as_str()), RpcParam::from(""), RpcParam::from("p.address="), RpcParam::from("p.client_version="), RpcParam::from("p.down_rate="), RpcParam::from("p.up_rate="), RpcParam::from("p.completed_percent="), ]; 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![ RpcParam::from(hash.as_str()), RpcParam::from(""), RpcParam::from("t.url="), RpcParam::from("t.activity_date_last="), RpcParam::from("t.message="), ]; 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) // f.set_priority is tricky. Let's send as string first as before, or int if we knew. // Usually priorities are small integers. // But since we are updating everything to RpcParam, let's use Int if possible or String. // The previous implementation used string. Let's stick to string for now or try Int. // Actually, f.set_priority likely takes an integer. let target = format!("{}:f{}", payload.hash, payload.file_index); let params = vec![ RpcParam::from(target.as_str()), RpcParam::from(payload.priority as i64), ]; match client.call("f.set_priority", ¶ms).await { Ok(_) => { let _ = client .call( "d.update_priorities", &[RpcParam::from(payload.hash.as_str())], ) .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); let params = vec![ RpcParam::from(payload.hash.as_str()), RpcParam::from(payload.label), ]; match client.call("d.custom1.set", ¶ms).await { Ok(_) => (StatusCode::OK, "Label updated").into_response(), Err(e) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("RPC error: {}", e), ) .into_response(), } } /// Get global speed limits #[utoipa::path( get, path = "/api/settings/global-limits", responses( (status = 200, description = "Current limits", body = GlobalLimitRequest), (status = 500, description = "Internal server error") ) )] pub async fn get_global_limit_handler(State(state): State) -> impl IntoResponse { let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); // throttle.global_down.max_rate, throttle.global_up.max_rate let down_fut = client.call("throttle.global_down.max_rate", &[]); let up_fut = client.call("throttle.global_up.max_rate", &[]); let down = match down_fut.await { Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0), Err(_) => -1, }; let up = match up_fut.await { Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0), Err(_) => -1, }; let resp = GlobalLimitRequest { max_download_rate: Some(down), max_upload_rate: Some(up), }; (StatusCode::OK, Json(resp)).into_response() } /// Set global speed limits #[utoipa::path( post, path = "/api/settings/global-limits", request_body = GlobalLimitRequest, responses( (status = 200, description = "Limits updated"), (status = 500, description = "Internal server error") ) )] pub async fn set_global_limit_handler( State(state): State, Json(payload): Json, ) -> impl IntoResponse { let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); // Use throttle.global_*.max_rate.set_kb which is more reliable than .set (which is buggy) // The .set_kb method expects KB/s, so we convert bytes to KB if let Some(down) = payload.max_download_rate { // Convert bytes/s to KB/s (divide by 1024) let down_kb = down / 1024; tracing::info!( "Setting download limit: {} bytes/s = {} KB/s", down, down_kb ); // Use set_kb with empty string as first param (throttle name), then value if let Err(e) = client .call( "throttle.global_down.max_rate.set_kb", &[RpcParam::from(""), RpcParam::Int(down_kb)], ) .await { tracing::error!("Failed to set download limit: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to set down limit: {}", e), ) .into_response(); } } if let Some(up) = payload.max_upload_rate { // Convert bytes/s to KB/s let up_kb = up / 1024; tracing::info!("Setting upload limit: {} bytes/s = {} KB/s", up, up_kb); if let Err(e) = client .call( "throttle.global_up.max_rate.set_kb", &[RpcParam::from(""), RpcParam::Int(up_kb)], ) .await { tracing::error!("Failed to set upload limit: {}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to set up limit: {}", e), ) .into_response(); } } (StatusCode::OK, "Limits updated").into_response() } pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) { if err.is::() { (StatusCode::REQUEST_TIMEOUT, "Request timed out") } else { ( StatusCode::INTERNAL_SERVER_ERROR, "Unhandled internal error", ) } }