// Story ids — a real URL per story, without a real URL in the path. // // /story/[id] needs a stable, path-safe id derived from the story url. Mongo urls contain // `:/?#[]@!$&'()*+,;=%` and can be ~900 chars, so we base64url the utf-8 bytes. Nothing is // truncated, nothing is "fixed" — the codec is the contract, and it must round-trip exactly // or a shared link lands on a dead page. // // Two forms, both resolvable (the page tries them in this order): // 1. storyId(url) — the full encoding. Always works; long urls make long route segments. // 2. shortId(url) — same, but past SHORT_MAX chars it falls back to base64url(sha1hex(url)). // A hash is one-way, so the short form only resolves once the doc carries `sid`, which // the story page writes (idempotent) the first time it resolves a long url. // // No DB, no Next imports — isomorphic and unit-testable. import { createHash } from "node:crypto"; // Decoded junk guard: a control character in the decoded string means this id was never a // url, whatever it decodes to. (Written escaped — raw control bytes must not live in source.) const CONTROL_JUNK = new RegExp("[\\u0000-\\u001F\\u007F]"); // Any url-encoded id past this gets hashed instead. 200 keeps the address bar honest for // normal stories; Google-News wrappers are what blow past it. const SHORT_MAX = 200; export function storyId(url) { const s = String(url ?? ""); if (!s) return ""; return Buffer.from(s, "utf8").toString("base64url"); } export function storyUrlFromId(id) { const s = String(id ?? "").trim(); // base64url alphabet only. Rejects path tricks (`..`, `%2f`, `?`) before we even decode. if (!s || !/^[A-Za-z0-9_-]+$/.test(s)) return null; let raw; try { raw = Buffer.from(s, "base64url").toString("utf8"); } catch { return null; } if (!raw || CONTROL_JUNK.test(raw)) return null; const t = raw.trim(); if (!/^https?:\/\/[^\s]+$/i.test(t)) return null; // hard guard: must decode to an http(s) URL return t; } // base64url(sha1hex(url)) — the short form. 40 hex chars -> 54 base64url chars. export function sidOf(url) { const s = String(url ?? ""); if (!s) return ""; const hex = createHash("sha1").update(s, "utf8").digest("hex"); return Buffer.from(hex, "utf8").toString("base64url"); } // A url-shaped id (short) or the hashed fallback (long). Never truncates the encoding. export function shortId(url) { const full = storyId(url); if (!full) return ""; return full.length > SHORT_MAX ? sidOf(url) : full; } // Does an incoming id *look* like the hashed form? (40 hex chars survive base64url decoding // as exactly that shape — cheap check, avoids a pointless Mongo query for full-form ids.) export function looksLikeSid(id) { const s = String(id ?? "").trim(); if (!/^[A-Za-z0-9_-]+$/.test(s)) return false; let raw; try { raw = Buffer.from(s, "base64url").toString("utf8"); } catch { return false; } return /^[0-9a-f]{40}$/.test(raw); }