diff --git a/Dockerfile b/Dockerfile index 02446e144f..39692acac9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -124,6 +124,7 @@ COPY web/package.json web/ COPY admin-web/package.json admin-web/ RUN pnpm install --frozen-lockfile --filter buzz-web --filter buzz-admin-web COPY web/ web/ +COPY previews/invite-video/buzz-invite-loop.mp4 previews/invite-video/buzz-invite-poster.png previews/invite-video/ COPY admin-web/ admin-web/ RUN pnpm -C web build && pnpm -C admin-web build diff --git a/Justfile b/Justfile index ea66c2c303..4a1181a6f6 100644 --- a/Justfile +++ b/Justfile @@ -644,6 +644,8 @@ test-unit: + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=state::tests::manager_disconnect_sets_reason_enqueues_frame_then_cancels) + test(/^api::nip_fi::/)' + # Public invite metadata and HTTP media delivery are infra-free. + cargo nextest run -p buzz-relay --lib -E 'test(/invite_preview/)' # Invitation drain/cutoff boundary contracts are infra-free. cargo nextest run -p buzz-relay --lib -E 'test(/^invite_token::/) + test(/^config::invite_cutoff_tests::/)' # Runtime concurrency caps: the flag controller, provider selection and diff --git a/crates/buzz-relay/src/invite_preview.rs b/crates/buzz-relay/src/invite_preview.rs new file mode 100644 index 0000000000..e7c804051f --- /dev/null +++ b/crates/buzz-relay/src/invite_preview.rs @@ -0,0 +1,143 @@ +//! Public, generic invite metadata. Never inspect or redeem the invite token. + +use axum::{ + http::StatusCode, + response::{Html, IntoResponse, Response}, +}; +use std::path::Path; +use url::Url; + +pub(crate) async fn response( + index: &Path, + relay_url: &str, + resolver: &R, + raw_host: &str, +) -> Response { + match tokio::fs::read_to_string(index).await { + Ok(html) => { + let tenant = crate::tenant::bind_community(resolver, raw_host).await.ok(); + Html(with_metadata(html, relay_url, tenant.as_ref())).into_response() + } + Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), + } +} + +fn with_metadata( + html: String, + relay_url: &str, + tenant: Option<&buzz_core::tenant::TenantContext>, +) -> String { + let Some(tenant) = tenant else { + return html; + }; + let Ok(relay) = Url::parse(relay_url) else { + return html; + }; + let scheme = match relay.scheme() { + "wss" | "https" => "https", + "ws" | "http" => "http", + _ => return html, + }; + // The deployment URL supplies TLS posture only. The resolved request host + // is authoritative; never advertise a neighboring community's seed host. + let origin = format!("{scheme}://{}", tenant.host()); + let origin = origin + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">"); + let poster = format!("{origin}/assets/invite-preview/buzz-invite-poster-v1.png"); + let video = format!("{origin}/assets/invite-preview/buzz-invite-loop-v1.mp4"); + let secure = if scheme == "https" { + format!("\n") + } else { + String::new() + }; + let tags = format!( + r#" + + + + + + + + + +{secure} + + +"# + ); + html.replacen("", &format!("{tags}"), 1) +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Resolver; + impl crate::tenant::HostResolver for Resolver { + type Error = (); + async fn resolve_host( + &self, + host: &str, + ) -> Result, ()> { + match host { + "alpha.example" | "beta.example:8443" => { + Ok(Some(buzz_core::tenant::CommunityId::from_uuid( + uuid::Uuid::from_u128(if host.starts_with("alpha") { 1 } else { 2 }), + ))) + } + "error.example" => Err(()), + _ => Ok(None), + } + } + } + + #[tokio::test] + async fn media_uses_only_resolved_request_host_and_fails_closed() { + let document = "Buzzapp"; + let dir = tempfile::tempdir().unwrap(); + let index = dir.path().join("index.html"); + std::fs::write(&index, document).unwrap(); + for relay in [ + "wss://seed.example/relay", + "https://user:password@seed.example/path?secret=x", + "ws://localhost:3000", + ] { + for host in [ + "ALPHA.example", + "beta.example:8443", + "unknown.example", + "error.example", + "", + ] { + let response = response(&index, relay, &Resolver, host).await; + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = std::str::from_utf8(&bytes).unwrap(); + if host == "ALPHA.example" || host == "beta.example:8443" { + let scheme = if relay.starts_with("ws://") { + "http" + } else { + "https" + }; + let origin = format!("{scheme}://{}", host.to_lowercase()); + for file in ["buzz-invite-loop-v1.mp4", "buzz-invite-poster-v1.png"] { + assert!(html.contains(&format!( + "content=\"{origin}/assets/invite-preview/{file}\"" + ))); + } + assert_eq!(html.contains("og:video:secure_url"), scheme == "https"); + assert!(!html.contains("seed.example")); + assert!(!html.contains("password")); + assert!(!html.contains("secret")); + } else { + assert_eq!(html, document); + } + } + } + } +} diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 3f6164ddb1..954ab78a40 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -9,6 +9,7 @@ mod build_info; mod community_active; mod fenced_cache; mod host_cache; +mod invite_preview; mod rejection; /// Shared NIP-FI assertion evaluation, denial rendering, and key pairing. diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index 45a7446ce1..f271b4e58d 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -616,6 +616,18 @@ pub fn build_router(state: Arc) -> Router { if path.starts_with("/assets/") { return files.oneshot(req).await.map(IntoResponse::into_response); } + if is_invite_landing_path(path) { + return Ok(crate::invite_preview::response( + &index, + &state.config.relay_url, + state.as_ref(), + req.headers() + .get(axum::http::header::HOST) + .and_then(|host| host.to_str().ok()) + .unwrap_or(""), + ) + .await); + } if should_serve_spa(path, serve_git_web_gui) { return Ok(read_spa_index(&index).await); } @@ -2130,6 +2142,154 @@ mod tests { .expect("response") } + #[tokio::test] + async fn invite_preview_is_public_and_media_supports_range_requests() { + let admin_dir = tempfile::tempdir().unwrap(); + let web_dir = tempfile::tempdir().unwrap(); + write_admin_bundle(admin_dir.path()); + write_bundle(web_dir.path()); + std::fs::write( + web_dir.path().join("index.html"), + "invite app", + ) + .unwrap(); + let media_dir = web_dir.path().join("assets/invite-preview"); + std::fs::create_dir_all(&media_dir).unwrap(); + for (name, bytes) in [ + ( + "buzz-invite-loop-v1.mp4", + include_bytes!("../../../previews/invite-video/buzz-invite-loop.mp4").as_slice(), + ), + ( + "buzz-invite-poster-v1.png", + include_bytes!("../../../previews/invite-video/buzz-invite-poster.png").as_slice(), + ), + ] { + std::fs::write(media_dir.join(name), bytes).unwrap(); + } + let state = spa_state(admin_dir.path(), web_dir.path()).await; + for (host, id) in [("alpha.example", 1), ("beta.example", 2)] { + state.tenant_host_cache.insert( + host.into(), + ( + buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::from_u128(id)), + std::time::Instant::now(), + ), + ); + } + // Also exercise the production router over a real HTTP connection. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let app = build_router(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let client = reqwest::Client::builder().no_proxy().build().unwrap(); + let fetched = client + .get(format!("http://{address}/invite/unused-token")) + .header("host", "alpha.example") + .send() + .await + .unwrap(); + assert_eq!(fetched.status(), StatusCode::OK); + assert!(fetched.text().await.unwrap().contains("og:video")); + let fetched = client + .get(format!( + "http://{address}/assets/invite-preview/buzz-invite-loop-v1.mp4" + )) + .header("range", "bytes=0-31") + .send() + .await + .unwrap(); + assert_eq!(fetched.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(fetched.bytes().await.unwrap().len(), 32); + server.abort(); + for host in ["alpha.example", "beta.example", ""] { + let response = spa_response(state.clone(), host, "/invite/unused-token").await; + assert_eq!(response.status(), StatusCode::OK); + let html = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = std::str::from_utf8(&html).unwrap(); + if host.is_empty() { + assert!(!html.contains("og:video")); + assert!(!html.contains("og:image")); + continue; + } + assert!(html.contains(&format!("http://{host}/assets/invite-preview/"))); + assert!(!html.contains(if host == "alpha.example" { + "beta.example" + } else { + "alpha.example" + })); + assert!(html.contains("og:video")); + assert!(html.contains("og:image")); + assert!(!html.contains("untrusted.example")); + assert!(!html.contains("unused-token")); + } + for path in ["/repos", "/invite/"] { + let response = spa_response(state.clone(), "public.example", path).await; + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!(!String::from_utf8_lossy(&body).contains("og:video")); + } + let response = spa_response(state.clone(), "admin.example", "/invite/unused-token").await; + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!(!String::from_utf8_lossy(&body).contains("og:video")); + for (name, mime) in [ + ("buzz-invite-loop-v1.mp4", "video/mp4"), + ("buzz-invite-poster-v1.png", "image/png"), + ] { + let path = format!("/assets/invite-preview/{name}"); + for method in ["GET", "HEAD"] { + let response = build_router(state.clone()) + .oneshot( + Request::builder() + .method(method) + .uri(&path) + .header("host", "public.example") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["content-type"], mime); + assert!( + response.headers()["content-length"] + .to_str() + .unwrap() + .parse::() + .unwrap() + > 1000 + ); + } + let response = build_router(state.clone()) + .oneshot( + Request::get(&path) + .header("host", "public.example") + .header("range", "bytes=0-31") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PARTIAL_CONTENT); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert_eq!(bytes.len(), 32); + } + } + #[tokio::test] async fn admin_spa_documents_and_assets_carry_the_admin_csp() { let admin_dir = tempfile::tempdir().expect("admin bundle dir"); diff --git a/previews/invite-video/README.md b/previews/invite-video/README.md index f49a2347d8..41a30ede07 100644 --- a/previews/invite-video/README.md +++ b/previews/invite-video/README.md @@ -32,8 +32,29 @@ npm run render Verification starts an isolated localhost server and checks poster selection, PNG export, persistence, loop continuity, mobile overflow and reduced-motion startup. Rendering also rebuilds both committed media files. Temporary captures stay in `.output/`. `PREVIEW_BROWSER` may select another installed Playwright Chromium channel. -## Integration boundary +## Production invite previews -This PR prepares media and preview tooling. It does not change live invite HTML or deploy the relay. To enable real link previews, serve the media at public absolute HTTPS URLs and add `og:video` (direct MP4), `og:video:type` (`video/mp4`), dimensions, `og:image` (poster), title and description to the initial invite HTML. Keep generic branding on the public preview and never redeem an invite during a crawler fetch. Ship the relay image and verify an actual shared invite in the target clients. +The production web build includes the approved MP4 and wordmark-only poster at +`/assets/invite-preview/buzz-invite-loop-v1.mp4` and +`/assets/invite-preview/buzz-invite-poster-v1.png`. The relay adds Open Graph +metadata to the initial HTML for `/invite/` only. Previews are generic and +do not validate or redeem the invite. + +Deploy both the updated relay and its web bundle (the relay Docker image includes +both). The media host comes from the request's resolved community, matching the +invite link's host. `RELAY_URL` supplies only the `wss`/`ws` TLS posture, mapped to +`https`/`http`. Unmapped hosts or lookup failures receive no preview metadata. +Each community host must serve the web bundle without authentication. Use HTTPS +in production. + +After deployment, inspect an invite response with `curl` for `og:video` and +`og:image`, then fetch both referenced URLs and check that they return MP4/PNG +content rather than a login page. The video endpoint supports HTTP range +requests. Share a fresh invite link in Messages to test actual playback; receiving +apps decide whether to play video or use the poster, and may cache previews. + +When replacing approved media, bump the versioned filenames in both +`web/vite.config.ts` and `crates/buzz-relay/src/invite_preview.rs` to avoid stale +cached media. The receiving app chooses playback behavior. The 16-second duration is an animation choice, not an asserted platform limit. diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 17b6848455..b4084e167c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -532,6 +532,9 @@ run_unit_tests() { run_test_step "buzz-relay binary tests" \ cargo test -p buzz-relay --bin buzz-relay -- --nocapture + run_test_step "buzz-relay public invite previews" \ + cargo test -p buzz-relay --lib invite_preview -- --nocapture + run_test_step "buzz-relay invitation token and cutoff contracts" \ cargo test -p buzz-relay --lib invite_token:: -- --nocapture run_test_step "buzz-relay invitation cutoff configuration" \ diff --git a/web/vite.config.ts b/web/vite.config.ts index 4fcf8c781c..d1c35639c4 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import { readFileSync } from "node:fs"; import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; @@ -17,6 +18,23 @@ export default defineConfig({ ], }), react(), + { + name: "invite-preview-media", + generateBundle() { + for (const [source, output] of [ + ["buzz-invite-loop.mp4", "buzz-invite-loop-v1.mp4"], + ["buzz-invite-poster.png", "buzz-invite-poster-v1.png"], + ]) { + this.emitFile({ + type: "asset", + fileName: `assets/invite-preview/${output}`, + source: readFileSync( + new URL(`../previews/invite-video/${source}`, import.meta.url), + ), + }); + } + }, + }, ], resolve: { alias: {