Ryan 6b06e08c08 BidaAngMamayan: public supporter tribute news desk for Senator BAM Aquino
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.
2026-09-15 08:37:03 +00:00

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);
}