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
Some checks failed
Build MIPS Binary / build (push) Failing after 1m27s
This commit is contained in:
@@ -1,8 +1,32 @@
|
||||
[package]
|
||||
name = "shared"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
ssr = [
|
||||
"dep:tokio",
|
||||
"dep:bytes",
|
||||
"dep:thiserror",
|
||||
"dep:quick-xml",
|
||||
"dep:leptos_axum",
|
||||
"leptos/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
utoipa = { version = "5.4.0", features = ["axum_extras"] }
|
||||
|
||||
# Leptos 0.8.7
|
||||
leptos = { version = "0.8.7", features = ["nightly"] }
|
||||
leptos_router = { version = "0.8.7", features = ["nightly"] }
|
||||
leptos_axum = { version = "0.8.7", optional = true }
|
||||
|
||||
# SSR Dependencies (XML-RPC & SCGI)
|
||||
tokio = { version = "1", features = ["full"], optional = true }
|
||||
bytes = { version = "1", optional = true }
|
||||
thiserror = { version = "2", optional = true }
|
||||
quick-xml = { version = "0.31", features = ["serde", "serialize"], optional = true }
|
||||
@@ -1,6 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod scgi;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
pub mod xmlrpc;
|
||||
|
||||
pub mod server_fns;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, ToSchema)]
|
||||
pub struct Torrent {
|
||||
pub hash: String,
|
||||
@@ -135,4 +143,4 @@ pub struct SetLabelRequest {
|
||||
pub struct AddTorrentRequest {
|
||||
#[schema(example = "magnet:?xt=urn:btih:...")]
|
||||
pub uri: String,
|
||||
}
|
||||
}
|
||||
129
shared/src/scgi.rs
Normal file
129
shared/src/scgi.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
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 mut response_vec = response;
|
||||
|
||||
// Improved header stripping: find the first occurrence of "<?xml" OR double newline
|
||||
let patterns = [
|
||||
&b"\r\n\r\n"[..],
|
||||
&b"\n\n"[..],
|
||||
&b"<?xml"[..] // If headers are missing or weird, find start of XML
|
||||
];
|
||||
|
||||
let mut found_pos = None;
|
||||
for (i, pattern) in patterns.iter().enumerate() {
|
||||
if let Some(pos) = response_vec
|
||||
.windows(pattern.len())
|
||||
.position(|window| window == *pattern)
|
||||
{
|
||||
// For XML pattern, we keep it. For newlines, we skip them.
|
||||
if i == 2 {
|
||||
found_pos = Some(pos);
|
||||
} else {
|
||||
found_pos = Some(pos + pattern.len());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pos) = found_pos {
|
||||
Ok(Bytes::from(response_vec.split_off(pos)))
|
||||
} else {
|
||||
Ok(Bytes::from(response_vec))
|
||||
}
|
||||
}
|
||||
26
shared/src/server_fns/mod.rs
Normal file
26
shared/src/server_fns/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use leptos::*;
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[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!()
|
||||
}
|
||||
}
|
||||
351
shared/src/xmlrpc.rs
Normal file
351
shared/src/xmlrpc.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
#![cfg(feature = "ssr")]
|
||||
|
||||
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", ¶ms).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", ¶ms).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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user