Forked from Forthena (PERSONAL/forthena) and retargeted: - NO login, NO LLM: the edition is public and the ranking is deterministic (stories that name Sen. BAM lead; freshest first) — Daniel: no like/dislike or store profile for visitors. - Crawler scoped to BAM: Bing news keyword queries + Inquirer/Rappler fallback wires, with a two-tier relevance gate (named-BAM always; Aquino+legislative-context only from fallback) so op-eds and non-Bam senators are dropped. - Desks: BAM (lead), Gaming, Education, Politics, Energy, PH, Business. - Community board /board: 'Thank you Senator BAM' — nickname-only posts, HTML-stripped, URL-only rejected, IP rate-limited (3/10min, 20/day). - Yellow-gamer theme (Orbitron/Rajdhani/JetBrains Mono, dark + gold, HUD corners), supporter disclaimer in header and footer. - Tests: category/date/relevance/text suites + Mongo board suite + full HTTP e2e (all passing). Infra: 66.181.46.132 (bidaangmamamayan.com), local Mongo 7.0, nginx + certbot TLS, PM2 bama (:3000) + 15-min crawl cron.
76 lines
2.9 KiB
JavaScript
76 lines
2.9 KiB
JavaScript
// 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);
|
|
}
|