ThreadCount Community edition
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user