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.
132 lines
4.8 KiB
JavaScript
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;
|
|
}
|
|
}
|