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