Serve video and poster previews for invite links (#9306)

Invite links currently return HTML without preview metadata. Add
server-rendered `og:video` and `og:image` tags for `/invite/<code>`, and
bundle the approved MP4 and wordmark-only poster from #9282 into the
relay image. Media URLs use the request’s resolved community host, with
only TLS posture from `RELAY_URL`. Unknown hosts and lookup failures
receive no metadata; previews stay generic and never redeem invites.

Validation: production web build, workspace Clippy, 52 router tests
(including real HTTP and MP4 range requests), metadata tests, and
independent agent review pass. `just ci` stops at unrelated mobile
dependency/format checks. The full relay suite was attempted but stopped
during database-dependent tests without local infrastructure. Broad
local Rust/desktop hooks were stopped for disk pressure; desktop/mobile
hooks also lack dependencies and were skipped to publish this draft.
Full-stack and native Messages playback remain unverified. The new
preview regressions run in both CI test-runner paths.

Rollout: merge and deploy the resulting relay image with its web bundle,
verify the invite HTML and both public media URLs, then share a fresh
invite in Messages. Full-stack and human validation remain outstanding.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-09 23:12:34 +00:00
committed by GitHub
parent 18f3e35413
commit 773849665e
8 changed files with 351 additions and 2 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<R: crate::tenant::HostResolver>(
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('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;");
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!("<meta property=\"og:video:secure_url\" content=\"{video}\">\n")
} else {
String::new()
};
let tags = format!(
r#"<meta property="og:title" content="Join me on Buzz">
<meta property="og:description" content="A place for people and agents to work together.">
<meta property="og:site_name" content="Buzz">
<meta property="og:type" content="website">
<meta property="og:image" content="{poster}">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="960">
<meta property="og:image:height" content="540">
<meta property="og:image:alt" content="Buzz wordmark on a chartreuse background">
<meta property="og:video" content="{video}">
{secure}<meta property="og:video:type" content="video/mp4">
<meta property="og:video:width" content="960">
<meta property="og:video:height" content="540">
"#
);
html.replacen("</head>", &format!("{tags}</head>"), 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<Option<buzz_core::tenant::CommunityId>, ()> {
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 = "<html><head><title>Buzz</title></head><body>app</body></html>";
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);
}
}
}
}
}

View File

@@ -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.

View File

@@ -616,6 +616,18 @@ pub fn build_router(state: Arc<AppState>) -> 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"),
"<html><head></head><body>invite app</body></html>",
)
.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::<std::net::SocketAddr>(),
)
.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::<usize>()
.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");

View File

@@ -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/<code>` 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.

View File

@@ -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" \

View File

@@ -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: {