mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757
parent
f5c33d3335
commit
34dc7dec75
+3
-2
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user