Skip to content
Open
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
1 change: 1 addition & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `BUZZ_ALLOW_NIP_OA_AUTH` | `false` | Enable NIP-OA owner attestation for membership |
| `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 |
| `BUZZ_WEB_SPA` | unset | Set to `full` when the configured `BUZZ_WEB_DIR` bundle is a full client that owns client-side routing: every path the relay does not serve itself then falls back to the SPA shell. Server-owned paths (`/api/...`, `/git/...`, `/hooks/...`, `/media/...`, `/upload`, `/.well-known/...`, `/_*`, `/events`, `/query`, `/count`, `/info`, `/health`, `/moderation`) still return their own responses, so a mistyped API call 404s instead of returning HTML |

CLI-side, only two matter for testing:

Expand Down
10 changes: 10 additions & 0 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ pub struct Config {
/// 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,
/// Whether the configured web bundle is a full client that owns client-side
/// routing. When true (`BUZZ_WEB_SPA=full`), every path the relay does not
/// serve itself falls back to the SPA shell. Defaults to false, which keeps
/// the conservative behaviour of serving only the invite landing page.
pub web_spa_full: bool,
}

fn parse_bind_addr(raw: &str) -> Result<SocketAddr, ConfigError> {
Expand Down Expand Up @@ -862,6 +867,9 @@ impl Config {
let serve_git_web_gui = std::env::var("BUZZ_SERVE_GIT_WEB_GUI")
.map(|value| value == "true" || value == "1")
.unwrap_or(false);
let web_spa_full = std::env::var("BUZZ_WEB_SPA")
.map(|value| value.trim().eq_ignore_ascii_case("full"))
.unwrap_or(false);

if let Some(ref dir) = web_dir {
if !dir.join("index.html").is_file() {
Expand Down Expand Up @@ -936,6 +944,7 @@ impl Config {
admin,
web_dir,
serve_git_web_gui,
web_spa_full,
})
}
}
Expand Down Expand Up @@ -986,6 +995,7 @@ mod tests {
!config.serve_git_web_gui,
"serve_git_web_gui should default to false"
);
assert!(!config.web_spa_full, "web_spa_full should default to false");
assert!(
!config.require_media_get_auth,
"require_media_get_auth should default to false for staged client rollout"
Expand Down
98 changes: 89 additions & 9 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
let web_index = web_dir.as_ref().map(|dir| dir.join("index.html"));
let web_files = web_dir.map(ServeDir::new);
let serve_git_web_gui = state.config.serve_git_web_gui;
let web_spa_full = state.config.web_spa_full;
let fallback_state = state.clone();
let spa_fallback = tower::service_fn(move |req: axum::extract::Request| {
let admin_index = admin_index.clone();
Expand All @@ -176,7 +177,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
if path.starts_with("/assets/") {
return files.oneshot(req).await.map(IntoResponse::into_response);
}
if should_serve_spa(path, serve_git_web_gui) {
if should_serve_spa(path, serve_git_web_gui, web_spa_full) {
return Ok(read_spa_index(&index).await);
}
}
Expand Down Expand Up @@ -218,10 +219,42 @@ fn is_invite_landing_path(path: &str) -> bool {
.is_some_and(|code| !code.is_empty() && !code.contains('/'))
}

fn should_serve_spa(path: &str, serve_git_web_gui: bool) -> bool {
fn should_serve_spa(path: &str, serve_git_web_gui: bool, web_spa_full: bool) -> bool {
if web_spa_full {
// A deployment whose bundle is a full client wants client-side routing on
// arbitrary paths. Server-owned prefixes still 404, so a mistyped API call
// gets an error rather than an HTML page a client would try to parse.
return !is_server_owned_path(path);
}
is_invite_landing_path(path) || (serve_git_web_gui && is_git_web_gui_path(path))
}

/// Paths the relay answers itself, which must never fall back to the SPA shell.
fn is_server_owned_path(path: &str) -> bool {
const PREFIXES: [&str; 7] = [
"/api/",
"/git/",
"/hooks/",
"/media/",
"/upload/",
"/.well-known/",
"/_",
];
const EXACT: [&str; 10] = [
"/events",
"/query",
"/count",
"/info",
"/health",
"/moderation",
"/upload",
"/api",
"/git",
"/hooks",
];
PREFIXES.iter().any(|p| path.starts_with(p)) || EXACT.contains(&path)
}

fn is_git_web_gui_path(path: &str) -> bool {
path == "/" || path == "/repos" || path.starts_with("/repos/")
}
Expand Down Expand Up @@ -481,13 +514,60 @@ mod tests {

#[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));
assert!(should_serve_spa("/invite/payload.mac", false, false));
assert!(should_serve_spa("/invite/payload.mac", true, false));
assert!(!should_serve_spa("/", false, false));
assert!(!should_serve_spa("/repos/example", false, false));
assert!(should_serve_spa("/", true, false));
assert!(should_serve_spa("/repos/example", true, false));
assert!(!should_serve_spa("/arbitrary", true, false));
}

#[test]
fn full_spa_mode_serves_arbitrary_client_routes() {
// Opting in makes client-side routing work for paths the relay has never
// heard of -- that is the whole point of a full client bundle.
assert!(should_serve_spa("/arbitrary", false, true));
assert!(should_serve_spa("/settings", false, true));
assert!(should_serve_spa("/settings/profile/nested", false, true));
assert!(should_serve_spa("/", false, true));
assert!(should_serve_spa("/repos/example", false, true));
assert!(should_serve_spa("/invite/payload.mac", false, true));
}

#[test]
fn full_spa_mode_still_lets_server_paths_404() {
// Falling back to HTML here would turn a mistyped API call into a 200
// that clients then fail to parse.
for path in [
"/api/invites",
"/api",
"/git/owner/repo.git",
"/hooks/abc",
"/media/blob",
"/upload",
"/.well-known/nostr.json",
"/_liveness",
"/_status",
"/events",
"/query",
"/count",
"/info",
"/health",
"/moderation",
] {
assert!(
!should_serve_spa(path, true, true),
"{path} must not fall back to the SPA shell"
);
}
}

#[test]
fn full_spa_mode_is_off_by_default() {
// Without the opt-in, behaviour is unchanged: unknown paths 404.
assert!(!should_serve_spa("/settings", false, false));
assert!(!should_serve_spa("/arbitrary", false, false));
}

#[tokio::test(flavor = "current_thread")]
Expand Down