use std::time::Duration; use reqwest::{Client, Url}; use serde::Deserialize; use tracing::warn; use crate::error::Result; use crate::lang; /// Timeout for copyparty search requests; autocomplete has a 3s Discord budget, /// so this must be comfortably lower. const SEARCH_HTTP_TIMEOUT: Duration = Duration::from_secs(4); #[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() && 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.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, } } /// Replace Copyparty's `pw` query parameter with `***` for logs and errors. /// Leaves non-secret URLs untouched. pub fn redact_url(url: &str) -> String { let Ok(mut parsed) = Url::parse(url) else { return url.to_string(); }; let mut redacted = false; let pairs: Vec<(String, String)> = parsed .query_pairs() .map(|(key, value)| { let value = if key.eq_ignore_ascii_case("pw") { redacted = true; "***".to_string() } else { value.into_owned() }; (key.into_owned(), value) }) .collect(); if redacted { parsed.query_pairs_mut().clear(); parsed.query_pairs_mut().extend_pairs(pairs); } parsed.to_string() } #[cfg(test)] mod tests { use super::*; fn client(password: Option<&str>) -> CopypartyClient { CopypartyClient::new( "http://localhost:3939".into(), "http://localhost:3939/uploads".into(), password.map(str::to_string), ) } #[test] fn authenticates_only_own_host() { let c = client(Some("s3cret")); assert!(c.authenticate_url("http://localhost:3939/a.mp3").contains("pw=s3cret")); assert_eq!( c.authenticate_url("https://evil.example/a.mp3"), "https://evil.example/a.mp3" ); // Same host, different scheme/port is not our Copyparty. assert_eq!( c.authenticate_url("https://localhost:3939/a.mp3"), "https://localhost:3939/a.mp3" ); } #[test] fn encodes_password_and_keeps_existing_query() { let c = client(Some("p&w/word")); let url = c.authenticate_url("http://localhost:3939/a.mp3?download=1"); assert!(url.contains("download=1"), "{url}"); assert!(url.contains("pw=p%26w%2Fword"), "{url}"); assert_eq!(url.matches("pw=").count(), 1, "{url}"); } #[test] fn build_url_for_handles_paths_and_urls() { let c = client(Some("pw")); assert!(c.build_url_for("/music/a.mp3").contains("pw=pw")); assert_eq!( c.build_url_for("https://evil.example/a.mp3"), "https://evil.example/a.mp3" ); } #[test] fn redact_hides_password() { assert_eq!( redact_url("http://localhost:3939/a.mp3?pw=secret&x=1"), "http://localhost:3939/a.mp3?pw=***&x=1" ); assert_eq!(redact_url("https://example.com/a"), "https://example.com/a"); } } /// 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(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 { // A trailing slash would produce `//` when a path is appended. base_url: base_url.trim_end_matches('/').to_string(), upload_url: upload_url.trim_end_matches('/').to_string(), password, client: Client::builder() .connect_timeout(Duration::from_secs(10)) .build() .unwrap_or_default(), } } /// 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 { self.authenticate_url(&self.build_url_no_pw(path)) } /// Build full URL without the password parameter. pub fn build_url_no_pw(&self, path: &str) -> String { if path.starts_with('/') { format!("{}{}", self.base_url, path) } else { format!("{}/{}", self.base_url, path) } } /// Add the Copyparty password to a URL **only when it points at our own /// Copyparty host**. This is the safe variant of `build_url` for URLs that /// may have come from a user (cron audio, playlists, redirects): a foreign /// host must never receive `?pw=...`. pub fn authenticate_url(&self, url: &str) -> String { let Some(password) = &self.password else { return url.to_string(); }; if !self.is_own_url(url) { return url.to_string(); } let Ok(mut parsed) = Url::parse(url) else { return url.to_string(); }; // Replace any existing `pw` parameter rather than appending a second // one (also handles URLs that already carry a query string). let pairs: Vec<(String, String)> = parsed .query_pairs() .filter(|(key, _)| key != "pw") .map(|(key, value)| (key.into_owned(), value.into_owned())) .collect(); parsed.query_pairs_mut().clear(); parsed.query_pairs_mut().extend_pairs(pairs); parsed.query_pairs_mut().append_pair("pw", password); parsed.to_string() } /// Accept either a path or a full URL: paths are resolved against the /// configured Copyparty base URL, absolute URLs are authenticated only /// when they belong to Copyparty. pub fn build_url_for(&self, path_or_url: &str) -> String { if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") { self.authenticate_url(path_or_url) } else { self.build_url(path_or_url) } } /// Is `url` the same origin as the configured Copyparty base URL? fn is_own_url(&self, url: &str) -> bool { let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else { return false; }; candidate.scheme() == base.scheme() && candidate.host_str() == base.host_str() && candidate.port_or_known_default() == base.port_or_known_default() } /// 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) .timeout(SEARCH_HTTP_TIMEOUT); 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())) } }