ThreadCount Community edition

Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
ThreadCount
2026-09-13 08:54:35 +10:00
commit 344b1701dd
505 changed files with 56231 additions and 0 deletions
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server";
import { allow, clientIp } from "@/lib/ratelimit";
import { sameOriginJson } from "@/lib/csrf";
import { verifyTurnstile } from "@/lib/turnstile";
export const dynamic = "force-dynamic";
/* Newsletter sign-up.
*
* ThreadCount keeps two promises that shape this endpoint. The contact form says, at the point of
* collection, "No mailing list, no follow-up sequence" — so nothing that arrives through the
* contact form ever reaches this list, and the two paths share no code and no storage. And the
* list is double opt-in: this handler only ever creates an *unconfirmed* subscriber, and Listmonk
* emails a confirmation link that the person has to click before they can be sent anything.
*
* It posts to ThreadCount's own Listmonk (lists.threadcount.tech), which is a separate instance
* from ClearAudit's: Listmonk has a single global from-address, so sharing one would have sent
* ThreadCount's confirmation emails from ClearAudit and failed SPF/DKIM alignment for this domain.
*
* The list uuid is not a secret — it is designed to sit in a public subscription form — so it is
* committed rather than left to an env var that a build could forget. */
const LIST_UUID = process.env.LISTMONK_LIST_UUID || "734e9011-5fd5-48a7-b0ae-4ea0e1deb972";
const LISTMONK = process.env.LISTMONK_URL || "https://lists.threadcount.tech";
const str = (v: unknown, max: number) => String(v ?? "").trim().slice(0, max);
export async function POST(req: NextRequest) {
const csrf = sameOriginJson(req);
if (csrf) return NextResponse.json({ error: csrf }, { status: 403 });
const ip = clientIp(req.headers);
if (!allow("subscribe:" + ip, 5, 60 * 60 * 1000) || !allow("subscribe-day:" + ip, 20, 24 * 60 * 60 * 1000)) {
return NextResponse.json({ error: "That's a few attempts in a short time. Try again later." }, { status: 429 });
}
let b: Record<string, unknown>;
try { b = await req.json(); } catch { return NextResponse.json({ error: "Bad request" }, { status: 400 }); }
// Honeypot, same as the contact form: a real person never fills this in.
if (str(b.company, 100)) return NextResponse.json({ ok: true });
const email = str(b.email, 160).toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "That email address doesn't look right." }, { status: 400 });
}
const cfErr = await verifyTurnstile(b.cfToken, ip);
if (cfErr) return NextResponse.json({ error: cfErr }, { status: 400 });
// Listmonk's public subscription handler. It creates the subscriber as unconfirmed and sends the
// opt-in email itself, which is why this endpoint never needs an admin token.
const form = new URLSearchParams({ email, name: "", l: LIST_UUID });
try {
const r = await fetch(`${LISTMONK}/subscription/form`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: form.toString(),
redirect: "manual", // success is a 302 back to a thank-you page
signal: AbortSignal.timeout(8000),
});
if (r.status >= 500) {
return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 });
}
} catch {
return NextResponse.json({ error: "Sign-up is unavailable for a moment — please try again shortly." }, { status: 502 });
}
// Deliberately the same answer whether or not the address was already on the list: otherwise
// this endpoint would confirm to a stranger who is subscribed.
return NextResponse.json({ ok: true });
}