diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 400ed1dfe3..daf2184de6 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -5,10 +5,10 @@ use std::sync::Arc; use axum::{ body::Body, - extract::{ConnectInfo, FromRequest, State, WebSocketUpgrade}, - http::{HeaderMap, Request, StatusCode}, + extract::{ConnectInfo, FromRequest, Path, State, WebSocketUpgrade}, + http::{header, uri::Authority, HeaderMap, Request, StatusCode}, middleware, - response::{IntoResponse, Json}, + response::{Html, IntoResponse, Json, Response}, routing::{get, post, put}, Router, }; @@ -110,6 +110,9 @@ pub fn build_router(state: Arc) -> Router { post(api::invites::accept_policy), ) .route("/api/invites/claim", post(api::invites::claim_invite)) + // Keep invite links usable even when BUZZ_WEB_DIR is not configured. + // Deployments with the web bundle still receive the full SPA page. + .route("/invite/{code}", get(invite_landing_handler)) // Moderation queue reads (NIP-98 auth + mod-authz gate, L6) .route("/moderation/reports", get(api::bridge::moderation_reports)) .route("/moderation/audit", get(api::bridge::moderation_audit)) @@ -233,6 +236,123 @@ async fn read_spa_index(index: &std::path::Path) -> axum::response::Response { } } +fn invite_relay_url(configured_relay_url: &str, headers: &HeaderMap) -> Option { + let raw_host = headers.get(header::HOST)?.to_str().ok()?.trim(); + let authority = raw_host.parse::().ok()?; + let scheme = if configured_relay_url.trim_start().starts_with("wss://") { + "wss" + } else { + "ws" + }; + Some(format!("{scheme}://{authority}")) +} + +fn minimal_invite_landing_html(relay_url: &str, code: &str) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("relay", relay_url) + .append_pair("code", code) + .finish(); + let app_link = format!("buzz://join?{query}").replace('&', "&"); + + const TEMPLATE: &str = r#" + + + + + Join Buzz + + + +
+

You're invited to Buzz

+

Open Buzz to accept this invitation and join the community.

+ Accept invite in Buzz + Download Buzz +
+ + +"#; + + TEMPLATE.replace("__APP_LINK__", &app_link) +} + +async fn invite_landing_handler( + State(state): State>, + Path(code): Path, + headers: HeaderMap, +) -> Response { + if api::admin::is_admin_host(&state, &headers) { + return StatusCode::NOT_FOUND.into_response(); + } + + if let Some(web_dir) = state.config.web_dir.as_ref() { + return read_spa_index(&web_dir.join("index.html")).await; + } + + let Some(relay_url) = invite_relay_url(&state.config.relay_url, &headers) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + let mut response = Html(minimal_invite_landing_html(&relay_url, &code)).into_response(); + let response_headers = response.headers_mut(); + response_headers.insert( + header::CACHE_CONTROL, + header::HeaderValue::from_static("no-store"), + ); + response_headers.insert( + header::CONTENT_SECURITY_POLICY, + header::HeaderValue::from_static( + "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", + ), + ); + response_headers.insert( + header::REFERRER_POLICY, + header::HeaderValue::from_static("no-referrer"), + ); + response_headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + header::HeaderValue::from_static("nosniff"), + ); + response +} + /// Build the health-only router for K8s probes (port 8080 in CAKE). /// /// No metrics middleware, no auth, no CORS, no body limit. @@ -490,6 +610,34 @@ mod tests { assert!(!should_serve_spa("/arbitrary", true)); } + #[test] + fn minimal_invite_page_builds_a_safe_app_deep_link() { + let html = minimal_invite_landing_html( + "wss://relay.example.com", + "v2.payload\">", + ); + + assert!(html.contains( + "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=v2.payload%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E" + )); + assert!(!html.contains("")); + } + + #[test] + fn invite_relay_url_uses_the_request_host_and_configured_tls_posture() { + let mut headers = HeaderMap::new(); + headers.insert(header::HOST, "community.example.com:8443".parse().unwrap()); + + assert_eq!( + invite_relay_url("wss://relay.internal", &headers).as_deref(), + Some("wss://community.example.com:8443") + ); + assert_eq!( + invite_relay_url("ws://localhost:3000", &headers).as_deref(), + Some("ws://community.example.com:8443") + ); + } + #[tokio::test(flavor = "current_thread")] async fn http_and_datastore_spans_are_exported_in_the_same_trace() { let exporter = InMemorySpanExporter::default();