v1.3 - added an sqlite db and built data structure
This commit is contained in:
parent
b0fba52c0e
commit
5efdafbb97
37 changed files with 6414 additions and 1988 deletions
130
server/src/routes/feedback.js
Normal file
130
server/src/routes/feedback.js
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue