diff --git a/.env.example b/.env.example index 3dc54856e7..f0e27c97e4 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,11 @@ TYPESENSE_URL=http://localhost:8108 BUZZ_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 +# Optional URL path prefix to serve the relay under. Empty (the default) mounts +# everything at /. Set this only when a gateway routes to the relay by path +# instead of by hostname — the WebSocket then answers on ws://host/ and +# every HTTP route lives under it. RELAY_URL must include the same prefix. +# BUZZ_BASE_PATH=/relay # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # BUZZ_RELAY_PRIVATE_KEY=<32-byte hex private key> diff --git a/crates/buzz-cli/src/client.rs b/crates/buzz-cli/src/client.rs index d0dd2677a9..353e671744 100644 --- a/crates/buzz-cli/src/client.rs +++ b/crates/buzz-cli/src/client.rs @@ -272,12 +272,17 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so the relay URL's own path is stripped before + // the `/media/` check rather than assuming the relay owns the root. + let relay_prefix = relay.path().trim_end_matches('/'); + let media_path = parsed + .path() + .strip_prefix(relay_prefix) + .unwrap_or(parsed.path()); + let Some(sha256_ext) = media_path.strip_prefix("/media/") else { return Err(CliError::Usage( "media URL must point at a /media/ path".to_string(), )); @@ -287,8 +292,6 @@ fn media_url_from_input(relay_url: &str, input: &str) -> Result/media/`, so both the sha shorthand and a full URL have to + /// work against a relay URL that carries a path. + #[test] + fn media_url_handles_a_relay_served_under_a_base_path() { + let hash = "a".repeat(64); + assert_eq!( + media_url_from_input("https://relay.example/relay", &format!("{hash}.jpg")).unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "sha shorthand resolves under the prefix" + ); + assert_eq!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media/{hash}.jpg") + ) + .unwrap(), + format!("https://relay.example/relay/media/{hash}.jpg"), + "a full prefixed URL is accepted, not rejected as non-media" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://relay.example/relay/media-evil/{hash}.jpg") + ) + .is_err(), + "the /media/ requirement still holds inside the prefix" + ); + assert!( + media_url_from_input( + "https://relay.example/relay", + &format!("https://evil.example/relay/media/{hash}.jpg") + ) + .is_err(), + "origin pinning still holds under a prefix" + ); + } + #[test] fn media_url_accepts_only_same_relay_media_urls() { let hash = "a".repeat(64); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 2e00d6bd2f..0a28380e06 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -192,8 +192,14 @@ async fn check_nip98_replay_with_guard( /// pass and the relay would proceed against the wrong tenant's auth context), /// and (b) reject every legitimate request whose community host isn't the /// single configured one. Substituting `tenant.host()` closes both directions. +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Callers pass the route's own unprefixed path — the same +/// literal the route is declared with — and the prefix is applied here, at the +/// single choke point, so a prefixed deployment reconstructs the URL the client +/// actually signed instead of 401ing on a path mismatch. pub(crate) fn nip98_expected_url( config_relay_url: &str, + base_path: &str, tenant: &TenantContext, path: &str, ) -> String { @@ -202,7 +208,7 @@ pub(crate) fn nip98_expected_url( } else { "http" }; - format!("{scheme}://{}{path}", tenant.host()) + format!("{scheme}://{}{base_path}{path}", tenant.host()) } /// Construct the NIP-42 expected `relay` URL for a connection bound to `tenant`. @@ -220,15 +226,22 @@ pub(crate) fn nip98_expected_url( /// the client's connect URL embedded in the signed AUTH event; the helper /// preserves the deployment's TLS posture from `config_relay_url`'s prefix so /// `wss://` deployments stay `wss://` and `ws://` dev/test stays `ws://`. -/// Path is empty — clients put the bare WS origin (`ws://host[:port]`) in the -/// `relay` tag, matching how `EventBuilder::auth` accepts a [`nostr::RelayUrl`]. -pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantContext) -> String { +/// Path is the deployment's `BUZZ_BASE_PATH` prefix and nothing more: clients put +/// their connect URL in the `relay` tag, which is the bare WS origin +/// (`ws://host[:port]`) at the root and `ws://host[:port]/` when the relay +/// is served under one. Matching how `EventBuilder::auth` accepts a +/// [`nostr::RelayUrl`]. +pub(crate) fn nip42_expected_relay_url( + config_relay_url: &str, + base_path: &str, + tenant: &TenantContext, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") { "wss" } else { "ws" }; - format!("{scheme}://{}", tenant.host()) + format!("{scheme}://{}{base_path}", tenant.host()) } /// Extract a channel UUID from a single filter's `#h` tag. @@ -632,7 +645,12 @@ pub async fn submit_event( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/events"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/events", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -900,7 +918,12 @@ pub async fn query_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/query"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/query", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -1335,7 +1358,12 @@ pub async fn count_events( ) })?; - let url = nip98_expected_url(&state.config.relay_url, &tenant, "/count"); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + "/count", + ); let (pubkey, event_id_bytes) = verify_bridge_auth( &headers, "POST", @@ -2066,7 +2094,12 @@ async fn authorize_moderation_read( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = nip98_expected_url(&state.config.relay_url, &tenant, &path_with_query); + let url = nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + &path_with_query, + ); let (pubkey, event_id_bytes) = verify_bridge_auth(headers, "GET", &url, None, state.config.require_auth_token)?; check_nip98_replay(state, &tenant, event_id_bytes).await?; @@ -2462,7 +2495,7 @@ mod tests { let config_relay_url = "wss://host-a.example"; // doesn't matter — only used for scheme. let tenant_b = fresh_tenant("host-b.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_b, "/events"); + let expected_url = nip98_expected_url(config_relay_url, "", &tenant_b, "/events"); let (status, body) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) .expect_err( @@ -2528,7 +2561,7 @@ mod tests { // tenant host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "wss://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example"); - let expected_url = nip98_expected_url(config_relay_url, &tenant_a, "/events"); + let expected_url = nip98_expected_url(config_relay_url, "", &tenant_a, "/events"); let (pubkey, _event_id_bytes) = verify_bridge_auth(&headers, "POST", &expected_url, Some(b""), true) @@ -2553,7 +2586,7 @@ mod tests { Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - nip98_expected_url(config_relay_url, tenant, &path_with_query) + nip98_expected_url(config_relay_url, "", tenant, &path_with_query) } /// L7 read-auth blocker (Wren, #1591 sweep): the CLI signs the *full* @@ -2675,15 +2708,15 @@ mod tests { let tenant_a = fresh_tenant("host-a.example"); let tenant_b = fresh_tenant("host-b.example"); - let url_a = nip98_expected_url("wss://config-host.example", &tenant_a, "/events"); - let url_b = nip98_expected_url("wss://config-host.example", &tenant_b, "/events"); + let url_a = nip98_expected_url("wss://config-host.example", "", &tenant_a, "/events"); + let url_b = nip98_expected_url("wss://config-host.example", "", &tenant_b, "/events"); assert_eq!(url_a, "https://host-a.example/events"); assert_eq!(url_b, "https://host-b.example/events"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite.) let url_a_alt_config = - nip98_expected_url("wss://different-config.example", &tenant_a, "/events"); + nip98_expected_url("wss://different-config.example", "", &tenant_a, "/events"); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-98 expected URL — \ @@ -2698,17 +2731,69 @@ mod tests { fn nip98_expected_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example"); assert_eq!( - nip98_expected_url("wss://config.example", &tenant, "/events"), + nip98_expected_url("wss://config.example", "", &tenant, "/events"), "https://host-a.example/events", "wss:// production config → https:// URL" ); assert_eq!( - nip98_expected_url("ws://config.example", &tenant, "/events"), + nip98_expected_url("ws://config.example", "", &tenant, "/events"), "http://host-a.example/events", "ws:// dev config → http:// URL" ); } + /// A relay served under `BUZZ_BASE_PATH` must reconstruct the *prefixed* URL, + /// because that is what the client signed into its NIP-98 `u` tag. Getting + /// this wrong 401s every authenticated HTTP call on a prefixed deployment + /// while looking like a signature failure. + #[test] + fn nip98_expected_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip98_expected_url("wss://config.example", "/relay", &tenant, "/events"), + "https://host-a.example/relay/events" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "/buzz/relay", &tenant, "/query"), + "https://host-a.example/buzz/relay/query" + ); + assert_eq!( + nip98_expected_url( + "wss://config.example", + "/relay", + &tenant, + "/moderation/audit?limit=20" + ), + "https://host-a.example/relay/moderation/audit?limit=20", + "the prefix precedes the path and the query stays last" + ); + assert_eq!( + nip98_expected_url("wss://config.example", "", &tenant, "/events"), + "https://host-a.example/events", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + + /// NIP-42 sibling: the `relay` tag carries the client's connect URL, which + /// includes the prefix when the relay is served under one. + #[test] + fn nip42_expected_relay_url_includes_the_base_path_prefix() { + let tenant = fresh_tenant("host-a.example"); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "/relay", &tenant), + "wss://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("ws://config.example", "/relay", &tenant), + "ws://host-a.example/relay" + ); + assert_eq!( + nip42_expected_relay_url("wss://config.example", "", &tenant), + "wss://host-a.example", + "an empty prefix is byte-identical to pre-base-path behavior" + ); + } + // ----- NIP-42 host-binding tests (sibling of NIP-98 row 44 obligation) ----- /// Sign a NIP-42 AUTH event with `relay` tag = `relay_url`, then verify @@ -2752,7 +2837,7 @@ mod tests { let config_relay_url = "ws://host-a.example:3100"; let signed_relay_url = "ws://host-a.example:3100"; let tenant_b = fresh_tenant("host-b.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_b); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_b); let err = verify_nip42_with_urls(challenge, signed_relay_url, &expected).expect_err( "cross-host NIP-42 AUTH event MUST be rejected — row 44 sibling: \ @@ -2778,7 +2863,7 @@ mod tests { // host — proving the helper uses `tenant.host()`, not the config. let config_relay_url = "ws://other-config-host.example"; let tenant_a = fresh_tenant("host-a.example:3100"); - let expected = nip42_expected_relay_url(config_relay_url, &tenant_a); + let expected = nip42_expected_relay_url(config_relay_url, "", &tenant_a); verify_nip42_with_urls(challenge, signed_relay_url, &expected) .expect("matching-host NIP-42 AUTH event must verify"); @@ -2792,15 +2877,16 @@ mod tests { let tenant_a = fresh_tenant("host-a.example:3100"); let tenant_b = fresh_tenant("host-b.example:3100"); - let url_a = nip42_expected_relay_url("ws://config-host.example", &tenant_a); - let url_b = nip42_expected_relay_url("ws://config-host.example", &tenant_b); + let url_a = nip42_expected_relay_url("ws://config-host.example", "", &tenant_a); + let url_b = nip42_expected_relay_url("ws://config-host.example", "", &tenant_b); assert_eq!(url_a, "ws://host-a.example:3100"); assert_eq!(url_b, "ws://host-b.example:3100"); // Same tenant, two different config hosts → output is identical. // (If config-host ever leaked into the URL, this assertion would bite — // catches the exact "reverted to config host" regression.) - let url_a_alt_config = nip42_expected_relay_url("ws://different-config.example", &tenant_a); + let url_a_alt_config = + nip42_expected_relay_url("ws://different-config.example", "", &tenant_a); assert_eq!( url_a, url_a_alt_config, "config-relay-url's host MUST NOT influence the NIP-42 expected URL — \ @@ -2816,12 +2902,12 @@ mod tests { fn nip42_expected_relay_url_derives_scheme_from_config() { let tenant = fresh_tenant("host-a.example:3100"); assert_eq!( - nip42_expected_relay_url("wss://config.example", &tenant), + nip42_expected_relay_url("wss://config.example", "", &tenant), "wss://host-a.example:3100", "wss:// production config → wss:// URL" ); assert_eq!( - nip42_expected_relay_url("ws://config.example", &tenant), + nip42_expected_relay_url("ws://config.example", "", &tenant), "ws://host-a.example:3100", "ws:// dev config → ws:// URL" ); diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cca..0315c84339 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -246,7 +246,12 @@ async fn authenticate( ) })?; - let url = bridge::nip98_expected_url(&state.config.relay_url, &tenant, path); + let url = bridge::nip98_expected_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + path, + ); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, "POST", diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index fa0401bc26..a7478c7d91 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -402,6 +402,7 @@ pub async fn upload_blob( rewrite_descriptor_urls_for_tenant( &mut descriptor, &state.config.relay_url, + &state.config.base_path, auth.tenant.host(), ); @@ -444,7 +445,17 @@ pub async fn upload_blob( Ok(Json(descriptor)) } -pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &str) -> String { +/// Build the tenant-scoped media base URL that upload descriptors advertise. +/// +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). The prefix must be present: this URL is what clients and +/// agents fetch, and it is embedded in published events, so a missing prefix +/// bakes an unreachable URL into message history rather than failing loudly. +pub(crate) fn media_base_url_for_tenant( + config_relay_url: &str, + base_path: &str, + tenant_host: &str, +) -> String { let scheme = if config_relay_url.trim_start().starts_with("wss://") || config_relay_url.trim_start().starts_with("https://") { @@ -452,15 +463,16 @@ pub(crate) fn media_base_url_for_tenant(config_relay_url: &str, tenant_host: &st } else { "http" }; - format!("{scheme}://{tenant_host}/media") + format!("{scheme}://{tenant_host}{base_path}/media") } fn rewrite_descriptor_urls_for_tenant( descriptor: &mut BlobDescriptor, config_relay_url: &str, + base_path: &str, tenant_host: &str, ) { - let base = media_base_url_for_tenant(config_relay_url, tenant_host); + let base = media_base_url_for_tenant(config_relay_url, base_path, tenant_host); let ext = descriptor .url .rsplit_once('.') @@ -1271,15 +1283,69 @@ mod tests { #[test] fn media_base_url_for_tenant_uses_tenant_host_and_http_scheme() { assert_eq!( - media_base_url_for_tenant("wss://config.example", "tenant-b.example"), + media_base_url_for_tenant("wss://config.example", "", "tenant-b.example"), "https://tenant-b.example/media" ); assert_eq!( - media_base_url_for_tenant("ws://config.example", "localhost:3100"), + media_base_url_for_tenant("ws://config.example", "", "localhost:3100"), "http://localhost:3100/media" ); } + /// A relay served under BUZZ_BASE_PATH must advertise media under the prefix. + /// This URL is embedded in published events and fetched by clients and agents, + /// so dropping the prefix bakes an unreachable URL into message history — and + /// behind a path-routing gateway it resolves to the gateway, not the relay. + #[test] + fn media_base_url_for_tenant_includes_the_base_path_prefix() { + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/relay", "tenant-b.example"), + "https://tenant-b.example/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("wss://config.example", "/buzz/relay", "tenant-b.example"), + "https://tenant-b.example/buzz/relay/media" + ); + assert_eq!( + media_base_url_for_tenant("ws://config.example", "/relay", "localhost:3100"), + "http://localhost:3100/relay/media" + ); + } + + #[test] + fn rewrite_descriptor_urls_for_tenant_applies_the_base_path_prefix() { + let hash = "b".repeat(64); + let mut descriptor = BlobDescriptor { + url: format!("https://primary.example/media/{hash}.png"), + sha256: hash.clone(), + size: 7, + mime_type: "image/png".to_string(), + uploaded: 1700000000, + dim: None, + blurhash: None, + thumb: Some(format!("https://primary.example/media/{hash}.thumb.jpg")), + duration: None, + }; + + rewrite_descriptor_urls_for_tenant( + &mut descriptor, + "wss://primary.example", + "/relay", + "tenant-b.example", + ); + + assert_eq!( + descriptor.url, + format!("https://tenant-b.example/relay/media/{hash}.png") + ); + assert_eq!( + descriptor.thumb, + Some(format!( + "https://tenant-b.example/relay/media/{hash}.thumb.jpg" + )) + ); + } + #[test] fn rewrite_descriptor_urls_for_tenant_replaces_global_media_host() { let hash = "a".repeat(64); @@ -1298,6 +1364,7 @@ mod tests { rewrite_descriptor_urls_for_tenant( &mut descriptor, "wss://primary.example", + "", "tenant-b.example", ); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a43874..8073068ce5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -74,7 +74,11 @@ async fn authorize_operator_request( Some(q) if !q.is_empty() => format!("{path}?{q}"), _ => path.to_string(), }; - let url = format!("{origin}{path_with_query}"); + // `RELAY_OPERATOR_API_ORIGIN` is validated to carry no path, so the + // deployment's `BUZZ_BASE_PATH` prefix is applied here — the operator's + // signed `u` tag is the URL they actually called, prefix included. + let base_path = state.config.base_path.as_str(); + let url = format!("{origin}{base_path}{path_with_query}"); let (pubkey, event_id_bytes) = bridge::verify_bridge_auth_with_options( headers, method, diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index 16cd56209c..9598e16bc4 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -216,7 +216,11 @@ async fn handle_active_audio_connection( // Extract NIP-OA auth tag before verify_auth_event consumes the event. let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); - let relay_url = crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &tenant, + ); let auth_ctx = match state .auth .verify_auth_event(auth_msg.event, &challenge, &relay_url) diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..13dc730197 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -74,6 +74,27 @@ pub struct Config { pub db_pool_size: u32, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, + /// URL path prefix the relay is served under (`BUZZ_BASE_PATH`). + /// + /// Empty by default: every route mounts at the root and behavior is + /// identical to deployments that never set this. When non-empty, the whole + /// HTTP + WebSocket surface nests under the prefix, so the relay can sit + /// behind a gateway that routes by path instead of by hostname — the + /// WebSocket lands on `wss://host/` and the bridge endpoints on + /// `https://host//events` and friends. + /// + /// Normalized at load time to exactly one leading slash and no trailing + /// slash (`relay`, `/relay`, and `/relay/` all become `/relay`). The + /// prefix participates in NIP-98 `u` tags and the NIP-42 `relay` tag, so + /// it must match what clients connect to byte-for-byte. `RELAY_URL` and + /// `BUZZ_MEDIA_BASE_URL` must carry the same prefix. + /// + /// Known limitation: the browser web bundle (`BUZZ_WEB_DIR`) references its + /// assets at absolute `/assets/...` URLs baked in at build time, so serving + /// that SPA under a prefix additionally requires rebuilding it with a + /// matching Vite `base`. The relay's protocol surface — WebSocket, bridge, + /// media, git — is unaffected. + pub base_path: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. pub pairing_relay_url: Option, /// Maximum number of concurrent WebSocket connections. @@ -275,6 +296,47 @@ fn parse_bind_addr(raw: &str) -> Result { .map_err(|e| ConfigError::InvalidBindAddr(e.to_string())) } +/// Normalize a `BUZZ_BASE_PATH` value to the canonical form the router and the +/// auth-URL builders expect: either empty (mount at the root) or exactly one +/// leading slash with no trailing slash. +/// +/// The prefix is concatenated into signed-URL expectations (NIP-98 `u`, NIP-42 +/// `relay`), so anything that could make the server's reconstruction differ from +/// the client's connect URL is rejected at startup rather than surfacing later +/// as an unexplainable 401. That means no query, fragment, whitespace, dot +/// segments, or empty interior segments. +pub(crate) fn normalize_base_path(raw: &str) -> Result { + let trimmed = raw.trim(); + let stripped = trimmed.trim_matches('/'); + if stripped.is_empty() { + // Unset, blank, and a bare "/" all mean "serve at the root". + return Ok(String::new()); + } + + let invalid = |reason: &str| { + Err(ConfigError::InvalidValue(format!( + "BUZZ_BASE_PATH {reason} (got {raw:?})" + ))) + }; + + if stripped.contains('?') || stripped.contains('#') { + return invalid("must not contain a query string or fragment"); + } + if stripped.chars().any(char::is_whitespace) { + return invalid("must not contain whitespace"); + } + for segment in stripped.split('/') { + if segment.is_empty() { + return invalid("must not contain empty path segments"); + } + if segment == "." || segment == ".." { + return invalid("must not contain dot segments"); + } + } + + Ok(format!("/{stripped}")) +} + fn positive_u64_from_env(name: &str, default: u64) -> Result { match std::env::var(name) { Ok(raw) => raw @@ -441,6 +503,11 @@ impl Config { let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + let base_path = match std::env::var("BUZZ_BASE_PATH") { + Ok(raw) => normalize_base_path(&raw)?, + Err(_) => String::new(), + }; + let pairing_relay_url = std::env::var("BUZZ_PAIRING_RELAY_URL") .ok() .map(|value| value.trim().to_string()) @@ -891,6 +958,7 @@ impl Config { redis_pool_size, db_pool_size, relay_url, + base_path, pairing_relay_url, max_connections, max_concurrent_handlers, @@ -949,10 +1017,88 @@ mod tests { // value set by `invalid_bind_addr_returns_error`, causing a flaky failure. static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn base_path_normalizes_equivalent_spellings() { + for raw in ["relay", "/relay", "relay/", "/relay/", " /relay/ "] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "/relay", + "{raw:?} should normalize to /relay" + ); + } + assert_eq!( + normalize_base_path("/buzz/relay").expect("valid prefix"), + "/buzz/relay", + "multi-segment prefixes are preserved" + ); + } + + #[test] + fn base_path_treats_blank_and_root_as_unprefixed() { + for raw in ["", " ", "/", "//"] { + assert_eq!( + normalize_base_path(raw).expect("valid prefix"), + "", + "{raw:?} should mean serve at the root" + ); + } + } + + #[test] + fn base_path_rejects_values_that_would_break_signed_urls() { + // Each of these would make the server's URL reconstruction differ from + // the client's connect URL, surfacing as an unexplainable 401. + for raw in [ + "/relay?x=1", + "/relay#frag", + "/re lay", + "/relay//inner", + "/relay/../etc", + "/./relay", + ] { + let err = normalize_base_path(raw).expect_err("should be rejected"); + assert!( + matches!(err, ConfigError::InvalidValue(_)), + "{raw:?} should be an InvalidValue error, got {err:?}" + ); + } + } + + #[test] + fn base_path_defaults_to_empty_when_unset() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_BASE_PATH"); + std::env::remove_var("BUZZ_BASE_PATH"); + let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "an unset BUZZ_BASE_PATH must leave routes at the root" + ); + + std::env::set_var("BUZZ_BASE_PATH", "relay/"); + let prefixed = Config::from_env().expect("prefixed config"); + assert_eq!(prefixed.base_path, "/relay"); + + std::env::set_var("BUZZ_BASE_PATH", "/relay?x=1"); + assert!( + Config::from_env().is_err(), + "an invalid prefix must fail startup, not degrade silently" + ); + + match previous { + Some(value) => std::env::set_var("BUZZ_BASE_PATH", value), + None => std::env::remove_var("BUZZ_BASE_PATH"), + } + } + #[test] fn defaults_are_valid() { let _guard = ENV_MUTEX.lock().unwrap(); let config = Config::from_env().expect("default config"); + assert_eq!( + config.base_path, "", + "base_path should default to empty (root-mounted)" + ); assert!(config.bind_addr.port() > 0); assert!(!config.database_url.is_empty()); assert!(!config.redis_url.is_empty()); diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..ccc0990e9b 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -77,8 +77,11 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: // tampered, NIP-42 verification will fail before we ever inspect it. let auth_tag_json = extract_auth_tag_json(&event); - let relay_url = - crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant); + let relay_url = crate::api::bridge::nip42_expected_relay_url( + &state.config.relay_url, + &state.config.base_path, + &conn.tenant, + ); let auth_svc = Arc::clone(&state.auth); metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..1457cb64b2 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2253,8 +2253,11 @@ async fn ingest_event_inner( .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) .collect(); if !imeta_tags.is_empty() { - let tenant_media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let tenant_media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &tenant_media_base) .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage) diff --git a/crates/buzz-relay/src/handlers/product_feedback.rs b/crates/buzz-relay/src/handlers/product_feedback.rs index 92d045e194..c11b1f57c2 100644 --- a/crates/buzz-relay/src/handlers/product_feedback.rs +++ b/crates/buzz-relay/src/handlers/product_feedback.rs @@ -27,8 +27,11 @@ pub async fn handle( .map(|tag| tag.as_slice().iter().map(ToString::to_string).collect()) .collect::>>(); if !imeta_tags.is_empty() { - let media_base = - crate::api::media::media_base_url_for_tenant(&state.config.relay_url, tenant.host()); + let media_base = crate::api::media::media_base_url_for_tenant( + &state.config.relay_url, + &state.config.base_path, + tenant.host(), + ); crate::api::validate_imeta_tags(&imeta_tags, &media_base)?; crate::api::verify_imeta_blobs(tenant, &imeta_tags, &state.media_storage).await?; } diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..75e93012ae 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -180,9 +180,14 @@ pub async fn relay_info_handler( axum::response::Json(nip11_document(&state, raw_host).await) } +/// `base_path` is the deployment's `BUZZ_BASE_PATH` prefix (empty when the relay +/// serves at the root). Push clients dial the advertised `origin` directly, so a +/// prefixed deployment must advertise the prefix or every push registration +/// lands on a path the gateway does not route. fn push_descriptor( push_configured: bool, relay_url: &str, + base_path: &str, executor_key_id: &str, relay_keypair: &nostr::Keys, tenant_host: Option<&str>, @@ -195,7 +200,7 @@ fn push_descriptor( "ws" }; Some(serde_json::json!({ - "origin": format!("{scheme}://{host}"), + "origin": format!("{scheme}://{host}{base_path}"), "keys": [{ "id": executor_key_id, "pubkey": relay_keypair.public_key().to_hex(), @@ -253,6 +258,7 @@ pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &st if let Some(push) = push_descriptor( state.config.push_gateway_delivery_url.is_some(), &state.config.relay_url, + &state.config.base_path, &state.config.push_executor_key_id, &state.relay_keypair, tenant_host.as_deref(), @@ -341,12 +347,19 @@ mod tests { #[test] fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() { let keys = nostr::Keys::generate(); - assert!( - push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none() - ); - assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none()); - let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example")) - .expect("configured push descriptor"); + assert!(push_descriptor( + false, + "ws://relay", + "", + "key", + &keys, + Some("tenant.example") + ) + .is_none()); + assert!(push_descriptor(true, "ws://relay", "", "key", &keys, None).is_none()); + let descriptor = + push_descriptor(true, "ws://relay", "", "key", &keys, Some("tenant.example")) + .expect("configured push descriptor"); assert_eq!(descriptor["origin"], "ws://tenant.example"); assert_eq!( descriptor["push_kinds"], @@ -354,6 +367,23 @@ mod tests { ); } + /// Push clients dial the advertised `origin` verbatim, so a relay served + /// under `BUZZ_BASE_PATH` has to advertise the prefix. + #[test] + fn push_descriptor_origin_carries_the_base_path_prefix() { + let keys = nostr::Keys::generate(); + let descriptor = push_descriptor( + true, + "wss://relay", + "/relay", + "key", + &keys, + Some("tenant.example"), + ) + .expect("configured push descriptor"); + assert_eq!(descriptor["origin"], "wss://tenant.example/relay"); + } + #[test] fn supported_nips_includes_nip23_and_nip33() { // Tests the production SUPPORTED_NIPS constant directly — no Config::from_env() diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..2004a4c6ac 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -186,12 +186,47 @@ pub fn build_router(state: Arc) -> Router { merged = merged.fallback_service(spa_fallback); } - merged + // Optional path prefix (`BUZZ_BASE_PATH`). Empty is the default and leaves + // the router exactly as built above. When set, the whole surface nests + // under the prefix so the relay can live behind a gateway that routes by + // path rather than by hostname. + // + // `nest` strips the prefix before the inner router sees the request, so + // every route match, the SPA fallback's `req.uri().path()` checks, and the + // media/git sub-routers keep working against their unprefixed paths. + // + // Health probes stay mounted at the root as well: Kubernetes probes reach + // the pod directly rather than through the gateway that needs the prefix, + // and `deploy/compose` curls `/_liveness` on the published port. + let root_health = Router::new() + .route("/health", get(health_handler)) + .route("/_liveness", get(liveness_handler)) + .route("/_readiness", get(readiness_handler)) + .with_state(state.clone()); + let routed = nest_under_base_path(&state.config.base_path, merged, root_health); + + routed .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins)) } +/// Mount `router` under `base_path`, keeping `root_extras` reachable at the root. +/// +/// An empty `base_path` returns `router` untouched — the default, and byte-identical +/// to the pre-`BUZZ_BASE_PATH` router. Otherwise everything nests under the prefix +/// so the relay can sit behind a gateway that routes by path rather than hostname. +/// `nest` strips the prefix before the inner router sees the request, so route +/// matches and the SPA fallback's own `req.uri().path()` checks keep working +/// against their unprefixed paths. +fn nest_under_base_path(base_path: &str, router: Router, root_extras: Router) -> Router { + if base_path.is_empty() { + router + } else { + Router::new().nest(base_path, router).merge(root_extras) + } +} + fn http_trace_layer() -> TraceLayer) -> tracing::Span> { TraceLayer::new_for_http().make_span_with(make_http_span as fn(&Request) -> tracing::Span) } @@ -490,6 +525,79 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + /// Build the two-router pair `build_router` hands to [`nest_under_base_path`], + /// standing in for the real relay surface and the root-mounted health probes. + fn base_path_fixture(base_path: &str) -> Router { + // The real surface already carries the health probes (they live in + // `api_router`), so `root_extras` is a deliberate duplicate that only + // matters once the surface moves under a prefix. + let surface = Router::new() + .route("/", get(|| async { "ws-or-nip11" })) + .route("/events", get(|| async { "events" })) + .route("/_liveness", get(|| async { "ok" })); + let root_extras = Router::new().route("/_liveness", get(|| async { "ok" })); + nest_under_base_path(base_path, surface, root_extras) + } + + async fn status_of(router: Router, path: &str) -> StatusCode { + let request = Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"); + router + .oneshot(request) + .await + .expect("router response") + .status() + } + + #[tokio::test(flavor = "current_thread")] + async fn empty_base_path_leaves_every_route_at_the_root() { + let router = base_path_fixture(""); + assert_eq!(status_of(router.clone(), "/").await, StatusCode::OK); + assert_eq!(status_of(router.clone(), "/events").await, StatusCode::OK); + assert_eq!(status_of(router, "/_liveness").await, StatusCode::OK); + } + + #[tokio::test(flavor = "current_thread")] + async fn base_path_moves_the_surface_under_the_prefix() { + let router = base_path_fixture("/relay"); + // The WebSocket/NIP-11 route answers on the bare prefix, which is what a + // client connecting to wss://host/relay asks for. + assert_eq!(status_of(router.clone(), "/relay").await, StatusCode::OK); + assert_eq!( + status_of(router.clone(), "/relay/events").await, + StatusCode::OK + ); + // Root probes stay reachable: Kubernetes hits the pod directly, bypassing + // the gateway that requires the prefix. + assert_eq!( + status_of(router.clone(), "/_liveness").await, + StatusCode::OK + ); + // Unprefixed application paths no longer resolve — the gateway owns the + // root, and a stale client hitting it should fail loudly, not silently + // reach a half-configured relay. + assert_eq!( + status_of(router.clone(), "/events").await, + StatusCode::NOT_FOUND + ); + assert_eq!(status_of(router, "/other").await, StatusCode::NOT_FOUND); + } + + #[tokio::test(flavor = "current_thread")] + async fn multi_segment_base_path_routes() { + let router = base_path_fixture("/buzz/relay"); + assert_eq!( + status_of(router.clone(), "/buzz/relay").await, + StatusCode::OK + ); + assert_eq!( + status_of(router, "/buzz/relay/events").await, + StatusCode::OK + ); + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default(); diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 838824c17f..db1f2eb252 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -11,6 +11,10 @@ RELAY_URL=wss://buzz.example.com BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com BUZZ_CORS_ORIGINS=https://buzz.example.com +# Optional URL path prefix, for gateways that route by path rather than by +# hostname (the relay is normally served at the root, so leave this unset). +# When set, RELAY_URL and BUZZ_MEDIA_BASE_URL must carry the same prefix. +# BUZZ_BASE_PATH=/relay # Production defaults. Closed relay mode requires RELAY_OWNER_PUBKEY and a stable relay key. BUZZ_REQUIRE_AUTH_TOKEN=true diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index d3b1a9499d..6086af218b 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -49,9 +49,13 @@ fn validate_download_url(url: &str, relay_base: &str) -> Result<(), String> { return Err("download URL must match the relay origin".to_string()); } - // Path must be /media/{filename}. + // Path must contain a /media/{filename} segment. The segment may be preceded + // by a base path when the relay is served under one (BUZZ_BASE_PATH), e.g. + // /relay/media/.png. Requiring the full segment still rejects + // near-misses such as /media-evil/, and the origin check above already + // pins the request to the relay. let path = parsed.path(); - if !path.starts_with("/media/") { + if !path.contains("/media/") { return Err("download URL must be a /media/ path".to_string()); } @@ -791,6 +795,34 @@ mod tests { ); } + #[test] + fn test_validate_download_url_accepts_base_path_prefixed_media() { + // A relay served under BUZZ_BASE_PATH hosts media at /media/. + assert!(validate_download_url( + "https://relay.example.com/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + assert!(validate_download_url( + "https://relay.example.com/buzz/relay/media/abc123.png", + RELAY_BASE, + ) + .is_ok()); + } + + #[test] + fn test_validate_download_url_rejects_media_lookalike_segment() { + // The full /media/ segment is still required, prefix or not. + let result = + validate_download_url("https://relay.example.com/media-evil/abc123.png", RELAY_BASE); + assert!(result.is_err()); + let prefixed = validate_download_url( + "https://relay.example.com/relay/media-evil/abc123.png", + RELAY_BASE, + ); + assert!(prefixed.is_err()); + } + #[test] fn test_validate_download_url_non_relay_origin_rejected() { let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); diff --git a/desktop/src/shared/api/inviteHelpers.ts b/desktop/src/shared/api/inviteHelpers.ts index 2b5efe4e94..5fa15ce1cc 100644 --- a/desktop/src/shared/api/inviteHelpers.ts +++ b/desktop/src/shared/api/inviteHelpers.ts @@ -18,6 +18,7 @@ export type ParsedInvite = * Accepted input forms: * - `https:///invite/` → `{ relayWsUrl: "wss://", code }` * - `http:///invite/` → `{ relayWsUrl: "ws://", code }` + * - `https:////invite/` → `{ relayWsUrl: "wss:///", code }` * - `buzz://join?relay=&code=` → `{ relayWsUrl, code }` * - bare code (no `://`, no `/`) → `{ code }` * @@ -52,16 +53,21 @@ export function parseInviteInput(input: string): ParsedInvite | null { return { relayWsUrl: relay, code }; } - // https(s):///invite/ + // https(s)://[/]/invite/ if (url.protocol === "https:" || url.protocol === "http:") { if (url.username || url.password || url.hash) return null; - // pathname must be /invite/ with optional single trailing slash - const match = url.pathname.match(/^\/invite\/([^/]+)\/?$/); - if (!match?.[1]) return null; - const code = decodeURIComponent(match[1]); + // `/invite/` may be preceded by a base path when the relay is served + // under one (BUZZ_BASE_PATH), e.g. https://host/relay/invite/. The + // prefix is captured so it can be carried into the derived relay URL — + // rebuilding from `url.host` alone would drop it and the client would then + // dial a relay that isn't there. + const match = url.pathname.match(/^(\/.*?)?\/invite\/([^/]+)\/?$/); + if (!match?.[2]) return null; + const basePath = match[1] ?? ""; + const code = decodeURIComponent(match[2]); // Convert scheme: https → wss, http → ws. url.host already includes port. - const relayWsUrl = - url.protocol === "https:" ? `wss://${url.host}` : `ws://${url.host}`; + const scheme = url.protocol === "https:" ? "wss" : "ws"; + const relayWsUrl = `${scheme}://${url.host}${basePath}`; return { relayWsUrl, code }; } diff --git a/desktop/src/shared/api/parseInviteInput.test.mjs b/desktop/src/shared/api/parseInviteInput.test.mjs index 858f725880..a9988afd3f 100644 --- a/desktop/src/shared/api/parseInviteInput.test.mjs +++ b/desktop/src/shared/api/parseInviteInput.test.mjs @@ -28,6 +28,51 @@ test("parseInviteInput_http_invite_url_returns_ws_relay_and_code", () => { }); }); +// --------------------------------------------------------------------------- +// Base-path deployments (BUZZ_BASE_PATH): the prefix must survive into the +// derived relay URL, or the client dials a relay that isn't there. +// --------------------------------------------------------------------------- + +test("parseInviteInput_base_path_invite_url_keeps_prefix_in_relay_url", () => { + const result = parseInviteInput( + "https://relay.example.com/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_multi_segment_base_path_keeps_full_prefix", () => { + const result = parseInviteInput( + "https://relay.example.com/buzz/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/buzz/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_base_path_invite_url_over_http_uses_ws", () => { + const result = parseInviteInput( + "http://relay.example.com:3000/relay/invite/abc123", + ); + assert.deepEqual(result, { + relayWsUrl: "ws://relay.example.com:3000/relay", + code: "abc123", + }); +}); + +test("parseInviteInput_base_path_invite_url_tolerates_trailing_slash", () => { + const result = parseInviteInput( + "https://relay.example.com/relay/invite/abc123/", + ); + assert.deepEqual(result, { + relayWsUrl: "wss://relay.example.com/relay", + code: "abc123", + }); +}); + test("parseInviteInput_https_with_port_preserves_port", () => { const result = parseInviteInput( "https://relay.example.com:8443/invite/abc123", diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index 9a293e8b3e..c918244372 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -3,6 +3,7 @@ import { test } from "node:test"; import { beginRelayOriginFetch, + rewriteRelayUrl, getCachedRelayOrigin, mediaProxyUrl, resetMediaCaches, @@ -20,6 +21,32 @@ function deferred() { return { promise, resolve }; } +const PREFIX_HASH = "a".repeat(64); + +test("rewriteRelayUrl: recognises relay media under a base path", () => { + // BUZZ_BASE_PATH deployments serve media at /media/. Without a + // proxy port cached, a recognised relay URL falls back to buzz-media://; an + // unrecognised one is returned unchanged, which is how we detect the match. + resetMediaCaches(); + const prefixed = `https://relay.example.com/relay/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(prefixed), `buzz-media://localhost/media/${PREFIX_HASH}.png`); + + const multi = `https://relay.example.com/buzz/relay/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(multi), `buzz-media://localhost/media/${PREFIX_HASH}.png`); +}); + +test("rewriteRelayUrl: root-hosted relay media still recognised", () => { + resetMediaCaches(); + const root = `https://relay.example.com/media/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(root), `buzz-media://localhost/media/${PREFIX_HASH}.png`); +}); + +test("rewriteRelayUrl: a media-lookalike segment is left alone", () => { + resetMediaCaches(); + const evil = `https://relay.example.com/relay/media-evil/${PREFIX_HASH}.png`; + assert.equal(rewriteRelayUrl(evil), evil); +}); + test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () => { assert.equal( mediaProxyUrl(54321, `${HASH}.png`), diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 8875920ef2..de58251cf3 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -19,8 +19,11 @@ import { invoke } from "@tauri-apps/api/core"; // Matches: https://anything.com/media/{64-hex}.{ext} // Also matches thumbnails: /media/{64-hex}.thumb.jpg +// An optional path prefix before /media/ is allowed so relays served under +// BUZZ_BASE_PATH (https://host/relay/media/...) are still recognised as relay +// media; without it their URLs look external and skip the authenticated proxy. const RELAY_MEDIA_RE = - /^(?:https?:\/\/[^/]+)\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4|webm|mov)(?:\?.*)?)$/; + /^(?:https?:\/\/[^/]+)(?:\/.*?)?\/media\/([\da-f]{64}(?:\.thumb)?\.(?:jpg|png|gif|webp|mp4|webm|mov)(?:\?.*)?)$/; /** Cached proxy port — fetched once from the Tauri backend. */ let cachedPort: number | null = null; diff --git a/desktop/src/shared/ui/markdown/mediaEntry.test.mjs b/desktop/src/shared/ui/markdown/mediaEntry.test.mjs index bbd10898be..c8276a50a8 100644 --- a/desktop/src/shared/ui/markdown/mediaEntry.test.mjs +++ b/desktop/src/shared/ui/markdown/mediaEntry.test.mjs @@ -76,6 +76,21 @@ test("isRelayDownloadable: non-/media/ path on the relay is not eligible", () => assert.equal(isRelayDownloadable(`${RELAY}/other/abc.mp4`, RELAY), false); }); +test("isRelayDownloadable: relay served under a base path is eligible", () => { + // BUZZ_BASE_PATH deployments host media at /media/. + assert.equal( + isRelayDownloadable(`${RELAY}/relay/media/abc.mp4`, RELAY), + true, + ); +}); + +test("isRelayDownloadable: near-miss segment under a base path is not eligible", () => { + assert.equal( + isRelayDownloadable(`${RELAY}/relay/media-evil/abc.mp4`, RELAY), + false, + ); +}); + test("isRelayDownloadable: malformed URL is not eligible", () => { assert.equal(isRelayDownloadable("not a url", RELAY), false); }); diff --git a/desktop/src/shared/ui/markdown/mediaEntry.ts b/desktop/src/shared/ui/markdown/mediaEntry.ts index 8cc0160de7..5f90e8909b 100644 --- a/desktop/src/shared/ui/markdown/mediaEntry.ts +++ b/desktop/src/shared/ui/markdown/mediaEntry.ts @@ -60,6 +60,13 @@ export function isVideoMedia(src: string, imetaMime?: string): boolean { * it anyway). Callers must read `relayOrigin` from a reactive source * (`useRelayOrigin`) so eligibility recomputes — and Download appears for a * genuine relay URL — the moment the origin resolves. + * + * The `/media/` segment is matched anywhere in the path rather than only at the + * root, because a relay served under `BUZZ_BASE_PATH` hosts media at + * `/media/`. `relayOrigin` is origin-only by design (it backs + * proxy decisions and is canonicalized for case-insensitive host comparison), + * so the prefix isn't available here to anchor against. Requiring a full + * `/media/` segment still rejects near-misses like `/media-evil/`. */ export function isRelayDownloadable( src: string, @@ -72,6 +79,6 @@ export function isRelayDownloadable( } catch { return false; } - if (!parsed.pathname.startsWith("/media/")) return false; + if (!parsed.pathname.includes("/media/")) return false; return parsed.origin === relayOrigin; } diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index b21eeca36d..3e6661e895 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; const _mediaGetAuthKind = 24242; @@ -95,7 +96,7 @@ class MediaGetAuthService { if (mediaAuthority.toLowerCase() != relayAuthority.toLowerCase()) { return false; } - return uri.path.startsWith('/media/'); + return isRelayMediaPath(uri.path); } nostr.Event _buildGetAuthEvent(String nsec) { diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index 58c93979d7..0a6640f9ae 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -13,6 +13,7 @@ import 'package:pointycastle/digests/sha256.dart'; import 'animated_image_sanitizer.dart'; import 'media_auth.dart'; import 'mp4_fast_start.dart'; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; const _mediaUploadPath = '/upload'; @@ -405,7 +406,10 @@ class MediaUploadService { required String sha256, required String path, }) { - final request = http.Request('PUT', Uri.parse(_baseUrl).resolve(path)); + final request = http.Request( + 'PUT', + Uri.parse(relayEndpoint(_baseUrl, path)), + ); request.bodyBytes = bytes; request.headers.addAll( _buildUploadHeaders(mimeType: mimeType, sha256: sha256), diff --git a/mobile/lib/shared/relay/relay_client.dart b/mobile/lib/shared/relay/relay_client.dart index 453e11459e..bdb0f53c3c 100644 --- a/mobile/lib/shared/relay/relay_client.dart +++ b/mobile/lib/shared/relay/relay_client.dart @@ -1,5 +1,7 @@ import 'package:http/http.dart' as http; +import 'relay_endpoint.dart'; + /// Lightweight HTTP context for talking to the Buzz relay. /// /// In the pure-nostr architecture, all data flow happens over the relay @@ -18,10 +20,7 @@ class RelayClient { http.Client get httpClient => _http; /// Fully-qualified URL for the relay's Blossom-style media upload endpoint. - String get mediaUploadUrl { - final base = Uri.parse(baseUrl); - return base.resolve('/upload').toString(); - } + String get mediaUploadUrl => relayEndpoint(baseUrl, '/upload'); void dispose() => _http.close(); } diff --git a/mobile/lib/shared/relay/relay_endpoint.dart b/mobile/lib/shared/relay/relay_endpoint.dart new file mode 100644 index 0000000000..ee74f6d3b8 --- /dev/null +++ b/mobile/lib/shared/relay/relay_endpoint.dart @@ -0,0 +1,34 @@ +/// Joining relay HTTP endpoint URLs so they survive a base-path deployment. +/// +/// `Uri.resolve('/events')` discards any path the base URL carries, which is +/// correct for an origin-rooted relay but silently wrong for one served under +/// `BUZZ_BASE_PATH` (`https://host/relay`): the request lands on `/events` +/// instead of `/relay/events`. Since the relay's NIP-98 check reconstructs the +/// prefixed URL, a dropped prefix surfaces as a 401 or a 404 rather than +/// anything that names the real cause. +library; + +/// Join `path` onto `baseUrl`, preserving any path prefix the base carries. +/// +/// `path` is treated as relative to the base regardless of whether it has a +/// leading slash, so callers can keep passing the route literals they declare +/// (`/events`, `/media/upload`). +/// +/// ```dart +/// relayEndpoint('https://host', '/query'); // https://host/query +/// relayEndpoint('https://host/relay', '/query'); // https://host/relay/query +/// relayEndpoint('https://host/relay/', 'query'); // https://host/relay/query +/// ``` +String relayEndpoint(String baseUrl, String path) { + final base = baseUrl.replaceAll(RegExp(r'/+$'), ''); + final suffix = path.replaceAll(RegExp(r'^/+'), ''); + return suffix.isEmpty ? base : '$base/$suffix'; +} + +/// Whether `urlPath` addresses the relay's media route. +/// +/// Matches `/media/` as a full path segment anywhere in the path rather than +/// only at the root, because a base-path deployment serves media at +/// `/media/`. Requiring the whole segment still rejects +/// near-misses such as `/media-evil/`. +bool isRelayMediaPath(String urlPath) => urlPath.contains('/media/'); diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 9c167e10b1..716bf5c4db 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -13,6 +13,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; +import 'relay_endpoint.dart'; import 'relay_provider.dart'; import 'relay_socket.dart'; @@ -133,7 +134,7 @@ class RelaySessionNotifier extends Notifier { Duration timeout = const Duration(seconds: 8), }) async { final config = ref.read(relayConfigProvider); - final url = Uri.parse(config.baseUrl).resolve('/query').toString(); + final url = relayEndpoint(config.baseUrl, '/query'); final bodyBytes = utf8.encode( jsonEncode(filters.map((filter) => filter.toJson()).toList()), ); diff --git a/mobile/test/shared/relay/relay_endpoint_test.dart b/mobile/test/shared/relay/relay_endpoint_test.dart new file mode 100644 index 0000000000..be1ddaa0a0 --- /dev/null +++ b/mobile/test/shared/relay/relay_endpoint_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/shared/relay/relay_endpoint.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('relayEndpoint', () { + test('joins onto an origin-rooted relay unchanged', () { + expect(relayEndpoint('https://host', '/query'), 'https://host/query'); + expect( + relayEndpoint('https://host/', '/media/upload'), + 'https://host/media/upload', + ); + }); + + test('preserves a base path so BUZZ_BASE_PATH deployments resolve', () { + expect( + relayEndpoint('https://host/relay', '/query'), + 'https://host/relay/query', + ); + expect( + relayEndpoint('https://host/relay/', 'query'), + 'https://host/relay/query', + ); + expect( + relayEndpoint('https://host/buzz/relay', '/media/upload'), + 'https://host/buzz/relay/media/upload', + ); + }); + + test('treats the path as relative regardless of leading slashes', () { + expect( + relayEndpoint('https://host/relay', 'upload'), + 'https://host/relay/upload', + ); + expect( + relayEndpoint('https://host/relay', '//upload'), + 'https://host/relay/upload', + ); + }); + + test('returns the bare base for an empty path', () { + expect(relayEndpoint('https://host/relay/', '/'), 'https://host/relay'); + }); + }); + + group('isRelayMediaPath', () { + test('accepts the media route at the root and under a prefix', () { + expect(isRelayMediaPath('/media/abc'), isTrue); + expect(isRelayMediaPath('/relay/media/abc'), isTrue); + }); + + test('rejects near-miss segments', () { + expect(isRelayMediaPath('/media-evil/abc'), isFalse); + expect(isRelayMediaPath('/other/abc'), isFalse); + }); + }); +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 6cd601f56b..6ded6fb449 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -50,7 +50,9 @@ void main() { expect(capturedRequest, isNotNull); expect(capturedRequest!.method, 'POST'); - expect(capturedRequest!.url.toString(), 'https://relay.example/query'); + // The fixture's base URL carries a path (a BUZZ_BASE_PATH deployment), so + // the bridge call must land under it rather than at the origin root. + expect(capturedRequest!.url.toString(), 'https://relay.example/base/query'); expect(capturedRequest!.headers['Content-Type'], 'application/json'); expect(jsonDecode(capturedRequest!.body), [filter.toJson()]); @@ -70,9 +72,11 @@ void main() { expect(authEvent['kind'], 27235); expect(authEvent['pubkey'], keychain.public); + // The signed `u` tag must carry the base path as well: the relay + // reconstructs the prefixed URL, so a bare-origin tag would 401. expect( tags, - anyElement(equals(['u', 'https://relay.example/query'])), + anyElement(equals(['u', 'https://relay.example/base/query'])), ); expect(tags, anyElement(equals(['method', 'POST']))); expect(tags, anyElement(equals(['payload', payloadHash])));