ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { allow, clientIp } from "@/lib/ratelimit";
|
||||
import { sameOriginJson } from "@/lib/csrf";
|
||||
import { verifyTurnstile } from "@/lib/turnstile";
|
||||
import { mailConfigured, sendMail } from "@/lib/mail";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max);
|
||||
|
||||
/* Retention.
|
||||
*
|
||||
* A message through the contact form carries a name, a work address, a facility, a role, whatever
|
||||
* the person chose to write and the address they wrote it from. It was kept forever: the model has
|
||||
* no facility to cascade from, so nothing would ever have deleted one. Twelve months is long
|
||||
* enough for the enquiry and any follow-up it turns into, and the privacy note says the same
|
||||
* number — this is the mechanism that makes that sentence true rather than aspirational.
|
||||
*
|
||||
* Swept from here rather than from a cron, because a cron is a second thing to deploy and this
|
||||
* table only grows when this handler runs. The limiter is doing duty as an interval: one sweep an
|
||||
* hour, and the message the person is sending never waits on it. */
|
||||
const RETENTION_DAYS = 365;
|
||||
|
||||
function pruneOldMessages() {
|
||||
if (!allow("contact-prune", 1, 60 * 60 * 1000)) return;
|
||||
const cutoff = new Date(Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
|
||||
void prisma.contactMessage
|
||||
.deleteMany({ where: { createdAt: { lt: cutoff } } })
|
||||
.then((r) => { if (r.count) console.log(`[contact] retention: removed ${r.count} message(s) older than ${RETENTION_DAYS} days`); })
|
||||
.catch((e) => console.error("[contact] retention sweep failed:", (e as Error).message));
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const csrf = sameOriginJson(req);
|
||||
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
|
||||
|
||||
const ip = clientIp(req.headers);
|
||||
// Two buckets: a burst guard and a slower daily ceiling, so one address can't grind through it.
|
||||
if (!allow("contact:" + ip, 5, 60 * 60 * 1000) || !allow("contact-day:" + ip, 20, 24 * 60 * 60 * 1000)) {
|
||||
return NextResponse.json({ error: "That's a few messages in a short time. Try again later, or email hello@threadcount.tech." }, { status: 429 });
|
||||
}
|
||||
|
||||
let b: Record<string, unknown>;
|
||||
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
|
||||
|
||||
// Honeypot: a real person never fills this in.
|
||||
if (str(b.company, 100)) return NextResponse.json({ ok: true });
|
||||
|
||||
const name = str(b.name, 120);
|
||||
const email = str(b.email, 160).toLowerCase();
|
||||
const message = str(b.message, 4000);
|
||||
if (!name) return NextResponse.json({ error: "Add your name so I know who I'm replying to." }, { status: 400 });
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return NextResponse.json({ error: "Add an email address I can reply to." }, { status: 400 });
|
||||
if (message.length < 10) return NextResponse.json({ error: "Say a little more about what you need." }, { status: 400 });
|
||||
|
||||
const cfErr = await verifyTurnstile(b.cfToken, ip);
|
||||
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
|
||||
|
||||
pruneOldMessages();
|
||||
|
||||
const row = await prisma.contactMessage.create({
|
||||
data: {
|
||||
name, email, message, ip,
|
||||
role: str(b.role, 120), facility: str(b.facility, 160),
|
||||
topic: str(b.topic, 60), slot: str(b.slot, 60),
|
||||
},
|
||||
});
|
||||
|
||||
const emailed = await sendMail(
|
||||
`ThreadCount contact — ${row.topic || "A question"} — ${name}`,
|
||||
[`From: ${name}${row.role ? ` (${row.role})` : ""}`, row.facility && `Facility: ${row.facility}`, `Email: ${email}`,
|
||||
row.topic && `Topic: ${row.topic}`, row.slot && `Walkthrough: ${row.slot}`, "", message, "", `Received ${row.createdAt.toISOString()} from ${ip}`]
|
||||
.filter(Boolean).join("\n"),
|
||||
email,
|
||||
);
|
||||
if (emailed) await prisma.contactMessage.update({ where: { id: row.id }, data: { emailed: true } });
|
||||
else if (!mailConfigured()) console.warn("[contact] stored", row.id, "— SMTP not configured, no notification sent");
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
Reference in New Issue
Block a user