Files
bitvid/api/og.js
T
thePR0M3TH3ANandClaude Opus 5 b84558e928 [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]>
2026-07-28 13:59:25 -04:00

258 lines
8.5 KiB
JavaScript

// Vercel serverless handler: renders per-video OpenGraph / Twitter-player tags
// for crawlers hitting `/?v=<nevent>`.
//
// Nothing is pre-generated and nothing is persisted. This is a stateless
// function invoked per crawler request; `dist/` is byte-identical with or
// without it. The only caching is the CDN edge cache via Cache-Control below.
//
// This must never become load-bearing for the app: real users are not routed
// here (see the user-agent rewrite in vercel.json), and a static/nsite deploy
// without any functions keeps working exactly as it does today, just with the
// site-wide card.
import { nip19 } from "nostr-tools";
import { SimplePool, useWebSocketImplementation } from "nostr-tools/pool";
import WebSocket from "ws";
import {
isCrawlerUserAgent,
isPubliclyShareable,
parseVideoEvent,
renderOgHtml,
} from "./_lib/ogRenderer.mjs";
// Imported, not duplicated: these modules are plain ESM with no DOM access, so
// the function reads the SAME admin-list identity and relay set the browser
// does. A hardcoded copy would silently drift the moment an operator retunes
// the instance, and the failure mode (previews gated forever, or worse, a stale
// whitelist) would be invisible.
import {
ADMIN_LIST_NAMESPACE,
ADMIN_SUPER_NPUB,
} from "../js/config.js";
import { DEFAULT_RELAY_URLS } from "../js/nostr/toolkit.js";
import { ADMIN_LIST_IDENTIFIERS } from "../js/nostrEventSchemas.js";
useWebSocketImplementation(WebSocket);
const RELAYS = Array.from(DEFAULT_RELAY_URLS);
// Admin moderation lists are addressable kind-30000 sets published by the
// instance's super admin (js/adminListStore.js).
const ADMIN_LIST_KIND = 30000;
// Measured: a COLD pool's first read across these relays can take ~3s (WSS
// handshake + REQ round trip on four connections). 1.5s and 2.5s both cut it
// off and served the generic card every time. Twitter allows ~5s and Facebook
// ~10s, so 4s buys a correct card on a cold container while still leaving
// headroom under Vercel's function limit. Warm containers answer in <600ms.
const RELAY_TIMEOUT_MS = 4000;
// Admin lists are identical for every request, so re-reading them on each crawl
// is pure waste (and it was starving the video read of its budget). Memoized in
// module scope: warm invocations skip the relay round trip entirely. This is a
// per-container, in-memory cache of PUBLIC data that dies with the container —
// nothing is persisted anywhere.
const ADMIN_LIST_TTL_MS = 5 * 60 * 1000;
let adminListCache = { expiresAt: 0, whitelist: null, blacklist: null };
const SITE_ORIGIN = process.env.BITVID_SITE_ORIGIN || "https://bitvid.network";
const FALLBACK = Object.freeze({
siteName: "bitvid",
title: "bitvid - Decentralized Video Sharing",
description:
"bitvid is a decentralized video sharing platform built on Nostr and WebTorrent.",
image: `${SITE_ORIGIN}/assets/jpg/bitvid.jpg`,
url: SITE_ORIGIN,
});
function withTimeout(promise, ms, fallbackValue) {
return Promise.race([
promise.catch(() => fallbackValue),
new Promise((resolve) => setTimeout(() => resolve(fallbackValue), ms)),
]);
}
/** Decode `?v=` (nevent/naddr/note/raw hex) into a relay filter. */
function pointerToFilter(pointer) {
const trimmed = typeof pointer === "string" ? pointer.trim() : "";
if (!trimmed) {
return null;
}
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
return { ids: [trimmed.toLowerCase()] };
}
try {
const decoded = nip19.decode(trimmed);
if (decoded.type === "nevent" && decoded.data?.id) {
return { ids: [decoded.data.id] };
}
if (decoded.type === "note" && typeof decoded.data === "string") {
return { ids: [decoded.data] };
}
if (decoded.type === "naddr" && decoded.data?.identifier) {
return {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
}
} catch (err) {
// Unparseable pointer -> generic card.
}
return null;
}
function extractPubkeySet(event) {
const out = new Set();
if (!event || !Array.isArray(event.tags)) {
return out;
}
for (const tag of event.tags) {
if (!Array.isArray(tag) || tag[0] !== "p" || typeof tag[1] !== "string") {
continue;
}
const value = tag[1].trim().toLowerCase();
if (/^[0-9a-f]{64}$/.test(value)) {
out.add(value);
continue;
}
try {
const decoded = nip19.decode(value);
if (decoded.type === "npub" && typeof decoded.data === "string") {
out.add(decoded.data.toLowerCase());
}
} catch (err) {
// ignore malformed entries
}
}
return out;
}
export default async function handler(req, res) {
const host = req.headers?.host || new URL(SITE_ORIGIN).host;
const proto = req.headers?.["x-forwarded-proto"] || "https";
const requestUrl = new URL(req.url || "/", `${proto}://${host}`);
const pointer = requestUrl.searchParams.get("v") || "";
const shareUrl = pointer
? `${SITE_ORIGIN}/?v=${encodeURIComponent(pointer)}`
: SITE_ORIGIN;
const embedUrl = pointer
? `${SITE_ORIGIN}/embed.html?pointer=${encodeURIComponent(pointer)}`
: "";
const renderFallback = (reason) => {
// Short TTL: a video may be published, edited or moderated at any time, and
// we would rather re-ask the relays than pin a wrong card in someone's CDN.
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", reason);
res.status(200).send(
renderOgHtml({ video: null, shareUrl, fallback: FALLBACK })
);
};
// Defensive: the rewrite should only send crawlers here, but never serve a
// human this stub if the rule ever widens.
if (!isCrawlerUserAgent(req.headers?.["user-agent"])) {
res.setHeader("Cache-Control", "no-store");
res.redirect(302, shareUrl);
return;
}
const filter = pointerToFilter(pointer);
if (!filter) {
renderFallback("no-pointer");
return;
}
const pool = new SimplePool();
let superAdminHex = "";
try {
const decoded = nip19.decode(ADMIN_SUPER_NPUB);
superAdminHex = decoded.type === "npub" ? decoded.data : "";
} catch (err) {
superAdminHex = "";
}
try {
const listFilter = (identifier) => ({
kinds: [ADMIN_LIST_KIND],
"#d": [`${ADMIN_LIST_NAMESPACE}:${identifier}`],
...(superAdminHex ? { authors: [superAdminHex] } : {}),
});
// One shared deadline across all three reads.
const now = Date.now();
const adminListsFresh =
adminListCache.expiresAt > now && adminListCache.whitelist instanceof Set;
const [videoEvent, whitelistEvent, blacklistEvent] = await Promise.all([
withTimeout(pool.get(RELAYS, filter), RELAY_TIMEOUT_MS, null),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.whitelist)),
RELAY_TIMEOUT_MS,
null
),
adminListsFresh
? Promise.resolve(null)
: withTimeout(
pool.get(RELAYS, listFilter(ADMIN_LIST_IDENTIFIERS.blacklist)),
RELAY_TIMEOUT_MS,
null
),
]);
let whitelist = adminListsFresh ? adminListCache.whitelist : null;
let blacklist = adminListsFresh ? adminListCache.blacklist : null;
if (!adminListsFresh) {
whitelist = extractPubkeySet(whitelistEvent);
blacklist = extractPubkeySet(blacklistEvent);
// Only cache a whitelist we actually got. Caching an empty one would pin
// "whitelist-unavailable" for the whole TTL after a single slow read.
if (whitelist.size > 0) {
adminListCache = {
expiresAt: now + ADMIN_LIST_TTL_MS,
whitelist,
blacklist,
};
}
}
const video = parseVideoEvent(videoEvent);
if (!video) {
renderFallback("event-not-found");
return;
}
const gate = isPubliclyShareable(video, {
whitelistEnabled: true,
whitelist,
blacklist,
});
if (!gate.allowed) {
renderFallback(`gated:${gate.reason}`);
return;
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate=600");
res.setHeader("X-Bitvid-OG", "video");
res
.status(200)
.send(renderOgHtml({ video, shareUrl, embedUrl, fallback: FALLBACK, gate }));
} catch (err) {
renderFallback("error");
} finally {
try {
pool.close(RELAYS);
} catch (err) {
// ignore
}
}
}