Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
13 changes: 12 additions & 1 deletion crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -668,6 +674,7 @@ impl Config {
push_gateway_delivery_url,
push_gateway_timeout,
web_dir,
serve_git_web_gui,
})
}
}
Expand Down Expand Up @@ -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"
Expand Down
127 changes: 86 additions & 41 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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.
Expand Down Expand Up @@ -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();
}
}
}
}
Expand Down Expand Up @@ -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));
}
}