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

132 lines
4.8 KiB
JavaScript

// The community board — "Thank you Senator BAM".
//
// Visitors post with a nickname only (no accounts, no email, by design —
// Daniel: the site is public and the barrier to saying thanks must be a
// text field, not a login). The board is an open guestbook: the abuse
// controls are deliberately light but real:
//
// * per-IP rate limit: 3 posts / 10 min, 12 / day (lib/board.js, Mongo
// rate collection, no Redis needed).
// * hard content caps (nickname 24 chars, message 600 chars) so a drive-by
// can't park a wall of text.
// * message sanitisation: HTML is stripped to text. The UI renders plain
// text nodes only — there is no markdown, no links, no images. A post
// that is ONLY a URL is rejected (the classic guestbook spam shape).
// * nothing is executed, fetched, or rendered as markup. Ever.
//
// The default title of the board is "Thank you Senator BAM" and every post
// is a thank-you / message to the senator — that is the page's social
// contract, stated in the copy, enforced lightly (tone is not machine-
// judged).
import { getCollections } from "./db.js";
const NICK_MAX = 24;
const MSG_MAX = 600;
const RATE_WINDOW_MS = 10 * 60 * 1000;
const RATE_LIMIT_WINDOW = 3; // per 10 min
const RATE_LIMIT_DAY = 12; // per 24h
const LIST_LIMIT = 200; // the board prints newest-first, capped
function cleanNick(s) {
return String(s || "")
.replace(/<[^>]*>/g, " ")
.replace(/[\x00-\x1f]/g, "")
.replace(/\s+/g, " ")
.trim()
.slice(0, NICK_MAX);
}
function cleanMsg(s) {
// Strip markup/controls, collapse whitespace, cap length.
const t = String(s || "")
.replace(/<[^>]*>/g, " ")
.replace(/[\x00-\x1f]/g, "")
.replace(/\s+/g, " ")
.trim();
return t.slice(0, MSG_MAX);
}
// A message that is one URL (or a URL plus a couple of words) is spam-shaped.
// \b(?<=^|\s)https?:// = a URL at the start or after whitespace — the classic
// guestbook spam shape. A URL embedded in a real sentence ("read more at
// https://...") is left alone; plain words remain the rule, not the exception.
function looksLikeUrlOnly(t) {
const withUrl = /(^|\s)(https?:\/\/|www\.)\S+/i.test(t);
if (!withUrl) return false;
const stripped = t.replace(/(^|\s)(https?:\/\/|www\.)\S+/gi, " ").replace(/\s+/g, " ").trim();
return stripped.length < 12;
}
function ipOf(req) {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim().slice(0, 64);
return req.headers.get("x-real-ip") || "unknown";
}
export async function getBoard({ limit = LIST_LIMIT } = {}) {
const { board } = await getCollections();
const rows = await board
.find({})
.sort({ createdAt: -1 })
.limit(Math.min(Math.max(1, limit), LIST_LIMIT))
.toArray();
return rows.map((r) => ({
id: String(r._id),
nick: r.nick,
message: r.message,
createdAt: r.createdAt,
}));
}
export async function postBoard(req, body) {
const ip = ipOf(req);
const now = Date.now();
const { rate, board } = await getCollections();
// Rate limit: count this IP's posts in the 10-min and 24h windows.
// createdAt is stored as a BSON Date, so the comparison MUST be a Date too —
// a numeric epoch silently matches nothing (verified: the counter stayed 0).
const dayAgo = now - 24 * 3600e3;
const [inWindow, inDay] = await Promise.all([
rate.countDocuments({ ip, createdAt: { $gte: new Date(now - RATE_WINDOW_MS) } }),
rate.countDocuments({ ip, createdAt: { $gte: new Date(dayAgo) } }),
]);
if (inWindow >= RATE_LIMIT_WINDOW) {
return { ok: false, error: "Slow down — a few thanks at a time. Try again in a few minutes.", status: 429 };
}
if (inDay >= RATE_LIMIT_DAY) {
return { ok: false, error: "That's plenty of thanks for today. Come back tomorrow.", status: 429 };
}
const nick = cleanNick(body && body.nick);
const message = cleanMsg(body && body.message);
if (nick.length < 2) return { ok: false, error: "Give us a nickname (2+ characters) so we know who to thank.", status: 400 };
if (message.length < 3) return { ok: false, error: "Your message is empty.", status: 400 };
if (looksLikeUrlOnly(message)) return { ok: false, error: "Plain words only, please — no links.", status: 400 };
const doc = {
nick,
message,
ip: ip.slice(0, 64),
createdAt: new Date(now),
};
const res = await board.insertOne(doc);
// Bump the rate row (upsert keeps one doc per ip+day, but we just count
// recent createdAt values, so insert a lightweight marker per post).
await rate.insertOne({ ip, createdAt: new Date(now) });
return {
ok: true,
post: { id: String(res.insertedId), nick, message, createdAt: doc.createdAt },
};
}
// A friendly, non-authoritative total for the masthead.
export async function boardCount() {
try {
const { board } = await getCollections();
return await board.countDocuments({});
} catch {
return 0;
}
}