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(/