From 2740197f87b2ab2b93259d3b1d12b49a6aa9d3c6 Mon Sep 17 00:00:00 2001 From: cuishuang Date: Mon, 7 Sep 2026 20:40:59 +0800 Subject: [PATCH] fix(relay): preserve queries in normalization and HTTP requests Normalize relay URL components without modifying query values. Compose CLI and ACP HTTP endpoint paths with Url, preserving base paths and raw relay queries while appending endpoint query parameters. Exercise production HTTP clients against local servers and assert received paths, raw queries, and NIP-98 signed URLs. Cover bridge reads and writes, metadata, uploads and their legacy fallback, and media downloads. Signed-off-by: cuishuang --- crates/buzz-acp/src/relay.rs | 71 ++++++++- crates/buzz-acp/src/relay/endpoint_tests.rs | 137 +++++++++++++++++ crates/buzz-cli/src/client.rs | 146 ++++++++++++++++--- crates/buzz-cli/src/client/endpoint_tests.rs | 123 ++++++++++++++++ crates/buzz-core/src/relay.rs | 89 ++++++++++- 5 files changed, 534 insertions(+), 32 deletions(-) create mode 100644 crates/buzz-acp/src/relay/endpoint_tests.rs create mode 100644 crates/buzz-cli/src/client/endpoint_tests.rs diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 0538233267e..f312d73a4a0 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -289,7 +289,17 @@ fn unix_now_secs() -> u64 { .as_secs() } +#[cfg(test)] +#[path = "relay/endpoint_tests.rs"] +mod endpoint_tests; + impl RestClient { + fn endpoint_url(&self, path: &str) -> Result { + buzz_core::relay::relay_http_endpoint(&self.base_url, path) + .map(String::from) + .map_err(|e| RelayError::Http(format!("invalid relay URL: {e}"))) + } + /// Fetch the relay's stable signing identity from its NIP-11 document. /// /// Relay-authored workflow attribution is trusted only when the event signer @@ -302,7 +312,7 @@ impl RestClient { let mut saw_document_without_self = false; for path in ["/", "/info"] { - let url = format!("{}{path}", self.base_url); + let url = self.endpoint_url(path)?; let response = match self .http .get(&url) @@ -477,7 +487,7 @@ impl RestClient { path: &str, body_bytes: &[u8], ) -> Result { - let url = format!("{}{}", self.base_url, path); + let url = self.endpoint_url(path)?; let body_owned = body_bytes.to_vec(); let auth_tag_header = self.auth_tag_json.clone(); self.request_with_retry("POST", path, || { @@ -3851,12 +3861,39 @@ async fn send_auth_response( /// /// `ws://host:port` → `http://host:port` /// `wss://host:port` → `https://host:port` -/// Trailing slashes are stripped. +/// A root path slash is removed; non-root paths and queries are preserved. pub(crate) fn relay_ws_to_http(url: &str) -> String { - url.replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() + let mut parsed = match url::Url::parse(url.trim()) { + Ok(parsed) => parsed, + Err(_) => return url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "wss" => "https".to_owned(), + "ws" => "http".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + let had_root_path = parsed.path() == "/"; + if had_root_path { + parsed.set_path(""); + } else if parsed.query().is_none() && parsed.fragment().is_none() { + let path = parsed.path().trim_end_matches('/').to_owned(); + parsed.set_path(&path); + } + let mut serialized = parsed.to_string(); + if had_root_path { + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } + } + serialized } /// Build the subscription ID for a channel: `ch-`. @@ -4594,6 +4631,26 @@ mod tests { ); } + #[test] + fn relay_ws_to_http_preserves_queries() { + assert_eq!( + relay_ws_to_http("wss://relay.example.com/?next=/"), + "https://relay.example.com?next=/" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/?url=wss://other.example/"), + "https://relay.example.com?url=wss://other.example/" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/nostr/"), + "https://relay.example.com/nostr" + ); + assert_eq!( + relay_ws_to_http("wss://relay.example.com/nostr/?next=/"), + "https://relay.example.com/nostr/?next=/" + ); + } + #[test] fn channel_sub_id_format() { let uuid = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); diff --git a/crates/buzz-acp/src/relay/endpoint_tests.rs b/crates/buzz-acp/src/relay/endpoint_tests.rs new file mode 100644 index 00000000000..5a2d3d961f6 --- /dev/null +++ b/crates/buzz-acp/src/relay/endpoint_tests.rs @@ -0,0 +1,137 @@ +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; + +const QUERY: &str = "token=a%2fb+%20&token=second&url=wss://other.example/&next=/"; + +#[tokio::test] +async fn endpoint_requests_preserve_path_query_and_signed_url() { + for base_path in ["", "/", "/nostr", "/nostr/"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (tx, mut rx) = mpsc::channel(8); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + // Read a complete request, including its body, before closing the socket. + let request = tokio::time::timeout(Duration::from_secs(5), async { + let mut bytes = Vec::new(); + loop { + let mut chunk = [0; 4096]; + let n = socket.read(&mut chunk).await.unwrap(); + assert!(n > 0, "incomplete request"); + bytes.extend_from_slice(&chunk[..n]); + assert!(bytes.len() <= 32768, "test request too large"); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8(bytes[..end].to_vec()).unwrap(); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) + .map(|(_, value)| value.trim().parse::().unwrap()) + .unwrap_or(0); + if bytes.len() >= end + 4 + content_length { + break headers; + } + } + } + }) + .await + .unwrap(); + let first_line = request.lines().next().unwrap(); + let target = first_line.split_whitespace().nth(1).unwrap(); + let path = target.split('?').next().unwrap(); + let (status, body) = if first_line.starts_with("GET ") { + if path.ends_with("/info") { + ( + "200 OK", + serde_json::json!({"self": "ab".repeat(32)}).to_string(), + ) + } else { + ("404 Not Found", "{}".to_owned()) + } + } else { + ("200 OK", "[]".to_owned()) + }; + tx.send(request).await.unwrap(); + let response = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()); + socket.write_all(response.as_bytes()).await.unwrap(); + } + }); + let client = RestClient { + http: reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(), + base_url: relay_ws_to_http(&format!("ws://{address}{base_path}?{QUERY}")), + keys: Keys::generate(), + auth_tag_json: None, + }; + let filters = [nostr::Filter::new().kind(Kind::TextNote)]; + client.query(&filters).await.unwrap(); + client + .query_raw(&[serde_json::json!({"kinds": [1]})]) + .await + .unwrap(); + client.count(&filters).await.unwrap(); + let event = EventBuilder::new(Kind::TextNote, "test") + .sign_with_keys(&client.keys) + .unwrap(); + client.submit_event(&event).await.unwrap(); + assert_eq!(client.relay_self().await.unwrap(), Some("ab".repeat(32))); + + for (method, route) in [ + ("POST", "/query"), + ("POST", "/query"), + ("POST", "/count"), + ("POST", "/events"), + ("GET", "/"), + ("GET", "/info"), + ] { + let request = tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + let target = request + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(1) + .unwrap(); + let (path, query) = target.split_once('?').unwrap(); + assert!(request.starts_with(&format!("{method} "))); + assert_eq!(path, format!("{}{route}", base_path.trim_end_matches('/'))); + assert_eq!(query, QUERY); + if method == "POST" { + let auth = request + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.eq_ignore_ascii_case("authorization")) + .unwrap() + .1 + .trim() + .strip_prefix("Nostr ") + .unwrap(); + use base64::Engine; + let event: nostr::Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(), + ) + .unwrap(); + event.verify().unwrap(); + let signed_url = event.tags.iter().find(|t| t.as_slice()[0] == "u").unwrap(); + assert_eq!( + signed_url.as_slice()[1], + format!("http://{address}{target}") + ); + } else { + assert!(request + .lines() + .any(|line| line.eq_ignore_ascii_case("accept: application/nostr+json"))); + } + } + assert!(rx.try_recv().is_err(), "unexpected extra request"); + server.abort(); + } +} diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index bed59b5ae04..29e2cd606e8 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -299,10 +299,9 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result Result { Ok(value) } +#[cfg(test)] +#[path = "client/endpoint_tests.rs"] +mod endpoint_tests; + impl BuzzClient { + fn endpoint_url(&self, path: &str) -> Result { + buzz_core::relay::relay_http_endpoint(&self.relay_url, path) + .map(String::from) + .map_err(|e| CliError::Usage(format!("invalid relay URL: {e}"))) + } + /// Create a new client pointing at `relay_url`. /// /// Timeout defaults are tuned for degraded WAN links and can be overridden @@ -951,7 +960,7 @@ impl BuzzClient { /// `x-auth-tag` header — the endpoint is public relay metadata, not a /// membership-scoped resource. pub async fn get_public(&self, path: &str) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let resp = self .http .get(&url) @@ -971,7 +980,7 @@ impl BuzzClient { /// Execute a one-shot query with multiple filters via the HTTP bridge. /// Each filter is ORed by the relay (standard Nostr REQ behavior). pub async fn query_multi(&self, filters: &[serde_json::Value]) -> Result { - let url = format!("{}/query", self.relay_url); + let url = self.endpoint_url("/query")?; let body = bytes::Bytes::from( serde_json::to_vec(filters) .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?, @@ -1000,7 +1009,7 @@ impl BuzzClient { /// Returns the count as a JSON string. #[allow(dead_code)] pub async fn count(&self, filter: &serde_json::Value) -> Result { - let url = format!("{}/count", self.relay_url); + let url = self.endpoint_url("/count")?; let body = bytes::Bytes::from( serde_json::to_vec(&[filter]) .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?, @@ -1032,7 +1041,7 @@ impl BuzzClient { /// read commands, which read structured queue/audit rows rather than /// stored events. pub async fn get_authed(&self, path: &str) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; self.with_retry_body(|| { let url = url.clone(); async move { @@ -1063,7 +1072,7 @@ impl BuzzClient { path: &str, body: &serde_json::Value, ) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let body_bytes = bytes::Bytes::from( serde_json::to_vec(body) .map_err(|e| CliError::Other(format!("request serialization failed: {e}")))?, @@ -1100,7 +1109,7 @@ impl BuzzClient { path: &str, body: &serde_json::Value, ) -> Result { - let url = format!("{}{path}", self.relay_url); + let url = self.endpoint_url(path)?; let body = serde_json::to_vec(body).map_err(|e| CliError::Other(e.to_string()))?; let unknown = |detail: String| CliError::DeliveryUnknown(detail); let http = reqwest::Client::builder() @@ -1160,7 +1169,7 @@ impl BuzzClient { /// Submit a moderation command (kinds 9040–9044) with non-idempotent retry policy. async fn submit_moderation_event(&self, event: nostr::Event) -> Result { - let url = format!("{}/events", self.relay_url); + let url = self.endpoint_url("/events")?; let body = bytes::Bytes::from( serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, @@ -1291,7 +1300,7 @@ impl BuzzClient { /// Content-addressed uploads are exempt: same bytes ⇒ same hash, so outer /// re-run is safe regardless of the failure kind. async fn submit_stored_event(&self, event: nostr::Event) -> Result { - let url = format!("{}/events", self.relay_url); + let url = self.endpoint_url("/events")?; let body = bytes::Bytes::from( serde_json::to_vec(&event) .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?, @@ -1481,7 +1490,7 @@ impl BuzzClient { } else { Duration::from_secs(120) }; - let url = format!("{}/upload", self.relay_url); + let url = self.endpoint_url("/upload")?; let upload_body = bytes::Bytes::from(bytes); // The full upload operation — network send AND response body read — lives inside @@ -1532,7 +1541,7 @@ impl BuzzClient { Err(e) => return Err(e), } - let legacy_url = format!("{}/media/upload", self.relay_url); + let legacy_url = self.endpoint_url("/media/upload")?; self.with_retry_body(|| { let upload_body = upload_body.clone(); let legacy_url = legacy_url.clone(); @@ -1601,20 +1610,68 @@ impl BuzzClient { } } -/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash. +/// Normalize a relay URL: ws:// → http:// and wss:// → https://. /// BUZZ_RELAY_URL may be ws/wss (copied from MCP config). pub fn normalize_relay_url(url: &str) -> String { - url.replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() + let mut parsed = match url::Url::parse(url.trim()) { + Ok(parsed) => parsed, + Err(_) => return url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "wss" => "https".to_owned(), + "ws" => "http".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + let had_root_path = parsed.path() == "/"; + if had_root_path { + parsed.set_path(""); + } else if parsed.query().is_none() && parsed.fragment().is_none() { + let path = parsed.path().trim_end_matches('/').to_owned(); + parsed.set_path(&path); + } + serialize_relay_url(parsed, had_root_path) } /// Convert an HTTP(S) relay base URL back to a WebSocket URL for NIP-01 connections. fn to_ws_url(http_url: &str) -> String { - http_url - .replace("https://", "wss://") - .replace("http://", "ws://") + let mut parsed = match url::Url::parse(http_url.trim()) { + Ok(parsed) => parsed, + Err(_) => return http_url.trim().to_string(), + }; + let scheme = match parsed.scheme() { + "https" => "wss".to_owned(), + "http" => "ws".to_owned(), + scheme => scheme.to_owned(), + }; + let _ = parsed.set_scheme(&scheme); + // NIP-42 must name the configured path, including its trailing slash. + // Only remove a root slash that the parser inserted into a pathless URL. + let remove_root_slash = parsed.path() == "/" + && !http_url + .trim() + .split(['?', '#']) + .next() + .is_some_and(|base| base.ends_with('/')); + serialize_relay_url(parsed, remove_root_slash) +} + +fn serialize_relay_url(parsed: url::Url, had_root_path: bool) -> String { + let mut serialized = parsed.to_string(); + if !had_root_path { + return serialized; + } + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } + serialized } /// Normalize raw event JSON array into the canonical Nostr event shape. @@ -2673,10 +2730,53 @@ mod tests { use super::{ advance_query_cursor, create_response_with_id_if_accepted, extract_relay_response_field, - normalize_events, BuzzClient, Credentials, Proof, + normalize_events, normalize_relay_url, to_ws_url, BuzzClient, Credentials, Proof, }; use nostr::{EventBuilder, Keys, Kind, Tag}; + #[test] + fn relay_url_scheme_conversion_preserves_query_bytes() { + assert_eq!( + normalize_relay_url("wss://relay.example/?next=/"), + "https://relay.example?next=/" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/?url=wss://other.example/"), + "https://relay.example?url=wss://other.example/" + ); + assert_eq!( + to_ws_url("https://relay.example/?next=/foo/"), + "wss://relay.example/?next=/foo/" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/nostr/"), + "https://relay.example/nostr" + ); + assert_eq!( + normalize_relay_url("wss://relay.example/nostr/?next=/"), + "https://relay.example/nostr/?next=/" + ); + } + + #[test] + fn websocket_scheme_conversion_preserves_configured_path() { + for (input, expected) in [ + ("https://relay.example", "wss://relay.example"), + ("https://relay.example/", "wss://relay.example/"), + ("https://relay.example/team/", "wss://relay.example/team/"), + ( + "https://relay.example/team/?next=/", + "wss://relay.example/team/?next=/", + ), + ( + "http://relay.example?url=https://other/", + "ws://relay.example?url=https://other/", + ), + ] { + assert_eq!(to_ws_url(input), expected); + } + } + #[test] fn normalize_events_preserves_the_complete_signed_event_shape() { let signed_event = EventBuilder::new(Kind::TextNote, "signed content") diff --git a/crates/buzz-cli/src/client/endpoint_tests.rs b/crates/buzz-cli/src/client/endpoint_tests.rs new file mode 100644 index 00000000000..b1055f2b1ab --- /dev/null +++ b/crates/buzz-cli/src/client/endpoint_tests.rs @@ -0,0 +1,123 @@ +use super::*; +use axum::body::Bytes; +use axum::http::{HeaderMap, Method, StatusCode, Uri}; +use axum::Router; +use nostr::Keys; +use tokio::sync::mpsc; + +// Include duplicates, escaping, literal '+', nested schemes and a trailing slash. +const QUERY: &str = "token=a%2fb+%20&token=second&url=wss://other.example/&next=/"; + +#[tokio::test] +async fn endpoint_requests_preserve_path_query_and_signed_url() { + for base_path in ["", "/", "/nostr", "/nostr/", "/n%2Fostr/"] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let origin = format!("http://{address}"); + let (tx, mut rx) = mpsc::channel(16); + let app = Router::new().fallback( + move |method: Method, uri: Uri, headers: HeaderMap, _body: Bytes| { + let tx = tx.clone(); + async move { + tx.send((method, uri.clone(), headers)).await.unwrap(); + if uri.path().ends_with("/media/upload") { + (StatusCode::OK, r#"{"url":"http://example.test/media/blob","sha256":"abc","size":8,"type":"image/png","uploaded":0}"#) + } else if uri.path().ends_with("/upload") { + // Exercise the legacy fallback as well as the primary upload. + (StatusCode::NOT_FOUND, "{}") + } else { + (StatusCode::OK, "[]") + } + } + }, + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let base = normalize_relay_url(&format!("ws://{address}{base_path}?{QUERY}")); + let client = BuzzClient::new(base, Credentials::from_keys(Keys::generate())).unwrap(); + let prefix = base_path.trim_end_matches('/'); + let filter = serde_json::json!({"kinds": [1]}); + + // Each call below exercises a distinct production request construction site. + let mut expected = Vec::new(); + client.get_public("/info").await.unwrap(); + expected.push(("GET", "/info".to_owned(), QUERY.to_owned(), false)); + client.query(&filter).await.unwrap(); + expected.push(("POST", "/query".to_owned(), QUERY.to_owned(), true)); + client.count(&filter).await.unwrap(); + expected.push(("POST", "/count".to_owned(), QUERY.to_owned(), true)); + client + .get_authed("/moderation/reports?status=open&limit=20") + .await + .unwrap(); + expected.push(( + "GET", + "/moderation/reports".to_owned(), + format!("{QUERY}&status=open&limit=20"), + true, + )); + client + .post_json_authed("/gifs/search", &serde_json::json!({})) + .await + .unwrap(); + expected.push(("POST", "/gifs/search".to_owned(), QUERY.to_owned(), true)); + client + .post_json_once_authed("/commands?version=1", &serde_json::json!({})) + .await + .unwrap(); + expected.push(( + "POST", + "/commands".to_owned(), + format!("{QUERY}&version=1"), + true, + )); + for kind in [1, 9040] { + let event = client + .sign_event(EventBuilder::new(Kind::Custom(kind), "test")) + .await + .unwrap(); + client.submit_event(event).await.unwrap(); + expected.push(("POST", "/events".to_owned(), QUERY.to_owned(), true)); + } + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), b"\x89PNG\r\n\x1a\n").unwrap(); + client + .upload_file(file.path().to_str().unwrap()) + .await + .unwrap(); + for path in ["/upload", "/media/upload"] { + expected.push(("PUT", path.to_owned(), QUERY.to_owned(), false)); + } + let hash = "ab".repeat(32); + client.download_media(&hash).await.unwrap(); + expected.push(("GET", format!("/media/{hash}"), QUERY.to_owned(), false)); + + for (method, route, query, nip98) in expected { + let (received_method, uri, headers) = + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(received_method, method); + assert_eq!(uri.path(), format!("{prefix}{route}")); + assert_eq!(uri.query(), Some(query.as_str())); + if nip98 { + let auth = headers["authorization"] + .to_str() + .unwrap() + .strip_prefix("Nostr ") + .unwrap(); + let event: nostr::Event = serde_json::from_slice( + &base64::engine::general_purpose::STANDARD + .decode(auth) + .unwrap(), + ) + .unwrap(); + event.verify().unwrap(); + let signed_url = event.tags.iter().find(|t| t.as_slice()[0] == "u").unwrap(); + assert_eq!(signed_url.as_slice()[1], format!("{origin}{uri}")); + } + } + assert!(rx.try_recv().is_err(), "unexpected extra request"); + server.abort(); + } +} diff --git a/crates/buzz-core/src/relay.rs b/crates/buzz-core/src/relay.rs index 77c74a069ad..b27940916d1 100644 --- a/crates/buzz-core/src/relay.rs +++ b/crates/buzz-core/src/relay.rs @@ -3,6 +3,35 @@ use thiserror::Error; use url::{Host, Url}; +/// Append an HTTP endpoint to a relay base path without changing its query bytes. +/// +/// `endpoint` is a relay-relative path, optionally with a query string. Endpoint +/// query parameters follow the configured relay parameters, without decoding or +/// re-encoding either query. A base fragment is omitted from the request URL. +pub fn relay_http_endpoint(base: &str, endpoint: &str) -> Result { + let mut url = Url::parse(base)?; + let (path, query) = endpoint + .split_once('?') + .map_or((endpoint, None), |(path, query)| (path, Some(query))); + url.set_path(&format!( + "{}/{}", + url.path().trim_end_matches('/'), + path.trim_start_matches('/') + )); + if let Some(query) = query { + let combined = match url.query() { + Some(base_query) if !base_query.is_empty() && !query.is_empty() => { + format!("{base_query}&{query}") + } + Some(base_query) if query.is_empty() => base_query.to_owned(), + _ => query.to_owned(), + }; + url.set_query(Some(&combined)); + } + url.set_fragment(None); + Ok(url) +} + /// Errors returned while canonicalizing a relay URL for runtime identity. #[derive(Debug, Error, PartialEq, Eq)] pub enum NormalizeRelayUrlError { @@ -71,16 +100,55 @@ pub fn normalize_relay_url(raw: &str) -> Result url.set_port(None) .map_err(|_| NormalizeRelayUrlError::InvalidScheme)?; } - if url.path() == "/" { + let had_root_path = url.path() == "/"; + if had_root_path { url.set_path(""); } - Ok(url.to_string().trim_end_matches('/').to_string()) + let mut serialized = url.to_string(); + if had_root_path { + remove_root_path_separator(&mut serialized); + } + Ok(serialized) +} + +fn remove_root_path_separator(serialized: &mut String) { + let delimiter = serialized.find(['?', '#']); + match delimiter { + Some(index) if serialized.as_bytes().get(index.wrapping_sub(1)) == Some(&b'/') => { + serialized.remove(index - 1); + } + None if serialized.ends_with('/') => { + serialized.pop(); + } + _ => {} + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn http_endpoints_preserve_base_paths_and_raw_queries() { + for (base, endpoint, expected) in [ + ("https://relay.example", "/query", "https://relay.example/query"), + ("https://relay.example/nostr/", "/query", "https://relay.example/nostr/query"), + ("https://relay.example?token=abc", "/query", "https://relay.example/query?token=abc"), + ("https://relay.example/nostr/?token=abc", "/query", "https://relay.example/nostr/query?token=abc"), + ("https://relay.example/n%2Fostr?next=/", "/info", "https://relay.example/n%2Fostr/info?next=/"), + ("https://relay.example?token=a%2fb+%20&token=2&url=wss://other/", "/reports?status=open", "https://relay.example/reports?token=a%2fb+%20&token=2&url=wss://other/&status=open"), + ("https://relay.example/nostr/?token=abc", "/", "https://relay.example/nostr/?token=abc"), + ("http://relay.example:8080?", "/query", "http://relay.example:8080/query?"), + ("https://relay.example?", "/reports?status=open", "https://relay.example/reports?status=open"), + ("https://relay.example?token=abc", "/query?", "https://relay.example/query?token=abc"), + ("https://relay.example", "/reports?status=open", "https://relay.example/reports?status=open"), + ("https://relay.example?token=abc#fragment", "/query", "https://relay.example/query?token=abc"), + ] { + assert_eq!(relay_http_endpoint(base, endpoint).unwrap().as_str(), expected); + } + assert!(relay_http_endpoint("not a URL", "/query").is_err()); + } + #[test] fn loopback_spellings_have_one_identity() { let ipv6 = normalize_relay_url("wss://[::1]/").unwrap(); @@ -103,6 +171,23 @@ mod tests { ); } + #[test] + fn preserves_queries_when_removing_root_path() { + for (input, expected) in [ + ("wss://relay.example/?next=/", "wss://relay.example?next=/"), + ( + "wss://relay.example/?next=/foo/", + "wss://relay.example?next=/foo/", + ), + ( + "wss://relay.example/?url=wss://other.example/", + "wss://relay.example?url=wss://other.example/", + ), + ] { + assert_eq!(normalize_relay_url(input).unwrap(), expected); + } + } + #[test] fn rejects_non_relay_and_ambiguous_urls() { assert_eq!(