From cd8ed8bc5793985df2c1692072a932ceee517b95 Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 8 Jul 2026 02:13:35 +0200 Subject: OPEN THE RUST GATES Signed-off-by: salmonik --- src/copyparty/mod.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 src/copyparty/mod.rs (limited to 'src/copyparty/mod.rs') diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs new file mode 100644 index 0000000..00bfd53 --- /dev/null +++ b/src/copyparty/mod.rs @@ -0,0 +1,267 @@ +use reqwest::Client; +use serde::Deserialize; +use tracing::warn; + +use crate::error::Result; +use crate::lang; + +#[derive(Clone)] +pub struct CopypartyClient { + pub base_url: String, + pub upload_url: String, + pub password: Option, + client: Client, +} + +/// Decode percent-encoded copyparty paths (e.g. `%20` → space, `%C5%BA` → `ź`). +pub fn percent_decode(s: &str) -> String { + let mut buf: Vec = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let b = bytes[i]; + if b == b'%' && i + 2 < bytes.len() { + if let (Some(hi), Some(lo)) = (hex_nibble(bytes[i + 1]), hex_nibble(bytes[i + 2])) { + buf.push(hi << 4 | lo); + i += 3; + continue; + } + } + if b == b'+' { + buf.push(b' '); + } else { + buf.push(b); + } + i += 1; + } + String::from_utf8_lossy(&buf).into_owned() +} + +/// Decode common HTML entities in a display string. +/// Handles named (`&`, `<`) and numeric (`{`, `{`) forms. +/// Uses a byte buffer (like `percent_decode`) to preserve multi-byte UTF-8. +pub fn html_decode(s: &str) -> String { + let mut buf: Vec = Vec::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'&' { + let rest = &s[i..]; + if let Some(end) = rest.find(';') { + let entity = &rest[..=end]; + match entity { + "&" => buf.extend_from_slice(b"&"), + "<" => buf.extend_from_slice(b"<"), + ">" => buf.extend_from_slice(b">"), + """ => buf.extend_from_slice(b"\""), + "'" => buf.extend_from_slice(b"'"), + " " => buf.extend_from_slice(b" "), + _ if entity.as_bytes().len() > 3 && entity.as_bytes()[1] == b'#' => { + let (num_str, radix) = + if entity.as_bytes()[2] == b'x' || entity.as_bytes()[2] == b'X' { + (&entity[3..entity.len() - 1], 16u32) + } else { + (&entity[2..entity.len() - 1], 10u32) + }; + let decoded = u32::from_str_radix(num_str, radix) + .ok() + .and_then(std::char::from_u32); + if let Some(c) = decoded { + let mut tmp = [0u8; 4]; + buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes()); + i += entity.len(); + continue; + } + buf.extend_from_slice(entity.as_bytes()); + } + _ => buf.extend_from_slice(entity.as_bytes()), + } + i += entity.len(); + continue; + } + } + buf.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&buf).into_owned() +} + +fn hex_nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'A'..=b'F' => Some(b - b'A' + 10), + b'a'..=b'f' => Some(b - b'a' + 10), + _ => None, + } +} + +/// A search result from copyparty's `/?srch` API. +/// +/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). +/// The `name` / `url` / `path` fields are typically only present in directory +/// listing (`?ls`) responses, not in search. Use the convenience accessors +/// below instead of reading fields directly. +#[derive(Debug, Clone, Deserialize)] +pub struct SearchHit { + /// Relative path including filename (e.g. `/music/foo.mp3`). + /// This is the primary field populated by `/?srch`. + #[serde(default)] + pub rp: Option, + /// Path component (directory listing only) + #[serde(default)] + pub path: Option, + /// URL fragment for building a download URL + #[serde(default)] + pub url: Option, + /// Bare filename (directory listing only) + #[serde(default)] + pub name: Option, +} + +impl SearchHit { + /// Best-effort filename extraction: tries `name`, then `rp` tail, then `url` tail. + pub fn filename(&self) -> Option<&str> { + self.name + .as_deref() + .or_else(|| self.rp.as_deref().and_then(|p| p.rsplit('/').next())) + .or_else(|| self.url.as_deref().and_then(|u| u.rsplit('/').next())) + } + + /// Best-effort URL path for building a full download URL. + /// Tries `url`, then `rp`. + pub fn url_path(&self) -> Option<&str> { + self.url.as_deref().or_else(|| self.rp.as_deref()) + } +} + +#[derive(Debug, Deserialize)] +pub struct UploadResponse { + pub fileurl: Option, +} + +impl CopypartyClient { + pub fn new(base_url: String, upload_url: String, password: Option) -> Self { + Self { + base_url, + upload_url, + password, + client: Client::new(), + } + } + + /// Build a full Copyparty URL from a path, appending `?pw=PASSWORD` + /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) + /// or relative (`music/foo.mp3`). + pub fn build_url(&self, path: &str) -> String { + let prefixed = if path.starts_with('/') { + format!("{}{}", self.base_url, path) + } else { + format!("{}/{}", self.base_url, path) + }; + if let Some(pw) = &self.password { + format!("{prefixed}?pw={pw}") + } else { + prefixed + } + } + + /// Search copyparty via the jPOST `/?srch` endpoint. + /// + /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python + /// `_extract_files_from_response` helper), because the exact JSON shape can vary + /// across copyparty versions. + pub async fn search( + &self, + query: &str, + max_results: u32, + ext_filter: &[&str], + ) -> Result> { + let tokens: Vec<&str> = query.split_whitespace().collect(); + let conditions: Vec = tokens + .iter() + .map(|t| format!("(path like \"*{t}*\" or name like \"*{t}*\")")) + .collect(); + + let mut q = conditions.join(" and "); + if !ext_filter.is_empty() { + let ext_conditions: Vec = ext_filter + .iter() + .map(|e| format!("name like \"*.{e}\"")) + .collect(); + let ext_q = ext_conditions.join(" or "); + // When query has no tokens (e.g. empty autocomplete partial), + // avoid wrapping an empty string in parens → "() and ..." is invalid. + if q.is_empty() { + q = ext_q; + } else { + q = format!("({q}) and ({ext_q})"); + } + } + + let payload = serde_json::json!({"n": max_results, "q": q}); + + let mut req = self + .client + .post(format!("{}/?srch", self.base_url)) + .json(&payload); + if let Some(pw) = &self.password { + req = req.header("PW", pw); + } + + let resp = req.send().await?; + let status = resp.status(); + let body_text = resp.text().await?; + + if !status.is_success() { + let snippet: String = body_text.chars().take(200).collect(); + return Err(crate::error::Error::Custom(lang::fmt_cp_search_http( + &status, &snippet, + ))); + } + + let root: serde_json::Value = serde_json::from_str(&body_text).map_err(|e| { + let snippet: String = body_text.chars().take(200).collect(); + crate::error::Error::Custom(lang::fmt_cp_invalid_json(&e, &snippet)) + })?; + + // Extract hits array – copyparty wraps results in {"hits": [...]} + let hits_val = match &root { + serde_json::Value::Object(map) => map.get("hits"), + _ => None, + }; + + let hits: Vec = match hits_val { + Some(serde_json::Value::Array(arr)) => arr + .iter() + .filter_map(|v| SearchHit::deserialize(v).ok()) + .collect(), + _ => { + warn!( + "copyparty search response missing 'hits' array; body starts: {:?}", + body_text.chars().take(100).collect::() + ); + Vec::new() + } + }; + + Ok(hits) + } + + pub async fn upload(&self, file_name: &str, data: Vec) -> Result { + let part = reqwest::multipart::Part::bytes(data).file_name(file_name.to_string()); + let form = reqwest::multipart::Form::new().part("f", part); + + let mut req = self + .client + .post(format!("{}/?j=1&ck=b2", self.upload_url)) + .multipart(form); + + if let Some(pw) = &self.password { + req = req.header("Cookie", format!("cppwd={pw}")); + } + + let resp: UploadResponse = req.send().await?.json().await?; + resp.fileurl + .ok_or_else(|| crate::error::Error::Custom(lang::L_CP_UPLOAD_NO_URL.into())) + } +} -- cgit v1.2.3