mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
[og] Render per-video OpenGraph tags for crawlers
index.html carries one set of site-wide OG tags, so every /?v=<nevent> 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=<nevent>, 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) <[email protected]>
This commit is contained in:
1 parent
38b51f13a5
commit
b84558e928
4 files changed
+1060
No files matched your search
@@ -0,0 +1,426 @@
|
||||
// Per-video OpenGraph rendering for crawlers — see api/_lib/ogRenderer.mjs.
|
||||
//
|
||||
// Two things here are security-relevant and get the most attention:
|
||||
// 1. Escaping. Titles/descriptions come from arbitrary nostr events, and this
|
||||
// HTML is served from bitvid's own origin.
|
||||
// 2. Gating. A crawler never authenticates, so anything emitted is public
|
||||
// forever in third-party caches. Deleted/private/NSFW/non-whitelisted
|
||||
// videos must fall back to the generic site card.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
CRAWLER_USER_AGENT_PATTERN,
|
||||
escapeHtmlAttribute,
|
||||
isCrawlerUserAgent,
|
||||
isPubliclyShareable,
|
||||
normalizeText,
|
||||
parseVideoEvent,
|
||||
renderOgHtml,
|
||||
sanitizeAbsoluteUrl,
|
||||
} from "../api/_lib/ogRenderer.mjs";
|
||||
|
||||
const PUBKEY = "b7c6f6915cfa9a62fff6a1f02604de88c23c6c6c6d1b8f62c7cc10749f307e81";
|
||||
|
||||
const publicVideo = () => ({
|
||||
pubkey: PUBKEY,
|
||||
title: "Why I'm (sort of) not worried about AI",
|
||||
description: "A talk about creativity and machines.",
|
||||
thumbnail: "https://cdn.example/thumb.jpg",
|
||||
deleted: false,
|
||||
isPrivate: false,
|
||||
isNsfw: false,
|
||||
invalid: false,
|
||||
});
|
||||
|
||||
const FALLBACK = {
|
||||
siteName: "bitvid",
|
||||
title: "bitvid - Decentralized Video Sharing",
|
||||
description: "Decentralized video sharing on Nostr.",
|
||||
image: "https://bitvid.network/assets/jpg/bitvid.jpg",
|
||||
url: "https://bitvid.network",
|
||||
};
|
||||
|
||||
const render = (video, extra = {}) =>
|
||||
renderOgHtml({
|
||||
video,
|
||||
shareUrl: "https://bitvid.network/?v=nevent1abc",
|
||||
embedUrl: "https://bitvid.network/embed.html?pointer=nevent1abc",
|
||||
fallback: FALLBACK,
|
||||
gate: isPubliclyShareable(video, extra.gateOptions || {}),
|
||||
...extra.render,
|
||||
});
|
||||
|
||||
// --- escaping -------------------------------------------------------------
|
||||
|
||||
test("escapeHtmlAttribute neutralizes attribute-breaking characters", () => {
|
||||
assert.equal(
|
||||
escapeHtmlAttribute(`"><script>alert(1)</script>`),
|
||||
""><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 = `"><script>alert(document.domain)</script><meta x="`;
|
||||
const html = render(video);
|
||||
|
||||
assert.equal(html.includes("<script>"), false, "no raw script tag");
|
||||
assert.equal(html.includes(`"><script`), false, "no attribute breakout");
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
|
||||
test("a hostile description is escaped too", () => {
|
||||
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, /<link rel="canonical" href="https:\/\/bitvid\.network\/\?v=nevent1abc"/);
|
||||
});
|
||||
|
||||
test("without an embed URL it degrades to a summary_large_image card", () => {
|
||||
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, /^<!doctype html>/);
|
||||
assert.match(html, /<\/html>\n$/);
|
||||
// Crawler stub only — it must not pull in the app or any script.
|
||||
assert.equal(/<script/i.test(html), false);
|
||||
});
|
||||
|
||||
// --- event parsing --------------------------------------------------------
|
||||
// bitvid publishes kind 30078 with a v3 JSON body; nostube/slidestr and other
|
||||
// NIP-71-native clients publish kind 21/22/34235/34236 with the metadata in
|
||||
// TAGS. Parsing only the JSON shape made every NIP-71 video — including the
|
||||
// real note this work started from — fall back to the generic card.
|
||||
|
||||
test("parseVideoEvent reads the bitvid v3 JSON shape", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 30078,
|
||||
pubkey: PUBKEY,
|
||||
content: JSON.stringify({
|
||||
version: 3,
|
||||
title: "A bitvid upload",
|
||||
description: "Body text",
|
||||
thumbnail: "https://cdn.example/t.jpg",
|
||||
url: "https://cdn.example/v.mp4",
|
||||
isNsfw: false,
|
||||
}),
|
||||
tags: [],
|
||||
});
|
||||
|
||||
assert.equal(parsed.title, "A bitvid upload");
|
||||
assert.equal(parsed.description, "Body text");
|
||||
assert.equal(parsed.thumbnail, "https://cdn.example/t.jpg");
|
||||
assert.equal(parsed.invalid, false);
|
||||
assert.equal(isPubliclyShareable(parsed).allowed, true);
|
||||
});
|
||||
|
||||
test("parseVideoEvent reads the NIP-71 tag shape (the real nostube note)", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "As a creator, and as a leader of a tech company…",
|
||||
tags: [
|
||||
["d", "b52ab495-ac9c-4a29-a454-aff142a43691"],
|
||||
["title", "Why I'm (sort of) not worried about AI"],
|
||||
[
|
||||
"imeta",
|
||||
"dim 1280x720",
|
||||
"url https://almond.example/video.m3u8",
|
||||
"m application/vnd.apple.mpegurl",
|
||||
"image https://almond.example/thumb.jpg",
|
||||
"image https://blossom.example/thumb.jpg",
|
||||
],
|
||||
["client", "nostube"],
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(parsed.title, "Why I'm (sort of) not worried about AI");
|
||||
assert.equal(parsed.thumbnail, "https://almond.example/thumb.jpg");
|
||||
assert.match(parsed.description, /^As a creator/);
|
||||
assert.equal(parsed.invalid, false);
|
||||
});
|
||||
|
||||
test("parseVideoEvent treats a content-warning tag as NSFW", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "desc",
|
||||
tags: [["title", "Sensitive"], ["content-warning", "nudity"]],
|
||||
});
|
||||
assert.equal(parsed.isNsfw, true);
|
||||
assert.equal(isPubliclyShareable(parsed).reason, "nsfw");
|
||||
});
|
||||
|
||||
test("parseVideoEvent rejects untitled and non-video events", () => {
|
||||
assert.equal(
|
||||
parseVideoEvent({ kind: 34235, pubkey: PUBKEY, content: "", tags: [] }).invalid,
|
||||
true
|
||||
);
|
||||
// A plain kind-1 note is not a video.
|
||||
assert.equal(parseVideoEvent({ kind: 1, pubkey: PUBKEY, content: "hi", tags: [] }), null);
|
||||
assert.equal(parseVideoEvent(null), null);
|
||||
assert.equal(parseVideoEvent({}), null);
|
||||
});
|
||||
|
||||
test("parseVideoEvent survives malformed JSON content", () => {
|
||||
// A kind-30078 whose content is truncated/corrupt must not throw.
|
||||
assert.equal(
|
||||
parseVideoEvent({ kind: 30078, pubkey: PUBKEY, content: "{not json", tags: [] }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("parseVideoEvent ignores non-http imeta image entries", () => {
|
||||
const parsed = parseVideoEvent({
|
||||
kind: 34235,
|
||||
pubkey: PUBKEY,
|
||||
content: "desc",
|
||||
tags: [
|
||||
["title", "T"],
|
||||
["imeta", "image javascript:alert(1)", "image https://ok.example/t.png"],
|
||||
],
|
||||
});
|
||||
assert.equal(parsed.thumbnail, "https://ok.example/t.png");
|
||||
});
|
||||
|
||||
// --- crawler detection ----------------------------------------------------
|
||||
|
||||
test("known preview crawlers are recognized", () => {
|
||||
for (const ua of [
|
||||
"facebookexternalhit/1.1",
|
||||
"Twitterbot/1.0",
|
||||
"Slackbot-LinkExpanding 1.0",
|
||||
"Mozilla/5.0 (compatible; Discordbot/2.0)",
|
||||
"TelegramBot (like TwitterBot)",
|
||||
"WhatsApp/2.19",
|
||||
"Mozilla/5.0 (compatible; Googlebot/2.1)",
|
||||
]) {
|
||||
assert.equal(isCrawlerUserAgent(ua), true, `${ua} should match`);
|
||||
}
|
||||
});
|
||||
|
||||
test("real browsers are NOT treated as crawlers", () => {
|
||||
for (const ua of [
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15",
|
||||
"",
|
||||
]) {
|
||||
assert.equal(isCrawlerUserAgent(ua), false, `${ua} must not match`);
|
||||
}
|
||||
assert.equal(isCrawlerUserAgent(null), false);
|
||||
assert.equal(isCrawlerUserAgent(undefined), false);
|
||||
});
|
||||
|
||||
test("the crawler pattern is not sticky/global (no lastIndex state)", () => {
|
||||
// A /g regex would alternate true/false across calls via lastIndex — a
|
||||
// classic bug that would randomly serve crawlers the wrong page.
|
||||
assert.equal(CRAWLER_USER_AGENT_PATTERN.global, false);
|
||||
const ua = "Twitterbot/1.0";
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
assert.equal(isCrawlerUserAgent(ua), true);
|
||||
});
|
||||
|
||||
// --- config drift ---------------------------------------------------------
|
||||
// api/og.js imports the admin-list identity and relay set from the SAME modules
|
||||
// the browser uses, rather than keeping its own copies. These assertions fail
|
||||
// loudly if that ever regresses to duplicated constants, because the failure
|
||||
// mode otherwise is silent: previews gated forever, or a stale whitelist.
|
||||
|
||||
test("api/og.js derives its config from the app, not from copies", async () => {
|
||||
const source = await readFile(new URL("../api/og.js", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /from "\.\.\/js\/config\.js"/);
|
||||
assert.match(source, /from "\.\.\/js\/nostr\/toolkit\.js"/);
|
||||
assert.match(source, /from "\.\.\/js\/nostrEventSchemas\.js"/);
|
||||
|
||||
// No hardcoded npub or wss:// literal may reappear in the handler.
|
||||
assert.equal(
|
||||
/npub1[0-9a-z]{20,}/.test(source),
|
||||
false,
|
||||
"admin npub must come from js/config.js, not a literal"
|
||||
);
|
||||
assert.equal(
|
||||
/"wss:\/\//.test(source),
|
||||
false,
|
||||
"relay URLs must come from js/nostr/toolkit.js, not literals"
|
||||
);
|
||||
});
|
||||
|
||||
test("the admin list d-tag matches what the browser store queries", async () => {
|
||||
const { ADMIN_LIST_NAMESPACE } = await import("../js/config.js");
|
||||
const { ADMIN_LIST_IDENTIFIERS } = await import("../js/nostrEventSchemas.js");
|
||||
|
||||
// Pinned against the live list this instance actually publishes; a namespace
|
||||
// change without a matching relay migration would silently gate everything.
|
||||
assert.equal(
|
||||
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.whitelist}`,
|
||||
"bitvid:admin:whitelist"
|
||||
);
|
||||
assert.equal(
|
||||
`${ADMIN_LIST_NAMESPACE}:${ADMIN_LIST_IDENTIFIERS.blacklist}`,
|
||||
"bitvid:admin:blacklist"
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user