Compare commits
19 Commits
release-20
...
release-20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2bf6e6fd5 | ||
|
|
94bc7cb91d | ||
|
|
5e1f4b18c2 | ||
|
|
3f370389aa | ||
|
|
a4fe8d065c | ||
|
|
3215b38272 | ||
|
|
8eb594e804 | ||
|
|
518af10cd7 | ||
|
|
0304c5cb7d | ||
|
|
cee609700a | ||
|
|
a9de8aeb5a | ||
|
|
79a88306c3 | ||
|
|
96ca09b9bd | ||
|
|
4d88660d91 | ||
|
|
1c2fa499b8 | ||
|
|
f121d5b220 | ||
|
|
449227d019 | ||
|
|
bc47a4ac5c | ||
|
|
a3bf33aee4 |
@@ -17,4 +17,5 @@ strip = true
|
|||||||
incremental = false
|
incremental = false
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
coarsetime = { path = "third_party/coarsetime" }
|
coarsetime = { path = "patches/coarsetime" }
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
use shared::{
|
|
||||||
xmlrpc::{self, RpcParam},
|
|
||||||
AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
|
|
||||||
TorrentFile, TorrentPeer, TorrentTracker,
|
|
||||||
};
|
|
||||||
use crate::AppState;
|
|
||||||
#[cfg(feature = "push-notifications")]
|
|
||||||
use crate::push;
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Json, Path, State},
|
|
||||||
http::{header, StatusCode, Uri},
|
http::{header, StatusCode, Uri},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
BoxError,
|
BoxError,
|
||||||
@@ -37,7 +28,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
if path.contains('.') {
|
if path.contains('.') {
|
||||||
return StatusCode::NOT_FOUND.into_response();
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
}
|
}
|
||||||
// Fallback to index.html for SPA routing
|
|
||||||
match Asset::get("index.html") {
|
match Asset::get("index.html") {
|
||||||
Some(content) => {
|
Some(content) => {
|
||||||
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
let mime = mime_guess::from_path("index.html").first_or_octet_stream();
|
||||||
@@ -49,614 +39,6 @@ pub async fn static_handler(uri: Uri) -> impl IntoResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- 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<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);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// Note: Frontend shows its own toast, no SSE notification needed
|
|
||||||
StatusCode::OK
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to add torrent: {}", e);
|
|
||||||
// Note: Frontend shows its own toast, no SSE notification needed
|
|
||||||
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<AppState>,
|
|
||||||
Json(payload): Json<TorrentActionRequest>,
|
|
||||||
) -> 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) => {
|
|
||||||
// Note: Frontend shows its own toast
|
|
||||||
(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(_) => {
|
|
||||||
// Note: Frontend shows its own toast, no SSE notification needed
|
|
||||||
(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<AppState>) -> 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<TorrentFile>),
|
|
||||||
(status = 500, description = "Internal server error")
|
|
||||||
),
|
|
||||||
params(
|
|
||||||
("hash" = String, Path, description = "Torrent Hash")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_files_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(hash): Path<String>,
|
|
||||||
) -> 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<TorrentFile> = 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<TorrentPeer>),
|
|
||||||
(status = 500, description = "Internal server error")
|
|
||||||
),
|
|
||||||
params(
|
|
||||||
("hash" = String, Path, description = "Torrent Hash")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_peers_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(hash): Path<String>,
|
|
||||||
) -> 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<TorrentPeer> = 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<TorrentTracker>),
|
|
||||||
(status = 500, description = "Internal server error")
|
|
||||||
),
|
|
||||||
params(
|
|
||||||
("hash" = String, Path, description = "Torrent Hash")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_trackers_handler(
|
|
||||||
State(state): State<AppState>,
|
|
||||||
Path(hash): Path<String>,
|
|
||||||
) -> 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<TorrentTracker> = 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<AppState>,
|
|
||||||
Json(payload): Json<SetFilePriorityRequest>,
|
|
||||||
) -> 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<AppState>,
|
|
||||||
Json(payload): Json<SetLabelRequest>,
|
|
||||||
) -> 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<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_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<AppState>,
|
|
||||||
Json(payload): Json<GlobalLimitRequest>,
|
|
||||||
) -> 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) {
|
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")
|
||||||
@@ -668,43 +50,20 @@ pub async fn handle_timeout_error(err: BoxError) -> (StatusCode, &'static str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- PUSH NOTIFICATION HANDLERS ---
|
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
/// Get VAPID public key for push subscription
|
|
||||||
#[utoipa::path(
|
|
||||||
get,
|
|
||||||
path = "/api/push/public-key",
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "VAPID public key", body = String)
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn get_push_public_key_handler(
|
pub async fn get_push_public_key_handler(
|
||||||
State(state): State<AppState>,
|
axum::extract::State(state): axum::extract::State<crate::AppState>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let public_key = state.push_store.get_public_key();
|
let public_key = state.push_store.get_public_key();
|
||||||
(StatusCode::OK, Json(serde_json::json!({ "publicKey": public_key }))).into_response()
|
(StatusCode::OK, axum::extract::Json(serde_json::json!({ "publicKey": public_key }))).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
/// Subscribe to push notifications
|
|
||||||
#[utoipa::path(
|
|
||||||
post,
|
|
||||||
path = "/api/push/subscribe",
|
|
||||||
request_body = push::PushSubscription,
|
|
||||||
responses(
|
|
||||||
(status = 200, description = "Subscription saved"),
|
|
||||||
(status = 400, description = "Invalid subscription data")
|
|
||||||
)
|
|
||||||
)]
|
|
||||||
pub async fn subscribe_push_handler(
|
pub async fn subscribe_push_handler(
|
||||||
State(state): State<AppState>,
|
axum::extract::State(state): axum::extract::State<crate::AppState>,
|
||||||
Json(subscription): Json<push::PushSubscription>,
|
axum::extract::Json(subscription): axum::extract::Json<crate::push::PushSubscription>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
tracing::info!("Received push subscription: {:?}", subscription);
|
tracing::info!("Received push subscription: {:?}", subscription);
|
||||||
|
|
||||||
state.push_store.add_subscription(subscription).await;
|
state.push_store.add_subscription(subscription).await;
|
||||||
|
|
||||||
(StatusCode::OK, "Subscription saved").into_response()
|
(StatusCode::OK, "Subscription saved").into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ mod push;
|
|||||||
mod rate_limit;
|
mod rate_limit;
|
||||||
mod sse;
|
mod sse;
|
||||||
|
|
||||||
use shared::{scgi, xmlrpc};
|
use shared::xmlrpc;
|
||||||
|
|
||||||
use axum::error_handling::HandleErrorLayer;
|
use axum::error_handling::HandleErrorLayer;
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -107,16 +107,6 @@ struct Args {
|
|||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
handlers::add_torrent_handler,
|
|
||||||
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,
|
|
||||||
handlers::get_global_limit_handler,
|
|
||||||
handlers::set_global_limit_handler,
|
|
||||||
handlers::get_push_public_key_handler,
|
handlers::get_push_public_key_handler,
|
||||||
handlers::subscribe_push_handler,
|
handlers::subscribe_push_handler,
|
||||||
handlers::auth::login_handler,
|
handlers::auth::login_handler,
|
||||||
@@ -156,16 +146,6 @@ struct ApiDoc;
|
|||||||
#[derive(OpenApi)]
|
#[derive(OpenApi)]
|
||||||
#[openapi(
|
#[openapi(
|
||||||
paths(
|
paths(
|
||||||
handlers::add_torrent_handler,
|
|
||||||
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,
|
|
||||||
handlers::get_global_limit_handler,
|
|
||||||
handlers::set_global_limit_handler,
|
|
||||||
handlers::auth::login_handler,
|
handlers::auth::login_handler,
|
||||||
handlers::auth::logout_handler,
|
handlers::auth::logout_handler,
|
||||||
handlers::auth::check_auth_handler,
|
handlers::auth::check_auth_handler,
|
||||||
@@ -336,7 +316,7 @@ async fn main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(not(feature = "push-notifications"))]
|
#[cfg(not(feature = "push-notifications"))]
|
||||||
let push_store = ();
|
let _push_store = ();
|
||||||
|
|
||||||
let notify_poll = Arc::new(tokio::sync::Notify::new());
|
let notify_poll = Arc::new(tokio::sync::Notify::new());
|
||||||
|
|
||||||
@@ -488,7 +468,8 @@ async fn main() {
|
|||||||
#[cfg(feature = "swagger")]
|
#[cfg(feature = "swagger")]
|
||||||
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
let app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi()));
|
||||||
|
|
||||||
// Setup & Auth Routes
|
// Setup & Auth Routes (cookie-based, stay as REST)
|
||||||
|
let scgi_path_for_ctx = args.socket.clone();
|
||||||
let app = app
|
let app = app
|
||||||
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
|
.route("/api/setup/status", get(handlers::setup::get_setup_status_handler))
|
||||||
.route("/api/setup", post(handlers::setup::setup_handler))
|
.route("/api/setup", post(handlers::setup::setup_handler))
|
||||||
@@ -500,37 +481,21 @@ async fn main() {
|
|||||||
)
|
)
|
||||||
.route("/api/auth/logout", post(handlers::auth::logout_handler))
|
.route("/api/auth/logout", post(handlers::auth::logout_handler))
|
||||||
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
|
.route("/api/auth/check", get(handlers::auth::check_auth_handler))
|
||||||
// App Routes
|
|
||||||
.route("/api/events", get(sse::sse_handler))
|
.route("/api/events", get(sse::sse_handler))
|
||||||
.route("/api/torrents/add", post(handlers::add_torrent_handler))
|
.route("/api/server_fns/{*fn_name}", post({
|
||||||
.route(
|
let scgi_path = scgi_path_for_ctx.clone();
|
||||||
"/api/torrents/action",
|
move |req: Request<Body>| {
|
||||||
post(handlers::handle_torrent_action),
|
leptos_axum::handle_server_fns_with_context(
|
||||||
)
|
move || {
|
||||||
.route("/api/system/version", get(handlers::get_version_handler))
|
leptos::context::provide_context(shared::ServerContext {
|
||||||
.route(
|
scgi_socket_path: scgi_path.clone(),
|
||||||
"/api/torrents/{hash}/files",
|
});
|
||||||
get(handlers::get_files_handler),
|
},
|
||||||
)
|
req,
|
||||||
.route(
|
)
|
||||||
"/api/torrents/{hash}/peers",
|
}
|
||||||
get(handlers::get_peers_handler),
|
}))
|
||||||
)
|
.fallback(handlers::static_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))
|
|
||||||
.route(
|
|
||||||
"/api/settings/global-limits",
|
|
||||||
get(handlers::get_global_limit_handler).post(handlers::set_global_limit_handler),
|
|
||||||
)
|
|
||||||
.route("/api/server_fns/{*fn_name}", post(leptos_axum::handle_server_fns))
|
|
||||||
.fallback(handlers::static_handler); // Serve static files for everything else
|
|
||||||
|
|
||||||
#[cfg(feature = "push-notifications")]
|
#[cfg(feature = "push-notifications")]
|
||||||
let app = app
|
let app = app
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,4 @@
|
|||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
use shared::{AddTorrentRequest, TorrentActionRequest};
|
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -14,6 +13,8 @@ pub enum ApiError {
|
|||||||
Unauthorized,
|
Unauthorized,
|
||||||
#[error("Too many requests")]
|
#[error("Too many requests")]
|
||||||
RateLimited,
|
RateLimited,
|
||||||
|
#[error("Server function error: {0}")]
|
||||||
|
ServerFn(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn base_url() -> String {
|
fn base_url() -> String {
|
||||||
@@ -130,13 +131,12 @@ pub mod settings {
|
|||||||
use shared::GlobalLimitRequest;
|
use shared::GlobalLimitRequest;
|
||||||
|
|
||||||
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
|
pub async fn set_global_limits(req: &GlobalLimitRequest) -> Result<(), ApiError> {
|
||||||
Request::post(&format!("{}/settings/global-limits", base_url()))
|
shared::server_fns::settings::set_global_limits(
|
||||||
.json(req)
|
req.max_download_rate,
|
||||||
.map_err(|_| ApiError::Network)?
|
req.max_upload_rate,
|
||||||
.send()
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Network)?;
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,30 +168,16 @@ pub mod torrent {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
pub async fn add(uri: &str) -> Result<(), ApiError> {
|
pub async fn add(uri: &str) -> Result<(), ApiError> {
|
||||||
let req = AddTorrentRequest {
|
shared::server_fns::torrent::add_torrent(uri.to_string())
|
||||||
uri: uri.to_string(),
|
|
||||||
};
|
|
||||||
Request::post(&format!("{}/torrents/add", base_url()))
|
|
||||||
.json(&req)
|
|
||||||
.map_err(|_| ApiError::Network)?
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Network)?;
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
|
pub async fn action(hash: &str, action: &str) -> Result<(), ApiError> {
|
||||||
let req = TorrentActionRequest {
|
shared::server_fns::torrent::torrent_action(hash.to_string(), action.to_string())
|
||||||
hash: hash.to_string(),
|
|
||||||
action: action.to_string(),
|
|
||||||
};
|
|
||||||
Request::post(&format!("{}/torrents/action", base_url()))
|
|
||||||
.json(&req)
|
|
||||||
.map_err(|_| ApiError::Network)?
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Network)?;
|
.map(|_| ())
|
||||||
Ok(())
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete(hash: &str) -> Result<(), ApiError> {
|
pub async fn delete(hash: &str) -> Result<(), ApiError> {
|
||||||
@@ -211,33 +197,18 @@ pub mod torrent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
|
pub async fn set_label(hash: &str, label: &str) -> Result<(), ApiError> {
|
||||||
use shared::SetLabelRequest;
|
shared::server_fns::torrent::set_label(hash.to_string(), label.to_string())
|
||||||
let req = SetLabelRequest {
|
|
||||||
hash: hash.to_string(),
|
|
||||||
label: label.to_string(),
|
|
||||||
};
|
|
||||||
Request::post(&format!("{}/torrents/set_label", base_url()))
|
|
||||||
.json(&req)
|
|
||||||
.map_err(|_| ApiError::Network)?
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|_| ApiError::Network)?;
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
|
pub async fn set_priority(hash: &str, file_index: u32, priority: u8) -> Result<(), ApiError> {
|
||||||
use shared::SetFilePriorityRequest;
|
shared::server_fns::torrent::set_file_priority(
|
||||||
let req = SetFilePriorityRequest {
|
hash.to_string(),
|
||||||
hash: hash.to_string(),
|
|
||||||
file_index,
|
file_index,
|
||||||
priority,
|
priority,
|
||||||
};
|
)
|
||||||
Request::post(&format!("{}/torrents/set_priority", base_url()))
|
.await
|
||||||
.json(&req)
|
.map_err(|e| ApiError::ServerFn(e.to_string()))
|
||||||
.map_err(|_| ApiError::Network)?
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.map_err(|_| ApiError::Network)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ use leptos_router::hooks::use_navigate;
|
|||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
crate::store::provide_torrent_store();
|
crate::store::provide_torrent_store();
|
||||||
|
let store = use_context::<crate::store::TorrentStore>();
|
||||||
|
|
||||||
let is_loading = signal(true);
|
let is_loading = signal(true);
|
||||||
let is_authenticated = signal(false);
|
let is_authenticated = signal(false);
|
||||||
|
let needs_setup = signal(false);
|
||||||
|
|
||||||
Effect::new(move |_| {
|
Effect::new(move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
@@ -25,9 +27,8 @@ pub fn App() -> impl IntoView {
|
|||||||
match setup_res {
|
match setup_res {
|
||||||
Ok(status) => {
|
Ok(status) => {
|
||||||
if !status.completed {
|
if !status.completed {
|
||||||
log::info!("Setup not completed, redirecting to /setup");
|
log::info!("Setup not completed");
|
||||||
let navigate = use_navigate();
|
needs_setup.1.set(true);
|
||||||
navigate("/setup", Default::default());
|
|
||||||
is_loading.1.set(false);
|
is_loading.1.set(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -42,33 +43,18 @@ pub fn App() -> impl IntoView {
|
|||||||
log::info!("Authenticated!");
|
log::info!("Authenticated!");
|
||||||
|
|
||||||
if let Ok(user_info) = api::auth::get_user().await {
|
if let Ok(user_info) = api::auth::get_user().await {
|
||||||
if let Some(store) = use_context::<crate::store::TorrentStore>() {
|
if let Some(s) = store {
|
||||||
store.user.set(Some(user_info.username));
|
s.user.set(Some(user_info.username));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is_authenticated.1.set(true);
|
is_authenticated.1.set(true);
|
||||||
|
|
||||||
let pathname = window().location().pathname().unwrap_or_default();
|
|
||||||
if pathname == "/login" || pathname == "/setup" {
|
|
||||||
log::info!("Already authenticated, redirecting to home");
|
|
||||||
let navigate = use_navigate();
|
|
||||||
navigate("/", Default::default());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
Ok(false) => {
|
||||||
log::info!("Not authenticated");
|
log::info!("Not authenticated");
|
||||||
|
|
||||||
let pathname = window().location().pathname().unwrap_or_default();
|
|
||||||
if pathname != "/login" && pathname != "/setup" {
|
|
||||||
let navigate = use_navigate();
|
|
||||||
navigate("/login", Default::default());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("Auth check failed: {:?}", e);
|
log::error!("Auth check failed: {:?}", e);
|
||||||
let navigate = use_navigate();
|
|
||||||
navigate("/login", Default::default());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,10 +78,47 @@ pub fn App() -> impl IntoView {
|
|||||||
<div class="relative w-full h-screen" style="height: 100dvh;">
|
<div class="relative w-full h-screen" style="height: 100dvh;">
|
||||||
<Router>
|
<Router>
|
||||||
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
<Routes fallback=|| view! { <div class="p-4">"404 Not Found"</div> }>
|
||||||
<Route path=leptos_router::path!("/login") view=move || view! { <Login /> } />
|
<Route path=leptos_router::path!("/login") view=move || {
|
||||||
<Route path=leptos_router::path!("/setup") view=move || view! { <Setup /> } />
|
let authenticated = is_authenticated.0.get();
|
||||||
|
let setup_needed = needs_setup.0.get();
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if setup_needed {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/setup", Default::default());
|
||||||
|
} else if authenticated {
|
||||||
|
log::info!("Already authenticated, redirecting to home");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { <Login /> }
|
||||||
|
} />
|
||||||
|
<Route path=leptos_router::path!("/setup") view=move || {
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if is_authenticated.0.get() {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! { <Setup /> }
|
||||||
|
} />
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/") view=move || {
|
<Route path=leptos_router::path!("/") view=move || {
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !is_loading.0.get() && needs_setup.0.get() {
|
||||||
|
log::info!("Setup not completed, redirecting to setup");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/setup", Default::default());
|
||||||
|
} else if !is_loading.0.get() && !is_authenticated.0.get() {
|
||||||
|
log::info!("Not authenticated, redirecting to login");
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/login", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
<Show when=move || !is_loading.0.get() fallback=|| view! {
|
||||||
<div class="flex items-center justify-center h-screen bg-base-100">
|
<div class="flex items-center justify-center h-screen bg-base-100">
|
||||||
@@ -112,6 +135,13 @@ pub fn App() -> impl IntoView {
|
|||||||
}/>
|
}/>
|
||||||
|
|
||||||
<Route path=leptos_router::path!("/settings") view=move || {
|
<Route path=leptos_router::path!("/settings") view=move || {
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if !is_authenticated.0.get() {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
navigate("/login", Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
<Show when=move || !is_loading.0.get() fallback=|| ()>
|
||||||
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
<Show when=move || is_authenticated.0.get() fallback=|| ()>
|
||||||
@@ -128,4 +158,4 @@ pub fn App() -> impl IntoView {
|
|||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
pub mod context_menu;
|
pub mod context_menu;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod modal;
|
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
pub mod torrent;
|
pub mod torrent;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
use leptos::prelude::*;
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[component]
|
|
||||||
pub fn Modal(
|
|
||||||
#[prop(into)] title: String,
|
|
||||||
children: ChildrenFn,
|
|
||||||
#[prop(into)] on_confirm: Callback<()>,
|
|
||||||
#[prop(into)] on_cancel: Callback<()>,
|
|
||||||
#[prop(into)] visible: Signal<bool>,
|
|
||||||
#[prop(into, default = "Confirm".to_string())] confirm_text: String,
|
|
||||||
#[prop(into, default = "Cancel".to_string())] cancel_text: String,
|
|
||||||
#[prop(into, default = false)] is_danger: bool,
|
|
||||||
) -> impl IntoView {
|
|
||||||
let title = StoredValue::new_local(title);
|
|
||||||
let on_confirm = StoredValue::new_local(on_confirm);
|
|
||||||
let on_cancel = StoredValue::new_local(on_cancel);
|
|
||||||
let confirm_text = StoredValue::new_local(confirm_text);
|
|
||||||
let cancel_text = StoredValue::new_local(cancel_text);
|
|
||||||
|
|
||||||
view! {
|
|
||||||
<Show when=move || visible.get() fallback=|| ()>
|
|
||||||
<div class="fixed inset-0 bg-background/80 backdrop-blur-sm flex items-end md:items-center justify-center z-[200] animate-in fade-in duration-200 sm:p-4">
|
|
||||||
<div class="bg-card p-6 rounded-t-2xl md:rounded-lg w-full max-w-sm shadow-xl border border-border ring-0 transform transition-all animate-in slide-in-from-bottom-10 md:slide-in-from-bottom-0 md:zoom-in-95">
|
|
||||||
<h3 class="text-lg font-semibold text-card-foreground mb-4">{move || title.get_value()}</h3>
|
|
||||||
|
|
||||||
<div class="text-muted-foreground mb-6 text-sm">
|
|
||||||
{children()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2"
|
|
||||||
on:click=move |_| on_cancel.with_value(|cb| cb.run(()))
|
|
||||||
>
|
|
||||||
{move || cancel_text.get_value()}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class=move || crate::utils::cn(format!("inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2 {}",
|
|
||||||
if is_danger { "bg-destructive text-destructive-foreground hover:bg-destructive/90" }
|
|
||||||
else { "bg-primary text-primary-foreground hover:bg-primary/90" }
|
|
||||||
))
|
|
||||||
on:click=move |_| {
|
|
||||||
log::info!("Modal: Confirm clicked");
|
|
||||||
on_confirm.with_value(|cb| cb.run(()))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{move || confirm_text.get_value()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -51,44 +51,49 @@ pub fn TorrentTable() -> impl IntoView {
|
|||||||
let sort_dir = signal(SortDirection::Descending);
|
let sort_dir = signal(SortDirection::Descending);
|
||||||
|
|
||||||
let filtered_hashes = move || {
|
let filtered_hashes = move || {
|
||||||
store.torrents.with(|map| {
|
let torrents_map = store.torrents.get();
|
||||||
let mut torrents: Vec<&shared::Torrent> = map.values().filter(|t| {
|
log::debug!("TorrentTable: store.torrents has {} entries", torrents_map.len());
|
||||||
let filter = store.filter.get();
|
|
||||||
let search = store.search_query.get().to_lowercase();
|
let filter = store.filter.get();
|
||||||
let matches_filter = match filter {
|
let search = store.search_query.get();
|
||||||
crate::store::FilterStatus::All => true,
|
let search_lower = search.to_lowercase();
|
||||||
crate::store::FilterStatus::Downloading => t.status == shared::TorrentStatus::Downloading,
|
|
||||||
crate::store::FilterStatus::Seeding => t.status == shared::TorrentStatus::Seeding,
|
let mut torrents: Vec<&shared::Torrent> = torrents_map.values().filter(|t| {
|
||||||
crate::store::FilterStatus::Completed => t.status == shared::TorrentStatus::Seeding || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0),
|
let matches_filter = match filter {
|
||||||
crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused,
|
crate::store::FilterStatus::All => true,
|
||||||
crate::store::FilterStatus::Inactive => t.status == shared::TorrentStatus::Paused || t.status == shared::TorrentStatus::Error,
|
crate::store::FilterStatus::Downloading => t.status == shared::TorrentStatus::Downloading,
|
||||||
_ => true,
|
crate::store::FilterStatus::Seeding => t.status == shared::TorrentStatus::Seeding,
|
||||||
};
|
crate::store::FilterStatus::Completed => t.status == shared::TorrentStatus::Seeding || (t.status == shared::TorrentStatus::Paused && t.percent_complete >= 100.0),
|
||||||
let matches_search = if search.is_empty() { true } else { t.name.to_lowercase().contains(&search) };
|
crate::store::FilterStatus::Paused => t.status == shared::TorrentStatus::Paused,
|
||||||
matches_filter && matches_search
|
crate::store::FilterStatus::Inactive => t.status == shared::TorrentStatus::Paused || t.status == shared::TorrentStatus::Error,
|
||||||
}).collect();
|
_ => true,
|
||||||
|
};
|
||||||
|
let matches_search = if search_lower.is_empty() { true } else { t.name.to_lowercase().contains(&search_lower) };
|
||||||
|
matches_filter && matches_search
|
||||||
|
}).collect();
|
||||||
|
|
||||||
torrents.sort_by(|a, b| {
|
log::debug!("TorrentTable: {} torrents after filtering", torrents.len());
|
||||||
let col = sort_col.0.get();
|
|
||||||
let dir = sort_dir.0.get();
|
torrents.sort_by(|a, b| {
|
||||||
let cmp = match col {
|
let col = sort_col.0.get();
|
||||||
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
let dir = sort_dir.0.get();
|
||||||
SortColumn::Size => a.size.cmp(&b.size),
|
let cmp = match col {
|
||||||
SortColumn::Progress => a.percent_complete.partial_cmp(&b.percent_complete).unwrap_or(std::cmp::Ordering::Equal),
|
SortColumn::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
|
SortColumn::Size => a.size.cmp(&b.size),
|
||||||
SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate),
|
SortColumn::Progress => a.percent_complete.partial_cmp(&b.percent_complete).unwrap_or(std::cmp::Ordering::Equal),
|
||||||
SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate),
|
SortColumn::Status => format!("{:?}", a.status).cmp(&format!("{:?}", b.status)),
|
||||||
SortColumn::ETA => {
|
SortColumn::DownSpeed => a.down_rate.cmp(&b.down_rate),
|
||||||
let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta };
|
SortColumn::UpSpeed => a.up_rate.cmp(&b.up_rate),
|
||||||
let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta };
|
SortColumn::ETA => {
|
||||||
a_eta.cmp(&b_eta)
|
let a_eta = if a.eta <= 0 { i64::MAX } else { a.eta };
|
||||||
}
|
let b_eta = if b.eta <= 0 { i64::MAX } else { b.eta };
|
||||||
SortColumn::AddedDate => a.added_date.cmp(&b.added_date),
|
a_eta.cmp(&b_eta)
|
||||||
};
|
}
|
||||||
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
SortColumn::AddedDate => a.added_date.cmp(&b.added_date),
|
||||||
});
|
};
|
||||||
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
if dir == SortDirection::Descending { cmp.reverse() } else { cmp }
|
||||||
})
|
});
|
||||||
|
torrents.into_iter().map(|t| t.hash.clone()).collect::<Vec<String>>()
|
||||||
};
|
};
|
||||||
|
|
||||||
let handle_sort = move |col: SortColumn| {
|
let handle_sort = move |col: SortColumn| {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use gloo_net::eventsource::futures::EventSource;
|
use gloo_net::eventsource::futures::EventSource;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos::task::spawn_local;
|
||||||
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
use shared::{AppEvent, GlobalStats, NotificationLevel, SystemNotification, Torrent};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
@@ -11,10 +12,6 @@ pub struct NotificationItem {
|
|||||||
pub notification: SystemNotification,
|
pub notification: SystemNotification,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Toast Helper Functions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
pub fn show_toast_with_signal(
|
pub fn show_toast_with_signal(
|
||||||
notifications: RwSignal<Vec<NotificationItem>>,
|
notifications: RwSignal<Vec<NotificationItem>>,
|
||||||
level: NotificationLevel,
|
level: NotificationLevel,
|
||||||
@@ -29,7 +26,6 @@ pub fn show_toast_with_signal(
|
|||||||
|
|
||||||
notifications.update(|list| list.push(item));
|
notifications.update(|list| list.push(item));
|
||||||
|
|
||||||
// Auto-remove after 5 seconds
|
|
||||||
leptos::prelude::set_timeout(
|
leptos::prelude::set_timeout(
|
||||||
move || {
|
move || {
|
||||||
notifications.update(|list| list.retain(|i| i.id != id));
|
notifications.update(|list| list.retain(|i| i.id != id));
|
||||||
@@ -47,10 +43,6 @@ pub fn show_toast(level: NotificationLevel, message: impl Into<String>) {
|
|||||||
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
pub fn toast_success(message: impl Into<String>) { show_toast(NotificationLevel::Success, message); }
|
||||||
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
pub fn toast_error(message: impl Into<String>) { show_toast(NotificationLevel::Error, message); }
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Action Message Mapping
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
|
pub fn get_action_messages(action: &str) -> (&'static str, &'static str) {
|
||||||
match action {
|
match action {
|
||||||
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
|
"start" => ("Torrent başlatıldı", "Torrent başlatılamadı"),
|
||||||
@@ -75,10 +67,6 @@ pub struct PushKeys {
|
|||||||
pub auth: String,
|
pub auth: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Store Definition
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub enum FilterStatus {
|
pub enum FilterStatus {
|
||||||
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
|
All, Downloading, Seeding, Completed, Paused, Inactive, Active, Error,
|
||||||
@@ -107,89 +95,97 @@ pub fn provide_torrent_store() {
|
|||||||
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
|
let store = TorrentStore { torrents, filter, search_query, global_stats, notifications, user };
|
||||||
provide_context(store);
|
provide_context(store);
|
||||||
|
|
||||||
// SSE Connection
|
let notifications_for_sse = notifications;
|
||||||
Effect::new(move |_| {
|
let global_stats_for_sse = global_stats;
|
||||||
if user.get().is_none() { return; }
|
let torrents_for_sse = torrents;
|
||||||
|
let show_browser_notification = show_browser_notification.clone();
|
||||||
|
|
||||||
let show_browser_notification = show_browser_notification.clone();
|
spawn_local(async move {
|
||||||
leptos::task::spawn_local(async move {
|
let mut backoff_ms: u32 = 1000;
|
||||||
let mut backoff_ms: u32 = 1000;
|
let mut was_connected = false;
|
||||||
let mut was_connected = false;
|
let mut disconnect_notified = false;
|
||||||
let mut disconnect_notified = false;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let es_result = EventSource::new("/api/events");
|
|
||||||
match es_result {
|
log::debug!("SSE: Creating EventSource...");
|
||||||
Ok(mut es) => {
|
let es_result = EventSource::new("/api/events");
|
||||||
if let Ok(mut stream) = es.subscribe("message") {
|
match es_result {
|
||||||
let mut got_first_message = false;
|
Ok(mut es) => {
|
||||||
while let Some(Ok((_, msg))) = stream.next().await {
|
log::debug!("SSE: EventSource created, subscribing...");
|
||||||
if !got_first_message {
|
if let Ok(mut stream) = es.subscribe("message") {
|
||||||
got_first_message = true;
|
log::debug!("SSE: Subscribed to message channel");
|
||||||
backoff_ms = 1000;
|
let mut got_first_message = false;
|
||||||
if was_connected && disconnect_notified {
|
while let Some(Ok((_, msg))) = stream.next().await {
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
|
log::debug!("SSE: Received message");
|
||||||
disconnect_notified = false;
|
if !got_first_message {
|
||||||
}
|
got_first_message = true;
|
||||||
was_connected = true;
|
backoff_ms = 1000;
|
||||||
|
if was_connected && disconnect_notified {
|
||||||
|
show_toast_with_signal(notifications_for_sse, NotificationLevel::Success, "Sunucu bağlantısı yeniden kuruldu");
|
||||||
|
disconnect_notified = false;
|
||||||
}
|
}
|
||||||
|
was_connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(data_str) = msg.data().as_string() {
|
if let Some(data_str) = msg.data().as_string() {
|
||||||
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
|
log::debug!("SSE: Parsing JSON: {}", data_str);
|
||||||
match event {
|
if let Ok(event) = serde_json::from_str::<AppEvent>(&data_str) {
|
||||||
AppEvent::FullList { torrents: list, .. } => {
|
match event {
|
||||||
torrents.update(|map| {
|
AppEvent::FullList { torrents: list, .. } => {
|
||||||
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
log::info!("SSE: Received FullList with {} torrents", list.len());
|
||||||
map.retain(|hash, _| new_hashes.contains(hash));
|
torrents_for_sse.update(|map| {
|
||||||
for new_torrent in list {
|
let new_hashes: std::collections::HashSet<String> = list.iter().map(|t| t.hash.clone()).collect();
|
||||||
map.insert(new_torrent.hash.clone(), new_torrent);
|
map.retain(|hash, _| new_hashes.contains(hash));
|
||||||
}
|
for new_torrent in list {
|
||||||
});
|
map.insert(new_torrent.hash.clone(), new_torrent);
|
||||||
}
|
|
||||||
AppEvent::Update(update) => {
|
|
||||||
torrents.update(|map| {
|
|
||||||
if let Some(t) = map.get_mut(&update.hash) {
|
|
||||||
if let Some(v) = update.name { t.name = v; }
|
|
||||||
if let Some(v) = update.size { t.size = v; }
|
|
||||||
if let Some(v) = update.down_rate { t.down_rate = v; }
|
|
||||||
if let Some(v) = update.up_rate { t.up_rate = v; }
|
|
||||||
if let Some(v) = update.percent_complete { t.percent_complete = v; }
|
|
||||||
if let Some(v) = update.completed { t.completed = v; }
|
|
||||||
if let Some(v) = update.eta { t.eta = v; }
|
|
||||||
if let Some(v) = update.status { t.status = v; }
|
|
||||||
if let Some(v) = update.error_message { t.error_message = v; }
|
|
||||||
if let Some(v) = update.label { t.label = Some(v); }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
AppEvent::Stats(stats) => { global_stats.set(stats); }
|
|
||||||
AppEvent::Notification(n) => {
|
|
||||||
show_toast_with_signal(notifications, n.level.clone(), n.message.clone());
|
|
||||||
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
|
||||||
show_browser_notification("VibeTorrent", &n.message);
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
log::debug!("SSE: torrents map now has {} entries", torrents_for_sse.with(|m| m.len()));
|
||||||
|
}
|
||||||
|
AppEvent::Update(update) => {
|
||||||
|
torrents_for_sse.update(|map| {
|
||||||
|
if let Some(t) = map.get_mut(&update.hash) {
|
||||||
|
if let Some(v) = update.name { t.name = v; }
|
||||||
|
if let Some(v) = update.size { t.size = v; }
|
||||||
|
if let Some(v) = update.down_rate { t.down_rate = v; }
|
||||||
|
if let Some(v) = update.up_rate { t.up_rate = v; }
|
||||||
|
if let Some(v) = update.percent_complete { t.percent_complete = v; }
|
||||||
|
if let Some(v) = update.completed { t.completed = v; }
|
||||||
|
if let Some(v) = update.eta { t.eta = v; }
|
||||||
|
if let Some(v) = update.status { t.status = v; }
|
||||||
|
if let Some(v) = update.error_message { t.error_message = v; }
|
||||||
|
if let Some(v) = update.label { t.label = Some(v); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AppEvent::Stats(stats) => { global_stats_for_sse.set(stats); }
|
||||||
|
AppEvent::Notification(n) => {
|
||||||
|
show_toast_with_signal(notifications_for_sse, n.level.clone(), n.message.clone());
|
||||||
|
if n.message.contains("tamamlandı") || n.level == shared::NotificationLevel::Error {
|
||||||
|
show_browser_notification("VibeTorrent", &n.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if was_connected && !disconnect_notified {
|
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
|
|
||||||
disconnect_notified = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
Err(_) => {
|
|
||||||
if was_connected && !disconnect_notified {
|
if was_connected && !disconnect_notified {
|
||||||
show_toast_with_signal(notifications, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
|
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kesildi, yeniden bağlanılıyor...");
|
||||||
disconnect_notified = true;
|
disconnect_notified = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
Err(_) => {
|
||||||
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
if was_connected && !disconnect_notified {
|
||||||
|
show_toast_with_signal(notifications_for_sse, NotificationLevel::Warning, "Sunucu bağlantısı kurulamıyor...");
|
||||||
|
disconnect_notified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
log::debug!("SSE: Reconnecting in {}ms...", backoff_ms);
|
||||||
|
gloo_timers::future::TimeoutFuture::new(backoff_ms).await;
|
||||||
|
backoff_ms = std::cmp::min(backoff_ms * 2, 30000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
patches/coarsetime/Cargo.toml
Normal file
30
patches/coarsetime/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
edition = "2018"
|
||||||
|
name = "coarsetime"
|
||||||
|
version = "0.1.37"
|
||||||
|
description = "Time and duration crate optimized for speed (patched for MIPS)"
|
||||||
|
license = "BSD-2-Clause"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
wasi-abi2 = ["dep:wasi-abi2"]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "coarsetime"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
portable-atomic = { version = "1", default-features = false, features = ["fallback"] }
|
||||||
|
|
||||||
|
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
|
||||||
|
version = "0.13"
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
|
||||||
|
version = "0.2"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
|
||||||
|
version = "0.14.7"
|
||||||
|
optional = true
|
||||||
|
package = "wasi"
|
||||||
@@ -4,9 +4,6 @@
|
|||||||
)))]
|
)))]
|
||||||
use std::time;
|
use std::time;
|
||||||
|
|
||||||
#[cfg(target_has_atomic = "64")]
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
#[cfg(not(target_has_atomic = "64"))]
|
|
||||||
use portable_atomic::{AtomicU64, Ordering};
|
use portable_atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use super::Duration;
|
use super::Duration;
|
||||||
@@ -3,9 +3,6 @@ use std::mem::MaybeUninit;
|
|||||||
use std::ops::*;
|
use std::ops::*;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use std::ptr::*;
|
use std::ptr::*;
|
||||||
#[cfg(target_has_atomic = "64")]
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
#[cfg(not(target_has_atomic = "64"))]
|
|
||||||
use portable_atomic::{AtomicU64, Ordering};
|
use portable_atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
use super::duration::*;
|
use super::duration::*;
|
||||||
@@ -9,6 +9,11 @@ pub mod xmlrpc;
|
|||||||
|
|
||||||
pub mod server_fns;
|
pub mod server_fns;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct ServerContext {
|
||||||
|
pub scgi_socket_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
|
||||||
pub struct Torrent {
|
pub struct Torrent {
|
||||||
pub hash: String,
|
pub hash: String,
|
||||||
|
|||||||
@@ -1,26 +1,2 @@
|
|||||||
use leptos::*;
|
pub mod torrent;
|
||||||
use leptos::prelude::*;
|
pub mod settings;
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
use crate::xmlrpc::{self, RtorrentClient};
|
|
||||||
|
|
||||||
#[server(GetVersion, "/api/server_fns")]
|
|
||||||
pub async fn get_version() -> Result<String, ServerFnError> {
|
|
||||||
let socket_path = std::env::var("RTORRENT_SOCKET").unwrap_or_else(|_| "/tmp/rtorrent.sock".to_string());
|
|
||||||
|
|
||||||
#[cfg(feature = "ssr")]
|
|
||||||
{
|
|
||||||
let client = RtorrentClient::new(&socket_path);
|
|
||||||
match client.call("system.client_version", &[]).await {
|
|
||||||
Ok(xml) => {
|
|
||||||
let version = xmlrpc::parse_string_response(&xml).unwrap_or(xml);
|
|
||||||
Ok(version)
|
|
||||||
},
|
|
||||||
Err(e) => Err(ServerFnError::ServerError(e.to_string())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#[cfg(not(feature = "ssr"))]
|
|
||||||
{
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
58
shared/src/server_fns/settings.rs
Normal file
58
shared/src/server_fns/settings.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::GlobalLimitRequest;
|
||||||
|
|
||||||
|
#[server(GetGlobalLimits, "/api/server_fns")]
|
||||||
|
pub async fn get_global_limits() -> Result<GlobalLimitRequest, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{self, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
|
||||||
|
let down = match client.call("throttle.global_down.max_rate", &[]).await {
|
||||||
|
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
|
||||||
|
Err(_) => -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
let up = match client.call("throttle.global_up.max_rate", &[]).await {
|
||||||
|
Ok(xml) => xmlrpc::parse_i64_response(&xml).unwrap_or(0),
|
||||||
|
Err(_) => -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(GlobalLimitRequest {
|
||||||
|
max_download_rate: Some(down),
|
||||||
|
max_upload_rate: Some(up),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(SetGlobalLimits, "/api/server_fns")]
|
||||||
|
pub async fn set_global_limits(
|
||||||
|
max_download_rate: Option<i64>,
|
||||||
|
max_upload_rate: Option<i64>,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use crate::xmlrpc::{RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
|
||||||
|
if let Some(down) = max_download_rate {
|
||||||
|
let down_kb = down / 1024;
|
||||||
|
client
|
||||||
|
.call(
|
||||||
|
"throttle.global_down.max_rate.set_kb",
|
||||||
|
&[RpcParam::from(""), RpcParam::Int(down_kb)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to set down limit: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(up) = max_upload_rate {
|
||||||
|
let up_kb = up / 1024;
|
||||||
|
client
|
||||||
|
.call(
|
||||||
|
"throttle.global_up.max_rate.set_kb",
|
||||||
|
&[RpcParam::from(""), RpcParam::Int(up_kb)],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to set up limit: {}", e)))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
274
shared/src/server_fns/torrent.rs
Normal file
274
shared/src/server_fns/torrent.rs
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::{TorrentFile, TorrentPeer, TorrentTracker};
|
||||||
|
|
||||||
|
#[server(AddTorrent, "/api/server_fns")]
|
||||||
|
pub async fn add_torrent(uri: String) -> Result<(), ServerFnError> {
|
||||||
|
use crate::xmlrpc::{RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
let params = vec![RpcParam::from(""), RpcParam::from(uri.as_str())];
|
||||||
|
|
||||||
|
match client.call("load.start", ¶ms).await {
|
||||||
|
Ok(response) => {
|
||||||
|
if response.contains("faultCode") {
|
||||||
|
return Err(ServerFnError::new("rTorrent returned fault"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(ServerFnError::new(format!("Failed to add torrent: {}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(TorrentAction, "/api/server_fns")]
|
||||||
|
pub async fn torrent_action(hash: String, action: String) -> Result<String, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
|
||||||
|
if action == "delete_with_data" {
|
||||||
|
return delete_torrent_with_data_inner(&client, &hash).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let method = match action.as_str() {
|
||||||
|
"start" => "d.start",
|
||||||
|
"stop" => "d.stop",
|
||||||
|
"delete" => "d.erase",
|
||||||
|
_ => return Err(ServerFnError::new("Invalid action")),
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = vec![RpcParam::from(hash.as_str())];
|
||||||
|
match client.call(method, ¶ms).await {
|
||||||
|
Ok(_) => Ok("Action executed".to_string()),
|
||||||
|
Err(e) => Err(ServerFnError::new(format!("RPC error: {}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
async fn delete_torrent_with_data_inner(
|
||||||
|
client: &crate::xmlrpc::RtorrentClient,
|
||||||
|
hash: &str,
|
||||||
|
) -> Result<String, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{parse_string_response, RpcParam};
|
||||||
|
|
||||||
|
let params_hash = vec![RpcParam::from(hash)];
|
||||||
|
|
||||||
|
let path_xml = client
|
||||||
|
.call("d.base_path", ¶ms_hash)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to call rTorrent: {}", e)))?;
|
||||||
|
|
||||||
|
let path = parse_string_response(&path_xml)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse path: {}", e)))?;
|
||||||
|
|
||||||
|
let root_xml = client
|
||||||
|
.call("directory.default", &[])
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to get download root: {}", e)))?;
|
||||||
|
|
||||||
|
let root_path_str = parse_string_response(&root_xml)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to parse root path: {}", e)))?;
|
||||||
|
|
||||||
|
let root_path = tokio::fs::canonicalize(std::path::Path::new(&root_path_str))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Invalid download root: {}", e)))?;
|
||||||
|
|
||||||
|
let target_path_raw = std::path::Path::new(&path);
|
||||||
|
if !tokio::fs::try_exists(target_path_raw).await.unwrap_or(false) {
|
||||||
|
client
|
||||||
|
.call("d.erase", ¶ms_hash)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
|
||||||
|
return Ok("Torrent removed (Data not found)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let target_path = tokio::fs::canonicalize(target_path_raw)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Invalid data path: {}", e)))?;
|
||||||
|
|
||||||
|
if !target_path.starts_with(&root_path) {
|
||||||
|
return Err(ServerFnError::new(
|
||||||
|
"Security Error: Cannot delete files outside download directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if target_path == root_path {
|
||||||
|
return Err(ServerFnError::new(
|
||||||
|
"Security Error: Cannot delete the download root directory",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
client
|
||||||
|
.call("d.erase", ¶ms_hash)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Failed to erase torrent: {}", e)))?;
|
||||||
|
|
||||||
|
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".to_string()),
|
||||||
|
Err(e) => Err(ServerFnError::new(format!("Failed to delete data: {}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(GetFiles, "/api/server_fns")]
|
||||||
|
pub async fn get_files(hash: String) -> Result<Vec<TorrentFile>, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.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="),
|
||||||
|
];
|
||||||
|
|
||||||
|
let xml = client
|
||||||
|
.call("f.multicall", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
|
||||||
|
|
||||||
|
let rows = parse_multicall_response(&xml)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(GetPeers, "/api/server_fns")]
|
||||||
|
pub async fn get_peers(hash: String) -> Result<Vec<TorrentPeer>, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.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="),
|
||||||
|
];
|
||||||
|
|
||||||
|
let xml = client
|
||||||
|
.call("p.multicall", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
|
||||||
|
|
||||||
|
let rows = parse_multicall_response(&xml)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(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())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(GetTrackers, "/api/server_fns")]
|
||||||
|
pub async fn get_trackers(hash: String) -> Result<Vec<TorrentTracker>, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{parse_multicall_response, RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.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="),
|
||||||
|
];
|
||||||
|
|
||||||
|
let xml = client
|
||||||
|
.call("t.multicall", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
|
||||||
|
|
||||||
|
let rows = parse_multicall_response(&xml)
|
||||||
|
.map_err(|e| ServerFnError::new(format!("Parse error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| TorrentTracker {
|
||||||
|
url: row.get(0).cloned().unwrap_or_default(),
|
||||||
|
status: "Unknown".to_string(),
|
||||||
|
message: row.get(2).cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(SetFilePriority, "/api/server_fns")]
|
||||||
|
pub async fn set_file_priority(
|
||||||
|
hash: String,
|
||||||
|
file_index: u32,
|
||||||
|
priority: u8,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use crate::xmlrpc::{RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
|
||||||
|
let target = format!("{}:f{}", hash, file_index);
|
||||||
|
let params = vec![
|
||||||
|
RpcParam::from(target.as_str()),
|
||||||
|
RpcParam::from(priority as i64),
|
||||||
|
];
|
||||||
|
|
||||||
|
client
|
||||||
|
.call("f.set_priority", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
|
||||||
|
|
||||||
|
let _ = client
|
||||||
|
.call("d.update_priorities", &[RpcParam::from(hash.as_str())])
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(SetLabel, "/api/server_fns")]
|
||||||
|
pub async fn set_label(hash: String, label: String) -> Result<(), ServerFnError> {
|
||||||
|
use crate::xmlrpc::{RpcParam, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
let params = vec![RpcParam::from(hash.as_str()), RpcParam::from(label)];
|
||||||
|
|
||||||
|
client
|
||||||
|
.call("d.custom1.set", ¶ms)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(format!("RPC error: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(GetVersion, "/api/server_fns")]
|
||||||
|
pub async fn get_version() -> Result<String, ServerFnError> {
|
||||||
|
use crate::xmlrpc::{parse_string_response, RtorrentClient};
|
||||||
|
let ctx = expect_context::<crate::ServerContext>();
|
||||||
|
let client = RtorrentClient::new(&ctx.scgi_socket_path);
|
||||||
|
|
||||||
|
match client.call("system.client_version", &[]).await {
|
||||||
|
Ok(xml) => {
|
||||||
|
let version = parse_string_response(&xml).unwrap_or(xml);
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
Err(e) => Err(ServerFnError::new(format!("Failed to get version: {}", e))),
|
||||||
|
}
|
||||||
|
}
|
||||||
1
third_party/coarsetime/.cargo-ok
vendored
1
third_party/coarsetime/.cargo-ok
vendored
@@ -1 +0,0 @@
|
|||||||
{"v":1}
|
|
||||||
6
third_party/coarsetime/.cargo_vcs_info.json
vendored
6
third_party/coarsetime/.cargo_vcs_info.json
vendored
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"git": {
|
|
||||||
"sha1": "831c97016aa3d8f7851999aa1deea8407e7cbd42"
|
|
||||||
},
|
|
||||||
"path_in_vcs": ""
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
version: 2
|
|
||||||
updates:
|
|
||||||
- package-ecosystem: cargo
|
|
||||||
directory: "/"
|
|
||||||
schedule:
|
|
||||||
interval: daily
|
|
||||||
time: "04:00"
|
|
||||||
open-pull-requests-limit: 10
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
name: Close inactive issues
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "30 1 * * *"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
close-issues:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/stale@v9
|
|
||||||
with:
|
|
||||||
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
|
|
||||||
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
|
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
4
third_party/coarsetime/.gitignore
vendored
4
third_party/coarsetime/.gitignore
vendored
@@ -1,4 +0,0 @@
|
|||||||
target
|
|
||||||
Cargo.lock
|
|
||||||
.vscode
|
|
||||||
zig-cache
|
|
||||||
82
third_party/coarsetime/Cargo.toml
vendored
82
third_party/coarsetime/Cargo.toml
vendored
@@ -1,82 +0,0 @@
|
|||||||
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
|
|
||||||
#
|
|
||||||
# When uploading crates to the registry Cargo will automatically
|
|
||||||
# "normalize" Cargo.toml files for maximal compatibility
|
|
||||||
# with all versions of Cargo and also rewrite `path` dependencies
|
|
||||||
# to registry (e.g., crates.io) dependencies.
|
|
||||||
#
|
|
||||||
# If you are reading this file be aware that the original Cargo.toml
|
|
||||||
# will likely look very different (and much more reasonable).
|
|
||||||
# See Cargo.toml.orig for the original contents.
|
|
||||||
|
|
||||||
[package]
|
|
||||||
edition = "2018"
|
|
||||||
name = "coarsetime"
|
|
||||||
version = "0.1.37"
|
|
||||||
authors = ["Frank Denis <github@pureftpd.org>"]
|
|
||||||
build = false
|
|
||||||
autolib = false
|
|
||||||
autobins = false
|
|
||||||
autoexamples = false
|
|
||||||
autotests = false
|
|
||||||
autobenches = false
|
|
||||||
description = "Time and duration crate optimized for speed"
|
|
||||||
homepage = "https://github.com/jedisct1/rust-coarsetime"
|
|
||||||
readme = "README.md"
|
|
||||||
keywords = [
|
|
||||||
"time",
|
|
||||||
"date",
|
|
||||||
"duration",
|
|
||||||
]
|
|
||||||
categories = [
|
|
||||||
"concurrency",
|
|
||||||
"date-and-time",
|
|
||||||
"os",
|
|
||||||
]
|
|
||||||
license = "BSD-2-Clause"
|
|
||||||
repository = "https://github.com/jedisct1/rust-coarsetime"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
wasi-abi2 = ["dep:wasi-abi2"]
|
|
||||||
|
|
||||||
[lib]
|
|
||||||
name = "coarsetime"
|
|
||||||
path = "src/lib.rs"
|
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "benchmark"
|
|
||||||
path = "benches/benchmark.rs"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[dev-dependencies.benchmark-simple]
|
|
||||||
version = "0.1.10"
|
|
||||||
|
|
||||||
[dependencies.portable-atomic]
|
|
||||||
version = "1.6"
|
|
||||||
|
|
||||||
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies.wasm-bindgen]
|
|
||||||
version = "0.2"
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies.wasix]
|
|
||||||
version = "0.13"
|
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies.libc]
|
|
||||||
version = "0.2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "wasi")'.dependencies.wasi-abi2]
|
|
||||||
version = "0.14.7"
|
|
||||||
optional = true
|
|
||||||
package = "wasi"
|
|
||||||
|
|
||||||
[profile.bench]
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
overflow-checks = true
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = 3
|
|
||||||
lto = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
incremental = false
|
|
||||||
47
third_party/coarsetime/Cargo.toml.orig
generated
vendored
47
third_party/coarsetime/Cargo.toml.orig
generated
vendored
@@ -1,47 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "coarsetime"
|
|
||||||
version = "0.1.37"
|
|
||||||
description = "Time and duration crate optimized for speed"
|
|
||||||
authors = ["Frank Denis <github@pureftpd.org>"]
|
|
||||||
keywords = ["time", "date", "duration"]
|
|
||||||
readme = "README.md"
|
|
||||||
license = "BSD-2-Clause"
|
|
||||||
homepage = "https://github.com/jedisct1/rust-coarsetime"
|
|
||||||
repository = "https://github.com/jedisct1/rust-coarsetime"
|
|
||||||
categories = ["concurrency", "date-and-time", "os"]
|
|
||||||
edition = "2018"
|
|
||||||
|
|
||||||
[features]
|
|
||||||
wasi-abi2 = ["dep:wasi-abi2"]
|
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "wasix", target_os = "wasi")))'.dependencies]
|
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
|
||||||
wasi-abi2 = { package = "wasi", version = "0.14.7", optional = true }
|
|
||||||
|
|
||||||
[target.'cfg(any(target_os = "wasix", target_os = "wasi"))'.dependencies]
|
|
||||||
wasix = "0.13"
|
|
||||||
|
|
||||||
[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies]
|
|
||||||
wasm-bindgen = "0.2"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
benchmark-simple = "0.1.10"
|
|
||||||
|
|
||||||
[profile.bench]
|
|
||||||
codegen-units = 1
|
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "benchmark"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
lto = true
|
|
||||||
panic = "abort"
|
|
||||||
opt-level = 3
|
|
||||||
codegen-units = 1
|
|
||||||
incremental = false
|
|
||||||
|
|
||||||
[profile.dev]
|
|
||||||
overflow-checks=true
|
|
||||||
25
third_party/coarsetime/LICENSE
vendored
25
third_party/coarsetime/LICENSE
vendored
@@ -1,25 +0,0 @@
|
|||||||
BSD 2-Clause License
|
|
||||||
|
|
||||||
Copyright (c) 2016-2026, Frank Denis
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without
|
|
||||||
modification, are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
* Redistributions of source code must retain the above copyright notice, this
|
|
||||||
list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
* Redistributions in binary form must reproduce the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer in the documentation
|
|
||||||
and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
||||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
||||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
||||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
||||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
||||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
||||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
||||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
||||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
90
third_party/coarsetime/README.md
vendored
90
third_party/coarsetime/README.md
vendored
@@ -1,90 +0,0 @@
|
|||||||
[](https://docs.rs/coarsetime)
|
|
||||||
[](https://ci.appveyor.com/project/jedisct1/rust-coarsetime)
|
|
||||||
# coarsetime
|
|
||||||
|
|
||||||
A Rust crate to make time measurements, that focuses on speed, API stability and portability.
|
|
||||||
|
|
||||||
This crate is a partial replacement for the `Time` and `Duration` structures
|
|
||||||
from the standard library, with the following differences:
|
|
||||||
|
|
||||||
* Speed is privileged over accuracy. In particular, `CLOCK_MONOTONIC_COARSE` is
|
|
||||||
used to retrieve the clock value on Linux systems, and transformations avoid
|
|
||||||
operations that can be slow on non-Intel systems.
|
|
||||||
* The number of system calls can be kept to a minimum. The "most recent
|
|
||||||
timestamp" is always kept in memory. It can be read with just a load operation,
|
|
||||||
and can be updated only as frequently as necessary.
|
|
||||||
* The API is stable, and the same for all platforms. Unlike the standard library, it doesn't silently compile functions that do nothing but panic at runtime on some platforms.
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
`coarsetime` is available on [crates.io](https://crates.io/crates/coarsetime)
|
|
||||||
and works on Rust stable, beta, and nightly.
|
|
||||||
|
|
||||||
Windows and Unix-like systems are supported.
|
|
||||||
|
|
||||||
Available feature:
|
|
||||||
|
|
||||||
* `wasi-abi2`: when targeting WASI, use the second preview of the ABI. Default is to use the regular WASI-core ABI.
|
|
||||||
|
|
||||||
# Documentation
|
|
||||||
|
|
||||||
[API documentation](https://docs.rs/coarsetime)
|
|
||||||
|
|
||||||
# Example
|
|
||||||
|
|
||||||
```rust
|
|
||||||
extern crate coarsetime;
|
|
||||||
|
|
||||||
use coarsetime::{Duration, Instant, Updater};
|
|
||||||
|
|
||||||
// Get the current instant. This may require a system call, but it may also
|
|
||||||
// be faster than the stdlib equivalent.
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
// Get the latest known instant. This operation is super fast.
|
|
||||||
// In this case, the value will be identical to `now`, because we haven't
|
|
||||||
// updated the latest known instant yet.
|
|
||||||
let ts1 = Instant::recent();
|
|
||||||
|
|
||||||
// Update the latest known instant. This may require a system call.
|
|
||||||
// Note that a call to `Instant::now()` also updates the stored instant.
|
|
||||||
Instant::update();
|
|
||||||
|
|
||||||
// Now, we may get a different instant. This call is also super fast.
|
|
||||||
let ts2 = Instant::recent();
|
|
||||||
|
|
||||||
// Compute the time elapsed between ts2 and ts1.
|
|
||||||
let elapsed_ts2_ts1 = ts2.duration_since(ts1);
|
|
||||||
|
|
||||||
// Operations such as `+` and `-` between `Instant` and `Duration` are also
|
|
||||||
// available.
|
|
||||||
let elapsed_ts2_ts1 = ts2 - ts1;
|
|
||||||
|
|
||||||
// Returns the time elapsed since ts1.
|
|
||||||
// This retrieves the actual current time, and may require a system call.
|
|
||||||
let elapsed_since_ts1 = ts1.elapsed();
|
|
||||||
|
|
||||||
// Returns the approximate time elapsed since ts1.
|
|
||||||
// This uses the latest known instant, and is super fast.
|
|
||||||
let elapsed_since_recent = ts1.elapsed_since_recent();
|
|
||||||
|
|
||||||
// Instant::update() should be called periodically, for example using an
|
|
||||||
// event loop. Alternatively, the crate provides an easy way to spawn a
|
|
||||||
// background task that will periodically update the latest known instant.
|
|
||||||
// Here, the update will happen every 250ms.
|
|
||||||
let updater = Updater::new(250).start().unwrap();
|
|
||||||
|
|
||||||
// From now on, Instant::recent() will always return an approximation of the
|
|
||||||
// current instant.
|
|
||||||
let ts3 = Instant::recent();
|
|
||||||
|
|
||||||
// Stop the task.
|
|
||||||
updater.stop().unwrap();
|
|
||||||
|
|
||||||
// Returns the elapsed time since the UNIX epoch
|
|
||||||
let unix_timestamp = Clock::now_since_epoch();
|
|
||||||
|
|
||||||
// Returns an approximation of the elapsed time since the UNIX epoch, based on
|
|
||||||
// the latest time update
|
|
||||||
let unix_timestamp_approx = Clock::recent_since_epoch();
|
|
||||||
```
|
|
||||||
61
third_party/coarsetime/benches/benchmark.rs
vendored
61
third_party/coarsetime/benches/benchmark.rs
vendored
@@ -1,61 +0,0 @@
|
|||||||
use benchmark_simple::*;
|
|
||||||
use coarsetime::*;
|
|
||||||
use std::time;
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let options = &Options {
|
|
||||||
iterations: 250_000,
|
|
||||||
warmup_iterations: 25_000,
|
|
||||||
min_samples: 5,
|
|
||||||
max_samples: 10,
|
|
||||||
max_rsd: 1.0,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
bench_coarsetime_now(options);
|
|
||||||
bench_coarsetime_recent(options);
|
|
||||||
bench_coarsetime_elapsed(options);
|
|
||||||
bench_coarsetime_elapsed_since_recent(options);
|
|
||||||
bench_stdlib_now(options);
|
|
||||||
bench_stdlib_elapsed(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_coarsetime_now(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
Instant::update();
|
|
||||||
let res = b.run(options, Instant::now);
|
|
||||||
println!("coarsetime_now(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_coarsetime_recent(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
Instant::update();
|
|
||||||
let res = b.run(options, Instant::recent);
|
|
||||||
println!("coarsetime_recent(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_coarsetime_elapsed(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
let ts = Instant::now();
|
|
||||||
let res = b.run(options, || ts.elapsed());
|
|
||||||
println!("coarsetime_elapsed(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_coarsetime_elapsed_since_recent(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
let ts = Instant::now();
|
|
||||||
let res = b.run(options, || ts.elapsed_since_recent());
|
|
||||||
println!("coarsetime_since_recent(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_stdlib_now(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
let res = b.run(options, time::Instant::now);
|
|
||||||
println!("stdlib_now(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn bench_stdlib_elapsed(options: &Options) {
|
|
||||||
let b = Bench::new();
|
|
||||||
let ts = time::Instant::now();
|
|
||||||
let res = b.run(options, || ts.elapsed());
|
|
||||||
println!("stdlib_elapsed(): {}", res.throughput(1));
|
|
||||||
}
|
|
||||||
55
third_party/coarsetime/src/tests.rs
vendored
55
third_party/coarsetime/src/tests.rs
vendored
@@ -1,55 +0,0 @@
|
|||||||
use std::thread::sleep;
|
|
||||||
use std::time;
|
|
||||||
|
|
||||||
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
|
|
||||||
use super::Updater;
|
|
||||||
use super::{Clock, Duration, Instant};
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tests() {
|
|
||||||
let ts = Instant::now();
|
|
||||||
let d = Duration::from_secs(2);
|
|
||||||
sleep(time::Duration::new(3, 0));
|
|
||||||
let elapsed = ts.elapsed().as_secs();
|
|
||||||
println!("Elapsed: {elapsed} secs");
|
|
||||||
assert!(elapsed >= 2);
|
|
||||||
assert!(elapsed < 100);
|
|
||||||
assert!(ts.elapsed_since_recent() > d);
|
|
||||||
|
|
||||||
let ts = Instant::now();
|
|
||||||
sleep(time::Duration::new(1, 0));
|
|
||||||
assert_eq!(Instant::recent(), ts);
|
|
||||||
Instant::update();
|
|
||||||
assert!(Instant::recent() > ts);
|
|
||||||
|
|
||||||
let clock_now = Clock::recent_since_epoch();
|
|
||||||
sleep(time::Duration::new(1, 0));
|
|
||||||
assert_eq!(Clock::recent_since_epoch(), clock_now);
|
|
||||||
assert!(Clock::now_since_epoch() > clock_now);
|
|
||||||
|
|
||||||
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
|
|
||||||
tests_updater();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(any(target_arch = "wasm32", target_arch = "wasm64")))]
|
|
||||||
#[test]
|
|
||||||
fn tests_updater() {
|
|
||||||
let updater = Updater::new(250)
|
|
||||||
.start()
|
|
||||||
.expect("Unable to start a background updater");
|
|
||||||
let ts = Instant::recent();
|
|
||||||
let clock_recent = Clock::recent_since_epoch();
|
|
||||||
sleep(time::Duration::new(2, 0));
|
|
||||||
assert!(Clock::recent_since_epoch() > clock_recent);
|
|
||||||
assert!(Instant::recent() != ts);
|
|
||||||
updater.stop().unwrap();
|
|
||||||
let clock_recent = Clock::recent_since_epoch();
|
|
||||||
sleep(time::Duration::new(1, 0));
|
|
||||||
assert_eq!(Clock::recent_since_epoch(), clock_recent);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tests_duration() {
|
|
||||||
let duration = Duration::from_days(1000);
|
|
||||||
assert_eq!(duration.as_days(), 1000);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user