refactor(backend): move handlers to separate module

This commit is contained in:
spinline
2026-02-03 20:47:36 +03:00
parent 6dd6d51099
commit f13a712ebd
2 changed files with 275 additions and 269 deletions

263
backend/src/handlers/mod.rs Normal file
View File

@@ -0,0 +1,263 @@
use crate::{xmlrpc, AppState};
use axum::{
extract::{Json, State},
http::{header, StatusCode, Uri},
response::IntoResponse,
BoxError,
};
use rust_embed::RustEmbed;
use serde::Deserialize;
use shared::TorrentActionRequest;
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]
pub struct Asset;
#[derive(Deserialize)]
pub struct AddTorrentRequest {
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(),
}
}
}
}
pub async fn add_torrent_handler(
State(state): State<AppState>,
Json(payload): Json<AddTorrentRequest>,
) -> StatusCode {
tracing::info!(
"Received add_torrent request. URI length: {}",
payload.uri.len()
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
match client.call("load.start", &["", &payload.uri]).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;
}
StatusCode::OK
}
Err(e) => {
tracing::error!("Failed to add torrent: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
pub async fn handle_torrent_action(
State(state): State<AppState>,
Json(payload): Json<TorrentActionRequest>,
) -> impl IntoResponse {
tracing::info!(
"Received action: {} for hash: {}",
payload.action,
payload.hash
);
// 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()
}
};
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() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => 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) => {
tracing::error!("RPC error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to execute action",
)
.into_response()
}
}
}
pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Unhandled internal error",
)
}
}

View File

@@ -1,22 +1,19 @@
mod diff; mod diff;
mod handlers;
mod scgi; mod scgi;
mod sse; mod sse;
mod xmlrpc; mod xmlrpc;
use axum::{error_handling::HandleErrorLayer, BoxError}; use axum::error_handling::HandleErrorLayer;
use axum::{ use axum::{
extract::State,
http::{header, StatusCode, Uri},
response::IntoResponse,
routing::{get, post}, routing::{get, post},
Json, Router, Router,
}; };
use clap::Parser; use clap::Parser;
use rust_embed::RustEmbed; use shared::{AppEvent, Torrent};
use serde::Deserialize;
use shared::{AppEvent, Torrent, TorrentActionRequest}; // shared crates imports
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{broadcast, watch}; use tokio::sync::{broadcast, watch};
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{ use tower_http::{
@@ -44,70 +41,6 @@ struct Args {
port: u16, port: u16,
} }
#[derive(RustEmbed)]
#[folder = "../frontend/dist"]
struct Asset;
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(),
}
}
}
}
use std::time::Duration;
#[derive(Deserialize)]
struct AddTorrentRequest {
uri: String,
}
async fn add_torrent_handler(
State(state): State<AppState>,
Json(payload): Json<AddTorrentRequest>,
) -> StatusCode {
tracing::info!(
"Received add_torrent request. URI length: {}",
payload.uri.len()
);
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
match client.call("load.start", &["", &payload.uri]).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;
}
StatusCode::OK
}
Err(e) => {
tracing::error!("Failed to add torrent: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
}
}
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
// initialize tracing with env filter (default to info) // initialize tracing with env filter (default to info)
@@ -196,9 +129,12 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/api/events", get(sse::sse_handler)) .route("/api/events", get(sse::sse_handler))
.route("/api/torrents/add", post(add_torrent_handler)) .route("/api/torrents/add", post(handlers::add_torrent_handler))
.route("/api/torrents/action", post(handle_torrent_action)) .route(
.fallback(static_handler) // Serve static files for everything else "/api/torrents/action",
post(handlers::handle_torrent_action),
)
.fallback(handlers::static_handler) // Serve static files for everything else
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer( .layer(
CompressionLayer::new() CompressionLayer::new()
@@ -208,7 +144,7 @@ async fn main() {
) )
.layer( .layer(
ServiceBuilder::new() ServiceBuilder::new()
.layer(HandleErrorLayer::new(handle_timeout_error)) .layer(HandleErrorLayer::new(handlers::handle_timeout_error))
.layer(tower::timeout::TimeoutLayer::new(Duration::from_secs(30))), .layer(tower::timeout::TimeoutLayer::new(Duration::from_secs(30))),
) )
.layer(CorsLayer::permissive()) .layer(CorsLayer::permissive())
@@ -219,196 +155,3 @@ async fn main() {
tracing::info!("Backend listening on {}", addr); tracing::info!("Backend listening on {}", addr);
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
} }
async fn handle_torrent_action(
State(state): State<AppState>,
Json(payload): Json<TorrentActionRequest>,
) -> impl IntoResponse {
tracing::info!(
"Received action: {} for hash: {}",
payload.action,
payload.hash
);
// 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()
}
};
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()
}
};
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()
}
};
// 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() {
"start" => "d.start",
"stop" => "d.stop",
"delete" => "d.erase",
_ => 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) => {
tracing::error!("RPC error: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to execute action",
)
.into_response()
}
}
}
async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out")
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
"Unhandled internal error",
)
}
}