feat: public events/polls, friends graph, captcha, SSE hardening, and admin UX
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:
1 parent
2ff08e5127
commit
ed7db6e048
76 files changed
+4834
-1376
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user