mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00: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:
@@ -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=builder /build/target/release/buzz-pair-relay /usr/local/bin/buzz-pair-relay
|
||||||
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
COPY --from=web-builder /build/web/dist /srv/buzz/web
|
||||||
|
|
||||||
# The web UI is bundled but disabled by default. Set
|
# The invite landing page is always served from the bundled web UI. Repository
|
||||||
# BUZZ_WEB_DIR=/srv/buzz/web to opt in.
|
# 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
|
# 3000: app (WS + REST) · 8080: /_liveness, /_readiness · 9102: /metrics
|
||||||
EXPOSE 3000 8080 9102
|
EXPOSE 3000 8080 9102
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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_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:
|
CLI-side, only two matter for testing:
|
||||||
|
|
||||||
|
|||||||
@@ -198,9 +198,12 @@ pub struct Config {
|
|||||||
pub push_gateway_timeout: Duration,
|
pub push_gateway_timeout: Duration,
|
||||||
|
|
||||||
/// Optional path to the web UI `dist/` directory.
|
/// 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).
|
/// When unset, no static file serving happens (relay behaves as before).
|
||||||
pub web_dir: Option<std::path::PathBuf>,
|
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> {
|
fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
|
||||||
@@ -605,6 +608,9 @@ impl Config {
|
|||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(std::path::PathBuf::from);
|
.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 let Some(ref dir) = web_dir {
|
||||||
if !dir.join("index.html").is_file() {
|
if !dir.join("index.html").is_file() {
|
||||||
@@ -668,6 +674,7 @@ impl Config {
|
|||||||
push_gateway_delivery_url,
|
push_gateway_delivery_url,
|
||||||
push_gateway_timeout,
|
push_gateway_timeout,
|
||||||
web_dir,
|
web_dir,
|
||||||
|
serve_git_web_gui,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -712,6 +719,10 @@ mod tests {
|
|||||||
!config.allow_nip_oa_auth,
|
!config.allow_nip_oa_auth,
|
||||||
"allow_nip_oa_auth should default to false"
|
"allow_nip_oa_auth should default to false"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!config.serve_git_web_gui,
|
||||||
|
"serve_git_web_gui should default to false"
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
config.huddle_audio_available,
|
config.huddle_audio_available,
|
||||||
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
|
"huddle_audio_available should default to true so single-pod (N=1) keeps today's huddle behavior"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tower::ServiceExt;
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
use tower_http::limit::RequestBodyLimitLayer;
|
use tower_http::limit::RequestBodyLimitLayer;
|
||||||
use tower_http::services::ServeDir;
|
use tower_http::services::ServeDir;
|
||||||
@@ -107,44 +108,31 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.merge(git_router)
|
.merge(git_router)
|
||||||
.merge(git_policy_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 {
|
if let Some(ref web_dir) = state.config.web_dir {
|
||||||
let index_path = web_dir.join("index.html");
|
let index_path = web_dir.join("index.html");
|
||||||
let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn(
|
let static_files = ServeDir::new(web_dir);
|
||||||
move |req: axum::extract::Request| {
|
let serve_git_web_gui = state.config.serve_git_web_gui;
|
||||||
let index = index_path.clone();
|
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
|
||||||
async move {
|
let index = index_path.clone();
|
||||||
let path = req.uri().path();
|
let static_files = static_files.clone();
|
||||||
// Reserved API prefixes must 404 normally, not serve index.html.
|
async move {
|
||||||
let reserved = path.starts_with("/api/")
|
let path = req.uri().path();
|
||||||
|| path.starts_with("/media/")
|
if path.starts_with("/assets/") {
|
||||||
|| path.starts_with("/operator/")
|
return static_files
|
||||||
|| path.starts_with("/git/")
|
.oneshot(req)
|
||||||
|| path.starts_with("/internal/")
|
.await
|
||||||
|| path.starts_with("/.well-known/")
|
.map(IntoResponse::into_response);
|
||||||
|| 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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
));
|
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);
|
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))
|
.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).
|
/// Build the health-only router for K8s probes (port 8080 in CAKE).
|
||||||
///
|
///
|
||||||
/// No metrics middleware, no auth, no CORS, no body limit.
|
/// 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))
|
.on_upgrade(move |socket| handle_connection(socket, state, addr, tenant))
|
||||||
.into_response(),
|
.into_response(),
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// Browser requesting HTML and web UI is configured → serve SPA.
|
// Browser requesting HTML and Git web GUI is enabled → serve SPA.
|
||||||
if let Some(ref dir) = state.config.web_dir {
|
if state.config.serve_git_web_gui {
|
||||||
if accept.contains("text/html") {
|
if let Some(ref dir) = state.config.web_dir {
|
||||||
let index = dir.join("index.html");
|
if accept.contains("text/html") {
|
||||||
if let Ok(body) = tokio::fs::read(&index).await {
|
let index = dir.join("index.html");
|
||||||
return axum::response::Html(body).into_response();
|
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_methods(tower_http::cors::Any)
|
||||||
.allow_headers(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