modernize: migrate to Leptos 0.8 and Server Functions architecture, break backend->shared loop
Some checks failed
Build MIPS Binary / build (push) Failing after 1m27s

This commit is contained in:
spinline
2026-02-09 20:07:28 +03:00
parent 5a8f5169ea
commit e6d00e9d55
11 changed files with 1026 additions and 384 deletions

View File

@@ -4,15 +4,15 @@ version = "0.1.0"
edition = "2021"
[features]
default = ["push-notifications", "swagger"]
default = ["swagger"] # push-notifications kaldırıldı
push-notifications = ["web-push", "openssl"]
swagger = ["utoipa-swagger-ui"]
[dependencies]
axum = { version = "0.8", features = ["macros", "ws"] }
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["util", "timeout"] }
tower-http = { version = "0.5", features = ["fs", "trace", "cors", "compression-full"] }
tower = { version = "0.5", features = ["util", "timeout"] }
tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
@@ -21,16 +21,15 @@ tokio-stream = "0.1"
bytes = "1"
futures = "0.3"
quick-xml = { version = "0.31", features = ["serde", "serialize"] }
# We might need `tokio-util` for codecs if we implement SCGI manually
tokio-util = { version = "0.7", features = ["codec", "io"] }
clap = { version = "4.4", features = ["derive", "env"] }
rust-embed = "8.2"
mime_guess = "2.0"
shared = { path = "../shared" }
shared = { path = "../shared", features = ["ssr"] }
thiserror = "2.0.18"
dotenvy = "0.15.7"
utoipa = { version = "5.4.0", features = ["axum_extras"] }
utoipa-swagger-ui = { version = "9.0.2", features = ["axum"], optional = true }
utoipa-swagger-ui = { version = "9.0", features = ["axum"], optional = true }
web-push = { version = "0.10", default-features = false, features = ["hyper-client"], optional = true }
base64 = "0.22"
openssl = { version = "0.10", features = ["vendored"], optional = true }
@@ -42,3 +41,7 @@ anyhow = "1.0.101"
time = { version = "0.3.47", features = ["serde", "formatting", "parsing"] }
tower_governor = "0.8.0"
governor = "0.10.4"
# Leptos
leptos = { version = "0.8.15", features = ["nightly"] }
leptos_axum = { version = "0.8.7" }

View File

