130 lines
4.8 KiB
JavaScript
130 lines
4.8 KiB
JavaScript
/* ═══════════════════════════════════════════════════════════════
|
|
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;
|