feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 2m55s

Ship product gaps: register/vote pages, friend add/accept/decline/remove, email verify TTL, captcha on login/forgot, soft-fail user actions, SSE abort/shared client, Commando Centrum error toasts, admin delete for events/polls, and IT/NL i18n fills.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:08:33 +02:00
1 parent 2ff08e5127
commit ed7db6e048
76 files changed
+4834 -1376

No files matched your search

+75
View File
@@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockGet = vi.hoisted(() => vi.fn());
vi.mock("@/lib/services/site-settings", () => ({
siteSettings: { get: mockGet },
}));
import { captchaConfig, verifyCaptcha } from "./captcha";
beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
});
describe("verifyCaptcha", () => {
it("allows when provider is none", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "none";
return fallback ?? "";
});
expect(await verifyCaptcha(null)).toBe(true);
});
it("fails closed when provider set but secret missing", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "turnstile";
if (key === "turnstile_site_key") return "site-key";
if (key === "turnstile_secret") return "";
return fallback ?? "";
});
expect(await verifyCaptcha("tok")).toBe(false);
});
it("fails closed on provider API/network errors", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "turnstile";
if (key === "turnstile_site_key") return "site-key";
if (key === "turnstile_secret") return "secret";
return fallback ?? "";
});
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network down")),
);
expect(await verifyCaptcha("tok", "1.2.3.4")).toBe(false);
});
it("accepts a successful provider response", async () => {
mockGet.mockImplementation(async (key: string, fallback?: string) => {
if (key === "captcha_provider") return "recaptcha";
if (key === "recaptcha_site_key") return "site-key";
if (key === "recaptcha_secret") return "secret";
return fallback ?? "";
});
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
}),
);
expect(await verifyCaptcha("good-token")).toBe(true);
});
});
describe("captchaConfig", () => {
it("returns none by default", async () => {
mockGet.mockImplementation(async (_key: string, fallback?: string) => {
return fallback ?? "none";
});
const cfg = await captchaConfig();
expect(cfg.provider).toBe("none");
});
});
+15 -9
View File
@@ -3,9 +3,12 @@ import { siteSettings } from "@/lib/services/site-settings";
/**
* Server-side CAPTCHA verification, driven by website_settings so staff pick the
* provider in housekeeping. Supports Cloudflare Turnstile and Google reCAPTCHA
* (the two AtomCMS offers, mutually exclusive). FAIL-OPEN by configuration: when
* no provider/secret is set, registration isn't blocked; only an explicitly
* configured provider with a failing/absent token blocks.
* (the two AtomCMS offers, mutually exclusive).
*
* Behaviour:
* - provider "none" (or unset) → fail-open (allow)
* - provider configured but site key / secret missing, token absent, provider
* API error, or network failure → fail-closed (deny)
*
* Settings keys:
* captcha_provider = "turnstile" | "recaptcha" | "none" (default none)
@@ -23,7 +26,7 @@ const TURNSTILE_URL =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
const RECAPTCHA_URL = "https://www.google.com/recaptcha/api/siteverify";
/** Public config the register page needs to render the widget (no secrets). */
/** Public config the register/login pages need to render the widget (no secrets). */
export async function captchaConfig(): Promise<CaptchaConfig> {
const provider = (
(await siteSettings.get("captcha_provider", "none")) ?? "none"
@@ -45,18 +48,20 @@ export async function captchaConfig(): Promise<CaptchaConfig> {
return { provider: "none", siteKey: "", field: "" };
}
/** Verify a submitted token. Returns true when allowed (incl. fail-open). */
/** Verify a submitted token. Fail-closed when a provider is configured. */
export async function verifyCaptcha(
token: string | null,
remoteIp?: string,
): Promise<boolean> {
const cfg = await captchaConfig();
if (cfg.provider === "none" || !cfg.siteKey) return true;
if (cfg.provider === "none") return true;
if (!cfg.siteKey) return false;
const secretKey =
cfg.provider === "turnstile" ? "turnstile_secret" : "recaptcha_secret";
const secret = (await siteSettings.get(secretKey, "")) ?? "";
if (!secret) return true; // configured but no secret — don't hard-block
if (!secret) return false;
if (!token) return false;
const url = cfg.provider === "turnstile" ? TURNSTILE_URL : RECAPTCHA_URL;
@@ -74,10 +79,11 @@ export async function verifyCaptcha(
cache: "no-store",
});
clearTimeout(timer);
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data?.success === true;
} catch {
// Network/timeout — fail-open so a provider outage can't lock out signups.
return true;
// Network/timeout/misconfig — fail-closed so captcha can't be bypassed.
return false;
}
}
@@ -40,4 +40,26 @@ describe("import/core/sse-batch", () => {
failed: 1,
});
});
it("stops starting new chunks when AbortSignal fires", async () => {
const ac = new AbortController();
let started = 0;
const res = runSseBatch({
items: ["a", "b", "c", "d"],
concurrency: 1,
signal: ac.signal,
labelOf: (item) => item,
worker: async (item) => {
started++;
if (item === "a") ac.abort();
// Slow enough that abort lands before the next chunk starts.
await new Promise((r) => setTimeout(r, 20));
return { ok: true };
},
});
const events = await collect(res);
expect(started).toBeLessThan(4);
expect(events.some((e) => e.type === "batch_complete")).toBe(false);
expect(events[0]).toMatchObject({ type: "batch_start", total: 4 });
});
});
+34 -9
View File
@@ -7,6 +7,8 @@ export interface SseWorkerResult {
export interface RunSseBatchOptions<T> {
items: T[];
concurrency: number;
/** Abort when the client disconnects (e.g. `request.signal`). */
signal?: AbortSignal;
/** Label used as the `classname` field on item_progress (kept for the existing client parser). */
labelOf: (item: T) => string;
/** Per-item worker. `report(status)` streams intermediate progress (e.g. 'downloading'). */
@@ -20,21 +22,30 @@ export interface RunSseBatchOptions<T> {
/**
* Generic SSE batch runner. Emits the same event shape the furni client
* parser consumes: batch_start / item_progress / batch_complete.
* Stops starting new chunks when `signal` aborts or the client cancels the stream.
*/
export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const { items, labelOf, worker } = opts;
const { items, labelOf, worker, signal } = opts;
const concurrency = Math.min(Math.max(opts.concurrency || 3, 1), 5);
const encoder = new TextEncoder();
const ac = new AbortController();
if (signal) {
if (signal.aborted) ac.abort();
else signal.addEventListener("abort", () => ac.abort(), { once: true });
}
const stream = new ReadableStream({
async start(controller) {
const send = (data: unknown) => {
if (ac.signal.aborted) return;
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch {
/* stream closed by client */
ac.abort();
}
};
@@ -46,9 +57,12 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
let withWarnings = 0;
for (let i = 0; i < items.length; i += concurrency) {
if (ac.signal.aborted) break;
const chunk = items.slice(i, i + concurrency);
await Promise.allSettled(
chunk.map(async (item, chunkIdx) => {
if (ac.signal.aborted) return;
const index = i + chunkIdx;
const classname = labelOf(item);
send({
@@ -61,6 +75,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
const result = await worker(item, index, (status) =>
send({ type: "item_progress", classname, status, index }),
);
if (ac.signal.aborted) return;
if (result.ok) {
succeeded++;
if (result.warnings?.length) withWarnings++;
@@ -84,6 +99,7 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
});
}
} catch (err) {
if (ac.signal.aborted) return;
failed++;
send({
type: "item_progress",
@@ -97,14 +113,23 @@ export function runSseBatch<T>(opts: RunSseBatchOptions<T>): Response {
);
}
send({
type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
});
controller.close();
if (!ac.signal.aborted) {
send({
type: "batch_complete",
succeeded,
failed,
warnings: withWarnings,
duration: Date.now() - startTime,
});
}
try {
controller.close();
} catch {
/* already closed */
}
},
cancel() {
ac.abort();
},
});