feat(backend): add global speed limit apis

This commit is contained in:
spinline
2026-02-03 22:53:32 +03:00
parent 86ba4206b8
commit 7dfa3cf890
2 changed files with 106 additions and 14 deletions

View File

@@ -8,8 +8,8 @@ use axum::{
use rust_embed::RustEmbed; use rust_embed::RustEmbed;
use serde::Deserialize; use serde::Deserialize;
use shared::{ use shared::{
SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile, TorrentPeer, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest, TorrentFile,
TorrentTracker, TorrentPeer, TorrentTracker,
}; };
use utoipa::ToSchema; use utoipa::ToSchema;
@@ -113,6 +113,7 @@ pub async fn handle_torrent_action(
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
// Special handling for delete_with_data
if payload.action == "delete_with_data" { if payload.action == "delete_with_data" {
return match delete_torrent_with_data(&client, &payload.hash).await { return match delete_torrent_with_data(&client, &payload.hash).await {
Ok(msg) => (StatusCode::OK, msg).into_response(), Ok(msg) => (StatusCode::OK, msg).into_response(),
@@ -145,6 +146,7 @@ async fn delete_torrent_with_data(
client: &xmlrpc::RtorrentClient, client: &xmlrpc::RtorrentClient,
hash: &str, hash: &str,
) -> Result<&'static str, (StatusCode, String)> { ) -> Result<&'static str, (StatusCode, String)> {
// 1. Get Base Path
let path_xml = client.call("d.base_path", &[hash]).await.map_err(|e| { let path_xml = client.call("d.base_path", &[hash]).await.map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -159,6 +161,7 @@ async fn delete_torrent_with_data(
) )
})?; })?;
// 1.5 Get Default Download Directory (Sandbox Root)
let root_xml = client.call("directory.default", &[]).await.map_err(|e| { let root_xml = client.call("directory.default", &[]).await.map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -173,6 +176,7 @@ async fn delete_torrent_with_data(
) )
})?; })?;
// 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| { let root_path = std::fs::canonicalize(std::path::Path::new(&root_path_str)).map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
@@ -180,18 +184,21 @@ async fn delete_torrent_with_data(
) )
})?; })?;
// Check if target path exists before trying to resolve it
let target_path_raw = std::path::Path::new(&path); let target_path_raw = std::path::Path::new(&path);
if !target_path_raw.exists() { if !target_path_raw.exists() {
tracing::warn!( tracing::warn!(
"Data path not found: {:?}. Removing torrent only.", "Data path not found: {:?}. Removing torrent only.",
target_path_raw target_path_raw
); );
// If file doesn't exist, we just remove the torrent entry
client.call("d.erase", &[hash]).await.map_err(|e| { client.call("d.erase", &[hash]).await.map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to erase torrent: {}", e), format!("Failed to erase torrent: {}", e),
) )
})?; })?;
return Ok("Torrent removed (Data not found)"); return Ok("Torrent removed (Data not found)");
} }
@@ -208,6 +215,7 @@ async fn delete_torrent_with_data(
root_path root_path
); );
// SECURITY CHECK: Ensure path is inside root_path
if !target_path.starts_with(&root_path) { if !target_path.starts_with(&root_path) {
tracing::error!( tracing::error!(
"Security Risk: Attempted to delete path outside download directory: {:?}", "Security Risk: Attempted to delete path outside download directory: {:?}",
@@ -219,6 +227,7 @@ async fn delete_torrent_with_data(
)); ));
} }
// SECURITY CHECK: Ensure we are not deleting the root itself
if target_path == root_path { if target_path == root_path {
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
@@ -226,6 +235,7 @@ async fn delete_torrent_with_data(
)); ));
} }
// 2. Erase Torrent first
client.call("d.erase", &[hash]).await.map_err(|e| { client.call("d.erase", &[hash]).await.map_err(|e| {
tracing::warn!("Failed to erase torrent entry: {}", e); tracing::warn!("Failed to erase torrent entry: {}", e);
( (
@@ -234,6 +244,7 @@ async fn delete_torrent_with_data(
) )
})?; })?;
// 3. Delete Files via Native FS
let delete_result = if target_path.is_dir() { let delete_result = if target_path.is_dir() {
std::fs::remove_dir_all(&target_path) std::fs::remove_dir_all(&target_path)
} else { } else {
@@ -463,17 +474,7 @@ pub async fn set_file_priority_handler(
) -> impl IntoResponse { ) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path); 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(); 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); let target = format!("{}:f{}", payload.hash, payload.file_index);
match client match client
@@ -522,6 +523,90 @@ pub async fn set_label_handler(
} }
} }
/// 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<AppState>) -> 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_string_response(&xml)
.unwrap_or_default()
.parse::<i64>()
.unwrap_or(0),
Err(_) => -1,
};
let up = match up_fut.await {
Ok(xml) => xmlrpc::parse_string_response(&xml)
.unwrap_or_default()
.parse::<i64>()
.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<AppState>,
Json(payload): Json<GlobalLimitRequest>,
) -> impl IntoResponse {
let client = xmlrpc::RtorrentClient::new(&state.scgi_socket_path);
if let Some(down) = payload.max_download_rate {
if let Err(e) = client
.call("throttle.global_down.max_rate.set", &[&down.to_string()])
.await
{
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to set down limit: {}", e),
)
.into_response();
}
}
if let Some(up) = payload.max_upload_rate {
if let Err(e) = client
.call("throttle.global_up.max_rate.set", &[&up.to_string()])
.await
{
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) { pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
if err.is::<tower::timeout::error::Elapsed>() { if err.is::<tower::timeout::error::Elapsed>() {
(StatusCode::REQUEST_TIMEOUT, "Request timed out") (StatusCode::REQUEST_TIMEOUT, "Request timed out")

View File

@@ -59,7 +59,9 @@ struct Args {
handlers::get_peers_handler, handlers::get_peers_handler,
handlers::get_trackers_handler, handlers::get_trackers_handler,
handlers::set_file_priority_handler, handlers::set_file_priority_handler,
handlers::set_label_handler handlers::set_label_handler,
handlers::get_global_limit_handler,
handlers::set_global_limit_handler
), ),
components( components(
schemas( schemas(
@@ -71,7 +73,8 @@ struct Args {
shared::TorrentPeer, shared::TorrentPeer,
shared::TorrentTracker, shared::TorrentTracker,
shared::SetFilePriorityRequest, shared::SetFilePriorityRequest,
shared::SetLabelRequest shared::SetLabelRequest,
shared::GlobalLimitRequest
) )
), ),
tags( tags(
@@ -207,6 +210,10 @@ async fn main() {
post(handlers::set_file_priority_handler), post(handlers::set_file_priority_handler),
) )
.route("/api/torrents/label", post(handlers::set_label_handler)) .route("/api/torrents/label", post(handlers::set_label_handler))
.route(
"/api/settings/global-limits",
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
)
.fallback(handlers::static_handler) // Serve static files for everything else .fallback(handlers::static_handler) // Serve static files for everything else
.layer(TraceLayer::new_for_http()) .layer(TraceLayer::new_for_http())
.layer( .layer(