diff --git a/TESTING.md b/TESTING.md index 51a5eb44c1..c35e3f750b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -284,6 +284,7 @@ out of the box with `just setup` or `just relay`. Common overrides: | `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership | | `BUZZ_WEB_DIR` | unset (source), `/srv/buzz/web` (container) | Directory containing the invite landing bundle; the production container enables it so `/invite/{code}` always works | | `BUZZ_SERVE_GIT_WEB_GUI` | `false` | Set to `true` or `1` to expose the bundled Git repository browser at `/` and `/repos/...`; invite routes do not depend on this flag | +| `BUZZ_WEB_SPA` | unset | Set to `full` when the configured `BUZZ_WEB_DIR` bundle is a full client that owns client-side routing: every path the relay does not serve itself then falls back to the SPA shell. Server-owned paths (`/api/...`, `/git/...`, `/hooks/...`, `/media/...`, `/upload`, `/.well-known/...`, `/_*`, `/events`, `/query`, `/count`, `/info`, `/health`, `/moderation`) still return their own responses, so a mistyped API call 404s instead of returning HTML | CLI-side, only two matter for testing: diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..7469faa8b5 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -268,6 +268,11 @@ pub struct Config { /// Whether the configured web bundle serves Git browser routes in addition /// to the public invite landing page. Defaults to false. pub serve_git_web_gui: bool, + /// Whether the configured web bundle is a full client that owns client-side + /// routing. When true (`BUZZ_WEB_SPA=full`), every path the relay does not + /// serve itself falls back to the SPA shell. Defaults to false, which keeps + /// the conservative behaviour of serving only the invite landing page. + pub web_spa_full: bool, } fn parse_bind_addr(raw: &str) -> Result { @@ -862,6 +867,9 @@ impl Config { let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI") .map(|value| value == "true" || value == "1") .unwrap_or(false); + let web_spa_full = std::env::var("BUZZ_WEB_SPA") + .map(|value| value.trim().eq_ignore_ascii_case("full")) + .unwrap_or(false); if let Some(ref dir) = web_dir { if !dir.join("index.html").is_file() { @@ -936,6 +944,7 @@ impl Config { admin, web_dir, serve_git_web_gui, + web_spa_full, }) } } @@ -986,6 +995,7 @@ mod tests { !config.serve_git_web_gui, "serve_git_web_gui should default to false" ); + assert!(!config.web_spa_full, "web_spa_full should default to false"); assert!( !config.require_media_get_auth, "require_media_get_auth should default to false for staged client rollout" diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..c21c6a1880 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -150,6 +150,7 @@ pub fn build_router(state: Arc) -> Router { let web_index = web_dir.as_ref().map(|dir| dir.join("index.html")); let web_files = web_dir.map(ServeDir::new); let serve_git_web_gui = state.config.serve_git_web_gui; + let web_spa_full = state.config.web_spa_full; let fallback_state = state.clone(); let spa_fallback = tower::service_fn(move |req: axum::extract::Request| { let admin_index = admin_index.clone(); @@ -176,7 +177,7 @@ pub fn build_router(state: Arc) -> Router { if path.starts_with("/assets/") { return files.oneshot(req).await.map(IntoResponse::into_response); } - if should_serve_spa(path, serve_git_web_gui) { + if should_serve_spa(path, serve_git_web_gui, web_spa_full) { return Ok(read_spa_index(&index).await); } } @@ -218,10 +219,42 @@ fn is_invite_landing_path(path: &str) -> bool { .is_some_and(|code| !code.is_empty() && !code.contains('/')) } -fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool { +fn should_serve_spa(path: &str, serve_git_web_gui: bool, web_spa_full: bool) -> bool { + if web_spa_full { + // A deployment whose bundle is a full client wants client-side routing on + // arbitrary paths. Server-owned prefixes still 404, so a mistyped API call + // gets an error rather than an HTML page a client would try to parse. + return !is_server_owned_path(path); + } is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path)) } +/// Paths the relay answers itself, which must never fall back to the SPA shell. +fn is_server_owned_path(path: &str) -> bool { + const PREFIXES: [&str; 7] = [ + "/api/", + "/git/", + "/hooks/", + "/media/", + "/upload/", + "/.well-known/", + "/_", + ]; + const EXACT: [&str; 10] = [ + "/events", + "/query", + "/count", + "/info", + "/health", + "/moderation", + "/upload", + "/api", + "/git", + "/hooks", + ]; + PREFIXES.iter().any(|p| path.starts_with(p)) || EXACT.contains(&path) +} + fn is_git_web_gui_path(path: &str) -> bool { path == "/" || path == "/repos" || path.starts_with("/repos/") } @@ -481,13 +514,60 @@ mod tests { #[test] fn invite_is_always_served_but_git_gui_requires_opt_in() { - assert!(should_serve_spa("/invite/payload.mac", false)); - assert!(should_serve_spa("/invite/payload.mac", true)); - assert!(!should_serve_spa("/", false)); - assert!(!should_serve_spa("/repos/example", false)); - assert!(should_serve_spa("/", true)); - assert!(should_serve_spa("/repos/example", true)); - assert!(!should_serve_spa("/arbitrary", true)); + assert!(should_serve_spa("/invite/payload.mac", false, false)); + assert!(should_serve_spa("/invite/payload.mac", true, false)); + assert!(!should_serve_spa("/", false, false)); + assert!(!should_serve_spa("/repos/example", false, false)); + assert!(should_serve_spa("/", true, false)); + assert!(should_serve_spa("/repos/example", true, false)); + assert!(!should_serve_spa("/arbitrary", true, false)); + } + + #[test] + fn full_spa_mode_serves_arbitrary_client_routes() { + // Opting in makes client-side routing work for paths the relay has never + // heard of -- that is the whole point of a full client bundle. + assert!(should_serve_spa("/arbitrary", false, true)); + assert!(should_serve_spa("/settings", false, true)); + assert!(should_serve_spa("/settings/profile/nested", false, true)); + assert!(should_serve_spa("/", false, true)); + assert!(should_serve_spa("/repos/example", false, true)); + assert!(should_serve_spa("/invite/payload.mac", false, true)); + } + + #[test] + fn full_spa_mode_still_lets_server_paths_404() { + // Falling back to HTML here would turn a mistyped API call into a 200 + // that clients then fail to parse. + for path in [ + "/api/invites", + "/api", + "/git/owner/repo.git", + "/hooks/abc", + "/media/blob", + "/upload", + "/.well-known/nostr.json", + "/_liveness", + "/_status", + "/events", + "/query", + "/count", + "/info", + "/health", + "/moderation", + ] { + assert!( + !should_serve_spa(path, true, true), + "{path} must not fall back to the SPA shell" + ); + } + } + + #[test] + fn full_spa_mode_is_off_by_default() { + // Without the opt-in, behaviour is unchanged: unknown paths 404. + assert!(!should_serve_spa("/settings", false, false)); + assert!(!should_serve_spa("/arbitrary", false, false)); } #[tokio::test(flavor = "current_thread")]