Skip to content
Open
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
154 changes: 151 additions & 3 deletions crates/buzz-relay/src/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -110,6 +110,9 @@ pub fn build_router(state: Arc<AppState>) -> 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))
Expand Down Expand Up @@ -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<String> {
let raw_host = headers.get(header::HOST)?.to_str().ok()?.trim();
let authority = raw_host.parse::<Authority>().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('&', "&amp;");
Comment on lines +251 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve policy acceptance in fallback invite links

When BUZZ_WEB_DIR is unset but a join policy is configured, this generated deep link neither includes a policy_receipt nor presents the policy-acceptance flow. Both claim paths reject a missing receipt with join_policy_required in api/invites.rs, and the desktop and mobile deep-link handlers forward only an already-present receipt rather than acquiring one. Consequently, every user clicking this fallback page is unable to claim the invite; the fallback must collect policy acceptance and add its receipt, or hand off to a client flow that does so before claiming.

Useful? React with 👍 / 👎.


const TEMPLATE: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Join Buzz</title>
<style>
:root { color-scheme: light; font-family: ui-sans-serif, system-ui, sans-serif; }
* { box-sizing: border-box; }
body {
align-items: center;
background: linear-gradient(180deg, #d7d72e 0%, #d7e7f6 100%);
display: flex;
justify-content: center;
margin: 0;
min-height: 100vh;
padding: 24px;
}
main {
background: white;
border-radius: 24px;
box-shadow: 0 20px 60px rgb(0 0 0 / 12%);
max-width: 480px;
padding: 48px 32px;
text-align: center;
width: 100%;
}
h1 { font-size: 28px; margin: 0 0 12px; }
p { color: rgb(0 0 0 / 62%); line-height: 1.5; margin: 0 0 28px; }
a { display: block; }
.primary {
background: black;
border-radius: 10px;
color: white;
font-weight: 600;
padding: 12px 18px;
text-decoration: none;
}
.download {
color: black;
font-size: 14px;
margin-top: 18px;
text-underline-offset: 3px;
}
</style>
</head>
<body>
<main>
<h1>You're invited to Buzz</h1>
<p>Open Buzz to accept this invitation and join the community.</p>
<a class="primary" href="__APP_LINK__">Accept invite in Buzz</a>
<a class="download" href="https://github.com/block/buzz/releases/latest" rel="noreferrer">Download Buzz</a>
</main>
</body>
</html>
"#;

TEMPLATE.replace("__APP_LINK__", &app_link)
}

async fn invite_landing_handler(
State(state): State<Arc<AppState>>,
Path(code): Path<String>,
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.
Expand Down Expand Up @@ -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\"><script>alert(1)</script>",
);

assert!(html.contains(
"buzz://join?relay=wss%3A%2F%2Frelay.example.com&amp;code=v2.payload%22%3E%3Cscript%3Ealert%281%29%3C%2Fscript%3E"
));
assert!(!html.contains("<script>alert(1)</script>"));
}

#[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();
Expand Down