feat(relay): gate Git web GUI separately (#1901)

Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tyler
2026-07-15 11:15:54 -04:00
committed by GitHub
co-authored by npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent f5c33d3335
commit 34dc7dec75
4 changed files with 103 additions and 45 deletions
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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:
+12 -1
View File
@@ -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<std::path::PathBuf>,
/// 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<SocketAddr, ConfigError> {
@@ -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"
+86 -41
View File
@@ -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<AppState>) -> 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/<code> — 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<AppState>) -> 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));
}
}