// ============================================================================ // Project : modelmap // File : site/lib/comments.js // Purpose : Visitor comments — persistent JSON store, validation, limits // Author : Simon-Pierre Boucher // Contact : contact@spboucher.ai // Website : https://modelmap.io // Created : 2026-08-12 // Modified : 2026-08-12 // Platform : macOS / Apple Silicon (arm64) — Node.js (deployed on MacLustr) // License : All rights reserved (research code) // ============================================================================ "use strict"; const fs = require("fs"); const os = require("os"); const path = require("path"); const crypto = require("crypto"); // Stored OUTSIDE the app directory so rsync --delete redeploys never wipe it. const STORE = process.env.COMMENTS_FILE || path.join(os.homedir(), ".modelmap-web", "comments.json"); const MAX_NAME = 60; const MAX_MESSAGE = 2000; const MIN_MESSAGE = 3; const MAX_TOTAL = 5000; // hard cap on stored comments const RATE_LIMIT = 5; // posts per IP per window const RATE_WINDOW_MS = 60 * 60 * 1000; const recentByIp = new Map(); // ipHash -> [timestamps] function ensureStore() { fs.mkdirSync(path.dirname(STORE), { recursive: true }); if (!fs.existsSync(STORE)) fs.writeFileSync(STORE, "[]\n"); } function list() { try { ensureStore(); const all = JSON.parse(fs.readFileSync(STORE, "utf8")); return Array.isArray(all) ? all.slice().reverse() : []; } catch { return []; } } function clean(s, max) { return String(s || "") .replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "") .replace(/\s+/g, (m) => (m.includes("\n") ? "\n" : " ")) .trim() .slice(0, max); } function ipHash(ip) { return crypto.createHash("sha256").update(String(ip || "")).digest("hex").slice(0, 16); } function rateLimited(ip) { const key = ipHash(ip); const now = Date.now(); const times = (recentByIp.get(key) || []).filter((t) => now - t < RATE_WINDOW_MS); if (times.length >= RATE_LIMIT) return true; times.push(now); recentByIp.set(key, times); return false; } /** * Add a comment. Returns { ok, error? }. * honeypot: hidden "website" field — bots fill it, humans don't. */ function add({ name, message, honeypot, ip }) { if (honeypot) return { ok: true, dropped: true }; // pretend success, drop silently const n = clean(name, MAX_NAME) || "Anonymous"; const m = clean(message, MAX_MESSAGE); if (m.length < MIN_MESSAGE) return { ok: false, error: "Message is too short." }; if (rateLimited(ip)) return { ok: false, error: "Too many comments from this address — try again later." }; ensureStore(); let all; try { all = JSON.parse(fs.readFileSync(STORE, "utf8")); if (!Array.isArray(all)) all = []; } catch { all = []; } all.push({ id: crypto.randomUUID(), name: n, message: m, created: new Date().toISOString(), }); if (all.length > MAX_TOTAL) all = all.slice(all.length - MAX_TOTAL); fs.writeFileSync(STORE, JSON.stringify(all, null, 2) + "\n"); return { ok: true }; } module.exports = { list, add, STORE };