aboutsummaryrefslogtreecommitdiffstats
path: root/src/copyparty/mod.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/copyparty/mod.rs')
-rw-r--r--src/copyparty/mod.rs139
1 files changed, 132 insertions, 7 deletions
diff --git a/src/copyparty/mod.rs b/src/copyparty/mod.rs
index d3620b2..1f075a0 100644
--- a/src/copyparty/mod.rs
+++ b/src/copyparty/mod.rs
@@ -1,6 +1,6 @@
1use std::time::Duration; 1use std::time::Duration;
2 2
3use reqwest::Client; 3use reqwest::{Client, Url};
4use serde::Deserialize; 4use serde::Deserialize;
5use tracing::warn; 5use tracing::warn;
6 6
@@ -100,6 +100,88 @@ fn hex_nibble(b: u8) -> Option<u8> {
100 } 100 }
101} 101}
102 102
103/// Replace Copyparty's `pw` query parameter with `***` for logs and errors.
104/// Leaves non-secret URLs untouched.
105pub fn redact_url(url: &str) -> String {
106 let Ok(mut parsed) = Url::parse(url) else {
107 return url.to_string();
108 };
109 let mut redacted = false;
110 let pairs: Vec<(String, String)> = parsed
111 .query_pairs()
112 .map(|(key, value)| {
113 let value = if key.eq_ignore_ascii_case("pw") {
114 redacted = true;
115 "***".to_string()
116 } else {
117 value.into_owned()
118 };
119 (key.into_owned(), value)
120 })
121 .collect();
122 if redacted {
123 parsed.query_pairs_mut().clear();
124 parsed.query_pairs_mut().extend_pairs(pairs);
125 }
126 parsed.to_string()
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn client(password: Option<&str>) -> CopypartyClient {
134 CopypartyClient::new(
135 "http://localhost:3939".into(),
136 "http://localhost:3939/uploads".into(),
137 password.map(str::to_string),
138 )
139 }
140
141 #[test]
142 fn authenticates_only_own_host() {
143 let c = client(Some("s3cret"));
144 assert!(c.authenticate_url("http://localhost:3939/a.mp3").contains("pw=s3cret"));
145 assert_eq!(
146 c.authenticate_url("https://evil.example/a.mp3"),
147 "https://evil.example/a.mp3"
148 );
149 // Same host, different scheme/port is not our Copyparty.
150 assert_eq!(
151 c.authenticate_url("https://localhost:3939/a.mp3"),
152 "https://localhost:3939/a.mp3"
153 );
154 }
155
156 #[test]
157 fn encodes_password_and_keeps_existing_query() {
158 let c = client(Some("p&w/word"));
159 let url = c.authenticate_url("http://localhost:3939/a.mp3?download=1");
160 assert!(url.contains("download=1"), "{url}");
161 assert!(url.contains("pw=p%26w%2Fword"), "{url}");
162 assert_eq!(url.matches("pw=").count(), 1, "{url}");
163 }
164
165 #[test]
166 fn build_url_for_handles_paths_and_urls() {
167 let c = client(Some("pw"));
168 assert!(c.build_url_for("/music/a.mp3").contains("pw=pw"));
169 assert_eq!(
170 c.build_url_for("https://evil.example/a.mp3"),
171 "https://evil.example/a.mp3"
172 );
173 }
174
175 #[test]
176 fn redact_hides_password() {
177 assert_eq!(
178 redact_url("http://localhost:3939/a.mp3?pw=secret&x=1"),
179 "http://localhost:3939/a.mp3?pw=***&x=1"
180 );
181 assert_eq!(redact_url("https://example.com/a"), "https://example.com/a");
182 }
183}
184
103/// A search result from copyparty's `/?srch` API. 185/// A search result from copyparty's `/?srch` API.
104/// 186///
105/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`). 187/// **Important**: `/?srch` responses use `rp` (relative path like `/music/foo.mp3`).
@@ -159,12 +241,7 @@ impl CopypartyClient {
159 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`) 241 /// if a password is configured. Path can be absolute (e.g. `/music/foo.mp3`)
160 /// or relative (`music/foo.mp3`). 242 /// or relative (`music/foo.mp3`).
161 pub fn build_url(&self, path: &str) -> String { 243 pub fn build_url(&self, path: &str) -> String {
162 let prefixed = self.build_url_no_pw(path); 244 self.authenticate_url(&self.build_url_no_pw(path))
163 if let Some(pw) = &self.password {
164 format!("{prefixed}?pw={pw}")
165 } else {
166 prefixed
167 }
168 } 245 }
169 246
170 /// Build full URL without the password parameter. 247 /// Build full URL without the password parameter.
@@ -176,6 +253,54 @@ impl CopypartyClient {
176 } 253 }
177 } 254 }
178 255
256 /// Add the Copyparty password to a URL **only when it points at our own
257 /// Copyparty host**. This is the safe variant of `build_url` for URLs that
258 /// may have come from a user (cron audio, playlists, redirects): a foreign
259 /// host must never receive `?pw=...`.
260 pub fn authenticate_url(&self, url: &str) -> String {
261 let Some(password) = &self.password else {
262 return url.to_string();
263 };
264 if !self.is_own_url(url) {
265 return url.to_string();
266 }
267 let Ok(mut parsed) = Url::parse(url) else {
268 return url.to_string();
269 };
270 // Replace any existing `pw` parameter rather than appending a second
271 // one (also handles URLs that already carry a query string).
272 let pairs: Vec<(String, String)> = parsed
273 .query_pairs()
274 .filter(|(key, _)| key != "pw")
275 .map(|(key, value)| (key.into_owned(), value.into_owned()))
276 .collect();
277 parsed.query_pairs_mut().clear();
278 parsed.query_pairs_mut().extend_pairs(pairs);
279 parsed.query_pairs_mut().append_pair("pw", password);
280 parsed.to_string()
281 }
282
283 /// Accept either a path or a full URL: paths are resolved against the
284 /// configured Copyparty base URL, absolute URLs are authenticated only
285 /// when they belong to Copyparty.
286 pub fn build_url_for(&self, path_or_url: &str) -> String {
287 if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
288 self.authenticate_url(path_or_url)
289 } else {
290 self.build_url(path_or_url)
291 }
292 }
293
294 /// Is `url` the same origin as the configured Copyparty base URL?
295 fn is_own_url(&self, url: &str) -> bool {
296 let (Ok(candidate), Ok(base)) = (Url::parse(url), Url::parse(&self.base_url)) else {
297 return false;
298 };
299 candidate.scheme() == base.scheme()
300 && candidate.host_str() == base.host_str()
301 && candidate.port_or_known_default() == base.port_or_known_default()
302 }
303
179 /// Search copyparty via the jPOST `/?srch` endpoint. 304 /// Search copyparty via the jPOST `/?srch` endpoint.
180 /// 305 ///
181 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python 306 /// Uses `serde_json::Value` for flexible response parsing (mirroring the Python