Files
bitvid/api/og.js
T
thePR0M3TH3ANandClaude Opus 5 c0d006a5fc [og] Derive the preview origin from the request
SITE_ORIGIN is a constant defaulting to https://bitvid.network, so the unstable
deployment advertised production's canonical URL and, worse, a twitter:player
pointing at production's embed -- testing a preview link silently exercised the
live site.

Build shareUrl, embedUrl and the fallback card from the request's own
proto+host instead, so every deployment (production, unstable, preview) is
self-consistent. BITVID_SITE_ORIGIN remains the fallback when the host header
is absent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-07-28 14:48:32 -04:00

274 lines
9.0 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") || "";
// Derive the origin from the REQUEST, not from a constant. Otherwise every
// non-production deployment (unstable, previews) advertises production's
// canonical URL and — worse — a twitter:player pointing at production's
// embed, so testing a preview link silently exercises the live site.
const origin = `${proto}://${host}`;
const shareUrl = pointer
? `${origin}/?v=${encodeURIComponent(pointer)}`
: origin;
const embedUrl = pointer
? `${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, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
})
);
};
// 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, url: origin, image: `${origin}/assets/jpg/bitvid.jpg` },
gate,
})
);
} catch (err) {
renderFallback("error");
} finally {
try {
pool.close(RELAYS);
} catch (err) {
// ignore
}
}
}