From b84558e92827735cdb3204d30488a4fb777c90d1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:59:25 -0400 Subject: [PATCH] [og] Render per-video OpenGraph tags for crawlers index.html carries one set of site-wide OG tags, so every /?v= link previewed as the same generic bitvid card -- never the video's title or thumbnail. bitvid is a static SPA and crawlers do not run JS, so the client cannot fix this. api/og.js renders the tags on demand. Nothing is pre-generated and nothing is stored: the video is a query param, not a path, so one stateless function serves every video and dist/ is byte-identical with or without it. The only caching is the CDN edge (s-maxage=300) plus a per-container memo of the admin lists. This is never load-bearing. Only crawler user-agents are rewritten to it (vercel.json), humans get a 302 to the untouched SPA, and a static/nsite deploy with no functions keeps working -- it just serves the site-wide card. Rollback is deleting the `rewrites` block. - Emits twitter:player / og:video:url pointing at embed.html?pointer=, so a shared link plays inline on Twitter/Discord/Telegram rather than showing a still. frame-ancestors * already permits the embed. - Fails closed. A crawler never authenticates, so anything emitted is public forever in third-party caches: deleted, isPrivate, isNsfw/content-warning, blacklisted authors, and -- while whitelist mode is on -- an unavailable or empty whitelist all fall back to the generic card. - Escapes every attribute. Titles come from arbitrary nostr events and this HTML is served from bitvid's own origin; only http(s) URLs reach a meta tag. - Parses BOTH event shapes: bitvid's kind-30078 v3 JSON and the NIP-71 tag shape (kinds 21/22/34235/34236) that nostube/slidestr publish, where the title is a `title` tag and the thumbnail an `imeta image` entry. Reading only the JSON shape made every NIP-71 video fall back to the generic card. - Relay budget is 4s: a cold pool's first read measured ~3s, and 1.5s silently degraded every preview. Warm containers answer in <600ms. Admin-list identity and the relay set are imported from js/config.js, js/nostr/toolkit.js and js/nostrEventSchemas.js rather than duplicated -- a hardcoded copy of the namespace gated every preview during development, and a test now fails if any npub or wss:// literal reappears in the handler. Co-Authored-By: Claude Opus 5 (1M context) --- api/_lib/ogRenderer.mjs | 363 +++++++++++++++++++++++++++++++ api/og.js | 257 ++++++++++++++++++++++ tests/og-renderer.test.mjs | 426 +++++++++++++++++++++++++++++++++++++ vercel.json | 14 ++ 4 files changed, 1060 insertions(+) create mode 100644 api/_lib/ogRenderer.mjs create mode 100644 api/og.js create mode 100644 tests/og-renderer.test.mjs diff --git a/api/_lib/ogRenderer.mjs b/api/_lib/ogRenderer.mjs new file mode 100644 index 00000000..d23903b4 --- /dev/null +++ b/api/_lib/ogRenderer.mjs @@ -0,0 +1,363 @@ +// Per-video OpenGraph / Twitter-player rendering for crawlers. +// +// bitvid is a static SPA: index.html carries one set of site-wide OG tags, so +// every `/?v=` link previewed as the same generic bitvid card. Crawlers +// don't run JS, so the client can't fix that — this module renders the tags a +// crawler needs, and a thin host adapter (api/og.js on Vercel) feeds it. +// +// PURE by design: no network, no host globals. All I/O lives in the adapter so +// the escaping and gating rules below stay unit-testable. +// +// Rollback: remove the crawler rewrite from vercel.json. The static site keeps +// serving its existing tags and nothing else changes — this is never on the +// path of a real user's page load, and `npm run archive` (nsite) is unaffected. + +// Player card dimensions. Twitter refuses a player card without explicit +// dimensions, and 16:9 matches bitvid's embed shell. +export const DEFAULT_PLAYER_WIDTH = 1280; +export const DEFAULT_PLAYER_HEIGHT = 720; + +const MAX_TITLE_LENGTH = 120; +const MAX_DESCRIPTION_LENGTH = 200; + +/** + * Escape a value for use inside a double-quoted HTML attribute. + * + * Titles and descriptions come from arbitrary nostr events — treat every one as + * hostile. A title of `">`), + ""><script>alert(1)</script>" + ); + assert.equal(escapeHtmlAttribute("a & b"), "a & b"); + assert.equal(escapeHtmlAttribute("it's"), "it's"); + assert.equal(escapeHtmlAttribute(null), ""); + assert.equal(escapeHtmlAttribute(42), ""); +}); + +test("a hostile title cannot break out of the meta tag", () => { + const video = publicVideo(); + video.title = `">"), false, "no raw script tag"); + assert.equal(html.includes(`"> { + const video = publicVideo(); + video.description = `" onload="alert(1)`; + const html = render(video); + assert.equal(html.includes(`onload="alert(1)`), false); + assert.match(html, /" onload="alert\(1\)/); +}); + +// --- URL sanitization ----------------------------------------------------- + +test("sanitizeAbsoluteUrl accepts only http(s)", () => { + assert.equal( + sanitizeAbsoluteUrl("https://cdn.example/a.jpg"), + "https://cdn.example/a.jpg" + ); + assert.equal(sanitizeAbsoluteUrl("http://cdn.example/a.jpg"), "http://cdn.example/a.jpg"); + assert.equal(sanitizeAbsoluteUrl("javascript:alert(1)"), ""); + assert.equal(sanitizeAbsoluteUrl("data:image/png;base64,AAAA"), ""); + assert.equal(sanitizeAbsoluteUrl("assets/jpg/local.jpg"), ""); + assert.equal(sanitizeAbsoluteUrl(""), ""); + assert.equal(sanitizeAbsoluteUrl(null), ""); +}); + +test("a javascript: thumbnail never reaches og:image", () => { + const video = publicVideo(); + video.thumbnail = "javascript:alert(1)"; + const html = render(video); + assert.equal(html.includes("javascript:"), false); + // Falls back to the site image rather than emitting nothing. + assert.match(html, /og:image" content="https:\/\/bitvid\.network\/assets/); +}); + +// --- text normalization --------------------------------------------------- + +test("normalizeText collapses whitespace and truncates with an ellipsis", () => { + assert.equal(normalizeText(" a \n b \t c ", 50), "a b c"); + const long = "x".repeat(300); + const out = normalizeText(long, 120); + assert.equal(out.length, 120); + assert.equal(out.endsWith("…"), true); + assert.equal(normalizeText(null, 10), ""); +}); + +// --- gating --------------------------------------------------------------- + +test("a clean public video is shareable", () => { + assert.deepEqual(isPubliclyShareable(publicVideo()), { + allowed: true, + reason: "ok", + }); +}); + +test("deleted, private, NSFW and invalid videos are never described", () => { + for (const [field, reason] of [ + ["deleted", "deleted"], + ["isPrivate", "private"], + ["isNsfw", "nsfw"], + ["invalid", "invalid-video"], + ]) { + const video = publicVideo(); + video[field] = true; + const result = isPubliclyShareable(video); + assert.equal(result.allowed, false, `${field} must gate the preview`); + assert.equal(result.reason, reason); + } +}); + +test("a missing video or author is gated", () => { + assert.equal(isPubliclyShareable(null).allowed, false); + assert.equal(isPubliclyShareable(undefined).reason, "missing-video"); + const noAuthor = publicVideo(); + noAuthor.pubkey = ""; + assert.equal(isPubliclyShareable(noAuthor).reason, "missing-author"); +}); + +test("a blacklisted author is gated regardless of whitelist mode", () => { + const result = isPubliclyShareable(publicVideo(), { + blacklist: new Set([PUBKEY]), + }); + assert.equal(result.allowed, false); + assert.equal(result.reason, "blacklisted"); +}); + +test("whitelist mode fails CLOSED when the list is missing or empty", () => { + // The dangerous case: relays timed out, so we cannot prove the author is + // allowed. Emitting a rich card here would leak gated metadata. + assert.equal( + isPubliclyShareable(publicVideo(), { whitelistEnabled: true }).reason, + "whitelist-unavailable" + ); + assert.equal( + isPubliclyShareable(publicVideo(), { + whitelistEnabled: true, + whitelist: new Set(), + }).reason, + "whitelist-unavailable" + ); +}); + +test("whitelist mode admits a listed author and rejects an unlisted one", () => { + assert.equal( + isPubliclyShareable(publicVideo(), { + whitelistEnabled: true, + whitelist: new Set([PUBKEY]), + }).allowed, + true + ); + assert.equal( + isPubliclyShareable(publicVideo(), { + whitelistEnabled: true, + whitelist: new Set(["a".repeat(64)]), + }).reason, + "not-whitelisted" + ); +}); + +test("gated videos render the generic site card, leaking no metadata", () => { + const video = publicVideo(); + video.isPrivate = true; + video.title = "Secret internal recording"; + video.thumbnail = "https://cdn.example/secret.jpg"; + const html = render(video); + + assert.equal(html.includes("Secret internal recording"), false); + assert.equal(html.includes("secret.jpg"), false); + assert.match(html, /og:title" content="bitvid - Decentralized Video Sharing"/); + assert.match(html, /og:type" content="website"/); + // No player card for a gated video. + assert.equal(html.includes("twitter:player"), false); +}); + +// --- rendering ------------------------------------------------------------ + +test("a public video renders a player card pointing at the embed", () => { + const html = render(publicVideo()); + + assert.match(html, /og:type" content="video\.other"/); + assert.match(html, /og:title" content="Why I'm \(sort of\) not worried about AI"/); + assert.match(html, /og:image" content="https:\/\/cdn\.example\/thumb\.jpg"/); + assert.match( + html, + /og:video:url" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/ + ); + assert.match(html, /twitter:card" content="player"/); + assert.match( + html, + /twitter:player" content="https:\/\/bitvid\.network\/embed\.html\?pointer=nevent1abc"/ + ); + assert.match(html, /twitter:player:width" content="1280"/); + assert.match(html, /twitter:player:height" content="720"/); + assert.match(html, / { + const html = renderOgHtml({ + video: publicVideo(), + shareUrl: "https://bitvid.network/?v=nevent1abc", + fallback: FALLBACK, + gate: { allowed: true, reason: "ok" }, + }); + assert.match(html, /twitter:card" content="summary_large_image"/); + assert.equal(html.includes("twitter:player"), false); +}); + +test("the rendered document is well-formed and self-contained", () => { + const html = render(publicVideo()); + assert.match(html, /^/); + assert.match(html, /<\/html>\n$/); + // Crawler stub only — it must not pull in the app or any script. + assert.equal(/