/* ═══════════════════════════════════════════════════════════════ FEEDBACK ROUTE — the only public write on the site Everything hostile that will ever reach this service arrives here, so the defences live here rather than being sprinkled around: rate limit applied where this router is mounted (5/min) honeypot a field real users never see or fill length caps rejected before anything touches the database no HTML stored verbatim, escaped at render time The write is a single INSERT with positional parameters, so it works on node:sqlite and better-sqlite3 alike. No transaction: one statement is already atomic. ═══════════════════════════════════════════════════════════════ */ import { Hono } from "hono"; import { createHash, randomBytes } from "node:crypto"; const feedback = new Hono(); const LIMITS = { name: 120, email: 254, message: 5000, pagePath: 200, sectionId: 80, }; const MIN_MESSAGE = 10; // Must match FEEDBACK_TYPES in src/pages/sections/FeedbackForm.jsx. // Anything else falls back to 'general' rather than being rejected — // a renamed option shouldn't lose someone's submission. const TYPES = ["broken", "confusing", "outdated", "request", "praise", "other"]; /* ── IP hashing ──────────────────────────────────────────────── Stored so repeat abuse from one source is visible during triage, hashed so the table never holds an address. Without IP_SALT the salt is regenerated each boot, which makes hashes incomparable across restarts — fine for dev, set it in /etc/ngu/api.env for production. ───────────────────────────────────────────────────────────── */ const IP_SALT = process.env.IP_SALT ?? randomBytes(16).toString("hex"); if (!process.env.IP_SALT) { console.warn("IP_SALT unset — feedback ip_hash values reset on restart"); } function hashIp(ip) { if (!ip) return null; return createHash("sha256").update(`${IP_SALT}:${ip}`).digest("hex").slice(0, 32); } /* ── Input cleaning ──────────────────────────────────────────── */ function clean(value, max) { if (typeof value !== "string") return ""; return value.trim().slice(0, max); } // Deliberately permissive. Rejecting odd-but-valid addresses loses // real submissions, and the field is optional anyway. function looksLikeEmail(value) { return value === "" || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); } /* ── POST /api/feedback ──────────────────────────────────────── */ feedback.post("/", async (c) => { let body; try { body = await c.req.json(); } catch { return c.json({ error: "Expected a JSON body." }, 400); } // Honeypot. A bot fills every input it finds; a person can't see // this one. Return success so the bot doesn't learn anything. if (clean(body.website, 50) !== "") { return c.body(null, 204); } const name = clean(body.name, LIMITS.name); const email = clean(body.email, LIMITS.email); const message = clean(body.message, LIMITS.message); const pagePath = clean(body.pagePath, LIMITS.pagePath); // The picker holds nav hashes ('#chapters'); the column holds ids. const sectionId = clean(body.sectionId, LIMITS.sectionId).replace(/^#/, ""); const feedbackType = TYPES.includes(body.feedbackType) ? body.feedbackType : "general"; const errors = {}; if (message.length < MIN_MESSAGE) errors.message = "Please write a little more."; if (!looksLikeEmail(email)) errors.email = "That email doesn't look right."; if (Object.keys(errors).length > 0) { return c.json({ error: "Validation failed", fields: errors }, 422); } const db = c.get("db"); const result = db .prepare( `INSERT INTO feedback (feedback_type, message, name, email, page_path, section_id, user_agent, ip_hash) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ) .run( feedbackType, message, name || null, email || null, pagePath || null, sectionId || null, clean(c.req.header("user-agent"), 500) || null, hashIp(c.req.header("x-forwarded-for")?.split(",")[0].trim()), ); // created_at and status come from column defaults. console.log(`feedback #${result.lastInsertRowid} (${feedbackType})`); return c.body(null, 204); }); export default feedback;