@@ -1,7 +1,9 @@
use crate::{
use shared::{
xmlrpc::{self, RpcParam},
AppState,
AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentFile, TorrentPeer, TorrentTracker,
};
use crate::AppState;
#[cfg(feature = "push-notifications")]
use crate::push;
use axum::{
@@ -11,10 +13,6 @@ use axum::{
BoxError,
};
use rust_embed::RustEmbed;
use shared::{
AddTorrentRequest, GlobalLimitRequest, SetFilePriorityRequest, SetLabelRequest, TorrentActionRequest,
TorrentFile, TorrentPeer, TorrentTracker,
};
pub mod auth;
pub mod setup;

View File

@@ -4,9 +4,9 @@ mod handlers;
#[cfg(feature = "push-notifications")]
mod push;
mod rate_limit;
mod scgi;
mod sse;
mod xmlrpc;
use shared::{scgi, xmlrpc};
use axum::error_handling::HandleErrorLayer;
use axum::{
@@ -59,6 +59,7 @@ async fn auth_middleware(
if path.starts_with("/api/auth/login")
|| path.starts_with("/api/auth/check") // Used by frontend to decide where to go
|| path.starts_with("/api/setup")
|| path.starts_with("/api/server_fns")
|| path.starts_with("/swagger-ui")
|| path.starts_with("/api-docs")
|| !path.starts_with("/api/") // Allow static files (frontend)
@@ -528,6 +529,7 @@ async fn main() {
"/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")]

View File

@@ -1,109 +0,0 @@
use bytes::Bytes;
use std::collections::HashMap;
use thiserror::Error;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::UnixStream;
#[derive(Error, Debug)]
pub enum ScgiError {
#[error("IO Error: {0}")]
Io(#[from] std::io::Error),
#[allow(dead_code)]
#[error("Protocol Error: {0}")]
Protocol(String),
#[error("Timeout: SCGI request took too long")]
Timeout,
}
pub struct ScgiRequest {
headers: HashMap<String, String>,
body: Vec<u8>,
}
impl ScgiRequest {
pub fn new() -> Self {
let mut headers = HashMap::new();
headers.insert("SCGI".to_string(), "1".to_string());
Self {
headers,
body: Vec::new(),
}
}
pub fn _header(mut self, key: &str, value: &str) -> Self {
self.headers.insert(key.to_string(), value.to_string());
self
}
pub fn body(mut self, body: Vec<u8>) -> Self {
self.body = body;
self.headers
.insert("CONTENT_LENGTH".to_string(), self.body.len().to_string());
self
}
pub fn encode(&self) -> Vec<u8> {
let mut headers_data = Vec::new();
let content_len = self.body.len().to_string();
headers_data.extend_from_slice(b"CONTENT_LENGTH");
headers_data.push(0);
headers_data.extend_from_slice(content_len.as_bytes());
headers_data.push(0);
headers_data.extend_from_slice(b"SCGI");
headers_data.push(0);
headers_data.extend_from_slice(b"1");
headers_data.push(0);
for (k, v) in &self.headers {
if k == "CONTENT_LENGTH" || k == "SCGI" {
continue;
}
headers_data.extend_from_slice(k.as_bytes());
headers_data.push(0);
headers_data.extend_from_slice(v.as_bytes());
headers_data.push(0);
}
let headers_len = headers_data.len();
let mut packet = Vec::new();
let len_str = headers_len.to_string();
packet.extend_from_slice(len_str.as_bytes());
packet.push(b':');
packet.extend(headers_data);
packet.push(b',');
packet.extend(&self.body);
packet
}
}
pub async fn send_request(socket_path: &str, request: ScgiRequest) -> Result<Bytes, ScgiError> {
let perform_request = async {
let mut stream = UnixStream::connect(socket_path).await?;
let data = request.encode();
stream.write_all(&data).await?;
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
Ok::<Vec<u8>, std::io::Error>(response)
};
let response = tokio::time::timeout(std::time::Duration::from_secs(10), perform_request)
.await
.map_err(|_| ScgiError::Timeout)??;
let double_newline = b"\r\n\r\n";
let mut response_vec = response;
if let Some(pos) = response_vec
.windows(double_newline.len())
.position(|window| window == double_newline)
{
Ok(Bytes::from(
response_vec.split_off(pos + double_newline.len()),
))
} else {
Ok(Bytes::from(response_vec))
}
}

View File

@@ -1,4 +1,4 @@
use crate::xmlrpc::{
use shared::xmlrpc::{
parse_i64_response, parse_multicall_response, RpcParam, RtorrentClient, XmlRpcError,
};
use crate::AppState;

View File

@@ -1,349 +0,0 @@
use crate::scgi::{send_request, ScgiError, ScgiRequest};
use quick_xml::de::from_str;
use quick_xml::se::to_string;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum XmlRpcError {
#[error("SCGI Error: {0}")]
Scgi(#[from] ScgiError),
#[error("Serialization Error: {0}")]
Serialization(String),
#[error("Deserialization Error: {0}")]
Deserialization(#[from] quick_xml::de::DeError),
#[error("XML Parse Error: {0}")]
Parse(String),
}
// --- Request Parameters Enum ---
#[derive(Debug, Clone)]
pub enum RpcParam {
String(String),
Int(i64),
}
impl From<&str> for RpcParam {
fn from(s: &str) -> Self {
RpcParam::String(s.to_string())
}
}
impl From<String> for RpcParam {
fn from(s: String) -> Self {
RpcParam::String(s)
}
}
impl From<i64> for RpcParam {
fn from(i: i64) -> Self {
RpcParam::Int(i)
}
}
impl From<i32> for RpcParam {
fn from(i: i32) -> Self {
RpcParam::Int(i as i64)
}
}
// --- Request Models ---
#[derive(Debug, Serialize)]
#[serde(rename = "methodCall")]
struct MethodCall<'a> {
#[serde(rename = "methodName")]
method_name: &'a str,
params: RequestParams<'a>,
}
#[derive(Debug, Serialize)]
struct RequestParams<'a> {
param: Vec<RequestParam<'a>>,
}
#[derive(Debug, Serialize)]
struct RequestParam<'a> {
value: RequestValueInner<'a>,
}
#[derive(Debug, Serialize)]
struct RequestValueInner<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
string: Option<&'a str>,
// rTorrent standard is often i4 (32-bit signed int). i8 might not be supported by all XML-RPC libs.
// Casting i64 to i32 is safe for typical speed limits.
#[serde(skip_serializing_if = "Option::is_none")]
i4: Option<i32>,
}
// --- Response Models for d.multicall2 ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct MulticallResponse {
params: MulticallResponseParams,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseParams {
param: MulticallResponseParam,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseParam {
value: MulticallResponseValueArray,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseValueArray {
array: MulticallResponseDataOuter,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataOuter {
data: MulticallResponseDataOuterValue,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataOuterValue {
#[serde(rename = "value", default)]
values: Vec<MulticallRowValue>,
}
#[derive(Debug, Deserialize)]
struct MulticallRowValue {
array: MulticallResponseDataInner,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataInner {
data: MulticallResponseDataInnerValue,
}
#[derive(Debug, Deserialize)]
struct MulticallResponseDataInnerValue {
#[serde(rename = "value", default)]
values: Vec<MulticallItemValue>,
}
#[derive(Debug, Deserialize)]
struct MulticallItemValue {
#[serde(rename = "string", default)]
string: Option<String>,
#[serde(rename = "i4", default)]
i4: Option<i64>,
#[serde(rename = "i8", default)]
i8: Option<i64>,
}
impl MulticallItemValue {
fn to_string_lossy(&self) -> String {
if let Some(s) = &self.string {
s.clone()
} else if let Some(i) = self.i4 {
i.to_string()
} else if let Some(i) = self.i8 {
i.to_string()
} else {
String::new()
}
}
}
// --- Response Model for simple string ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct StringResponse {
params: StringResponseParams,
}
#[derive(Debug, Deserialize)]
struct StringResponseParams {
param: StringResponseParam,
}
#[derive(Debug, Deserialize)]
struct StringResponseParam {
value: StringResponseValue,
}
#[derive(Debug, Deserialize)]
struct StringResponseValue {
string: String,
}
// --- Response Model for simple integer (i8/i4) ---
#[derive(Debug, Deserialize)]
#[serde(rename = "methodResponse")]
struct IntegerResponse {
params: IntegerResponseParams,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseParams {
param: IntegerResponseParam,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseParam {
value: IntegerResponseValue,
}
#[derive(Debug, Deserialize)]
struct IntegerResponseValue {
#[serde(rename = "i8", default)]
i8: Option<i64>,
#[serde(rename = "i4", default)]
i4: Option<i64>,
#[serde(rename = "string", default)]
string: Option<String>,
}
// --- Client Implementation ---
pub struct RtorrentClient {
socket_path: String,
}
impl RtorrentClient {
pub fn new(socket_path: &str) -> Self {
Self {
socket_path: socket_path.to_string(),
}
}
/// Helper to build and serialize XML-RPC method call
fn build_method_call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
let req_params = RequestParams {
param: params
.iter()
.map(|p| RequestParam {
value: match p {
RpcParam::String(s) => RequestValueInner {
string: Some(s),
i4: None,
},
RpcParam::Int(i) => RequestValueInner {
string: None,
i4: Some(*i as i32),
},
},
})
.collect(),
};
let call = MethodCall {
method_name: method,
params: req_params,
};
let xml_body = to_string(&call).map_err(|e| XmlRpcError::Serialization(e.to_string()))?;
Ok(format!("<?xml version=\"1.0\"?>\n{}", xml_body))
}
pub async fn call(&self, method: &str, params: &[RpcParam]) -> Result<String, XmlRpcError> {
let xml = self.build_method_call(method, params)?;
let req = ScgiRequest::new().body(xml.into_bytes());
let bytes = send_request(&self.socket_path, req).await?;
let s = String::from_utf8_lossy(&bytes).to_string();
Ok(s)
}
}
pub fn parse_multicall_response(xml: &str) -> Result<Vec<Vec<String>>, XmlRpcError> {
let response: MulticallResponse = from_str(xml)?;
let mut result = Vec::new();
for row in response.params.param.value.array.data.values {
let mut row_vec = Vec::new();
for item in row.array.data.values {
row_vec.push(item.to_string_lossy());
}
result.push(row_vec);
}
Ok(result)
}
pub fn parse_string_response(xml: &str) -> Result<String, XmlRpcError> {
let response: StringResponse = from_str(xml)?;
Ok(response.params.param.value.string)
}
pub fn parse_i64_response(xml: &str) -> Result<i64, XmlRpcError> {
let response: IntegerResponse = from_str(xml)?;
if let Some(val) = response.params.param.value.i8 {
Ok(val)
} else if let Some(val) = response.params.param.value.i4 {
Ok(val)
} else if let Some(ref s) = response.params.param.value.string {
s.parse()
.map_err(|_| XmlRpcError::Parse("Not an integer string".to_string()))
} else {
Err(XmlRpcError::Parse("No integer value found".to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_method_call() {
let client = RtorrentClient::new("dummy");
let params = vec![
RpcParam::String("".to_string()),
RpcParam::String("main".to_string()),
RpcParam::String("d.name=".to_string()),
];
let xml = client.build_method_call("d.multicall2", &params).unwrap();
assert!(xml.contains("<methodName>d.multicall2</methodName>"));
assert!(xml.contains("<value><string>main</string></value>"));
}
#[test]
fn test_build_method_call_int() {
let client = RtorrentClient::new("dummy");
let params = vec![RpcParam::Int(1024)];
let xml = client.build_method_call("test.int", &params).unwrap();
// Should produce <value><i4>1024</i4></value>
assert!(xml.contains("<i4>1024</i4>"));
}
#[test]
fn test_parse_multicall_response() {
let xml = r#"<methodResponse>
<params>
<param>
<value>
<array>
<data>
<value>
<array>
<data>
<value><string>HASH123</string></value>
<value><string>Ubuntu ISO</string></value>
<value><i4>1024</i4></value>
</data>
</array>
</value>
</data>
</array>
</value>
</param>
</params>
</methodResponse>
"#;
let result = parse_multicall_response(xml).expect("Failed to parse");
assert_eq!(result.len(), 1);
assert_eq!(result[0][0], "HASH123");
assert_eq!(result[0][1], "Ubuntu ISO");
assert_eq!(result[0][2], "1024");
}
}