From e0b50ddd5a6bac2ee9a507888d2d035fd8ebe727 Mon Sep 17 00:00:00 2001 From: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Date: Wed, 15 Jul 2026 09:19:42 -0400 Subject: [PATCH] feat(relay): gate Git web GUI separately Always expose the bundled invite landing page in the production image, while requiring BUZZ_SERVE_GIT_WEB_GUI=true to serve root and repository browser routes. Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co> --- Dockerfile | 5 +- TESTING.md | 3 +- crates/buzz-relay/src/config.rs | 13 +++- crates/buzz-relay/src/router.rs | 127 +++++++++++++++++++++----------- 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/Dockerfile b/Dockerfile index 634f9ac58e..ddc2579a33 100644 --- a/Dockerfile +++ b/Dockerfile @@ -138,8 +138,9 @@ COPY --from=builder /build/target/release/buzz-admin /usr/local/bin/buzz-admi COPY --from=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay COPY --from=web-builder /build/web/dist /srv/buzz/web -# The web UI is bundled but disabled by default. Set -# BUZZ_WEB_DIR=/srv/buzz/web to opt in. +# The invite landing page is always served from the bundled web UI. Repository +# browser routes require the separate BUZZ_SERVE_GIT_WEB_GUI=true opt-in. +ENV BUZZ_WEB_DIR=/srv/buzz/web # 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics EXPOSE 3000 8080 9102 diff --git a/TESTING.md b/TESTING.md index a40bb5e306..36649a2de8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -272,7 +272,8 @@ out of the box with `just setup` or `just relay`. Common overrides: | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start | | `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership | -| `BUZZ_WEB_DIR` | unset | Set to the bundled `/srv/buzz/web` in the container to opt in to the browser web UI | +| `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 | CLI-side, only two matter for testing: diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index b4f6633028..d72654dbfa 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -198,9 +198,12 @@ pub struct Config { pub push_gateway_timeout: Duration, /// Optional path to the web UI `dist/` directory. - /// When set, the relay serves the SPA from this directory for browser requests. + /// When set, the relay serves the invite landing page and its static assets. /// When unset, no static file serving happens (relay behaves as before). pub web_dir: Option, + /// 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, } fn parse_bind_addr(raw: &str) -> Result { @@ -605,6 +608,9 @@ impl Config { .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .map(std::path::PathBuf::from); + let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI") + .map(|value| value == "true" || value == "1") + .unwrap_or(false); if let Some(ref dir) = web_dir { if !dir.join("index.html").is_file() { @@ -668,6 +674,7 @@ impl Config { push_gateway_delivery_url, push_gateway_timeout, web_dir, + serve_git_web_gui, }) } } @@ -712,6 +719,10 @@ mod tests { !config.allow_nip_oa_auth, "allow_nip_oa_auth should default to false" ); + assert!( + !config.serve_git_web_gui, + "serve_git_web_gui should default to false" + ); assert!( config.huddle_audio_available, "huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior" diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index f26a3703f3..3c2e422e10 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -12,6 +12,7 @@ use axum::{ Router, }; use serde_json::json; +use tower::ServiceExt; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; use tower_http::services::ServeDir; @@ -107,44 +108,31 @@ pub fn build_router(state: Arc) -> Router { .merge(git_router) .merge(git_policy_router); - // When BUZZ_WEB_DIR is set, serve the SPA as a fallback for unmatched routes. + // When BUZZ_WEB_DIR is set, serve either the full SPA or its invite-only + // surface. Invite-only mode deliberately exposes only /invite/{code} and + // hashed build assets; root and repository browser routes remain absent. if let Some(ref web_dir) = state.config.web_dir { let index_path = web_dir.join("index.html"); - let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn( - move |req: axum::extract::Request| { - let index = index_path.clone(); - async move { - let path = req.uri().path(); - // Reserved API prefixes must 404 normally, not serve index.html. - let reserved = path.starts_with("/api/") - || path.starts_with("/media/") - || path.starts_with("/operator/") - || path.starts_with("/git/") - || path.starts_with("/internal/") - || path.starts_with("/.well-known/") - || path.starts_with("/huddle/") - || path == "/health" - || path == "/_liveness" - || path == "/_readiness" - || path == "/_status" - || path == "/info"; - // Files with extensions (e.g. /assets/missing.js) should 404. - // Exception: /invite/ — invite codes contain a "." - // (payload.mac separator) but are SPA routes, not files. - let has_ext = !path.starts_with("/invite/") - && path.rsplit('/').next().is_some_and(|seg| seg.contains('.')); - if reserved || has_ext { - Ok(StatusCode::NOT_FOUND.into_response()) - } else { - // SPA client-side route → serve index.html - match tokio::fs::read(&index).await { - Ok(body) => Ok(axum::response::Html(body).into_response()), - Err(_) => Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), - } - } + let static_files = ServeDir::new(web_dir); + let serve_git_web_gui = state.config.serve_git_web_gui; + let spa_fallback = tower::service_fn(move |req: axum::extract::Request| { + let index = index_path.clone(); + let static_files = static_files.clone(); + async move { + let path = req.uri().path(); + if path.starts_with("/assets/") { + return static_files + .oneshot(req) + .await + .map(IntoResponse::into_response); + } + + if should_serve_spa(path, serve_git_web_gui) { + return Ok(read_spa_index(&index).await); } - }, - )); + Ok(StatusCode::NOT_FOUND.into_response()) + } + }); merged = merged.fallback_service(spa_fallback); } @@ -154,6 +142,26 @@ pub fn build_router(state: Arc) -> Router { .layer(build_cors_layer(&state.config.cors_origins)) } +fn is_invite_landing_path(path: &str) -> bool { + path.strip_prefix("/invite/") + .is_some_and(|code| !code.is_empty() && !code.contains('/')) +} + +fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool { + is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path)) +} + +fn is_git_web_gui_path(path: &str) -> bool { + path == "/" || path == "/repos" || path.starts_with("/repos/") +} + +async fn read_spa_index(index: &std::path::Path) -> axum::response::Response { + match tokio::fs::read(index).await { + Ok(body) => axum::response::Html(body).into_response(), + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + /// Build the health-only router for K8s probes (port 8080 in CAKE). /// /// No metrics middleware, no auth, no CORS, no body limit. @@ -218,12 +226,14 @@ async fn nip11_or_ws_handler( .on_upgrade(move |socket| handle_connection(socket, state, addr, tenant)) .into_response(), Err(_) => { - // Browser requesting HTML and web UI is configured → serve SPA. - if let Some(ref dir) = state.config.web_dir { - if accept.contains("text/html") { - let index = dir.join("index.html"); - if let Ok(body) = tokio::fs::read(&index).await { - return axum::response::Html(body).into_response(); + // Browser requesting HTML and Git web GUI is enabled → serve SPA. + if state.config.serve_git_web_gui { + if let Some(ref dir) = state.config.web_dir { + if accept.contains("text/html") { + let index = dir.join("index.html"); + if let Ok(body) = tokio::fs::read(&index).await { + return axum::response::Html(body).into_response(); + } } } } @@ -322,3 +332,38 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { .allow_methods(tower_http::cors::Any) .allow_headers(tower_http::cors::Any) } + +#[cfg(test)] +mod tests { + use super::{is_git_web_gui_path, is_invite_landing_path, should_serve_spa}; + + #[test] + fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { + assert!(is_invite_landing_path("/invite/payload.mac")); + assert!(!is_invite_landing_path("/invite/")); + assert!(!is_invite_landing_path("/invite/code/extra")); + assert!(!is_invite_landing_path("/repos")); + assert!(!is_invite_landing_path("/")); + } + + #[test] + fn git_web_gui_paths_are_explicit() { + assert!(is_git_web_gui_path("/")); + assert!(is_git_web_gui_path("/repos")); + assert!(is_git_web_gui_path("/repos/example")); + assert!(!is_git_web_gui_path("/repository")); + assert!(!is_git_web_gui_path("/arbitrary")); + assert!(!is_git_web_gui_path("/api/invites")); + } + + #[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)); + } +}