v1.3 - added an sqlite db and built data structure

This commit is contained in:
Zaldimmar 2026-09-25 02:35:46 -05:00
parent b0fba52c0e
commit 5efdafbb97
37 changed files with 6414 additions and 1988 deletions

View file

@ -0,0 +1,130 @@
/* ═══════════════════════════════════════════════════════════════
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;