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

+12 -3
View File
@@ -67,9 +67,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
const password = String(credentials?.password ?? "");
if (!username || !password) return null;
const ip = await clientIp();
// Throttle login attempts per IP (10 per 5 min) against credential stuffing.
if (!(await rateLimit(`login:${await clientIp()}`, 10, 5 * 60_000)).ok)
return null;
if (!(await rateLimit(`login:${ip}`, 10, 5 * 60_000)).ok) return null;
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
@@ -90,6 +91,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
});
if (!res.valid) return null;
if (
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return null;
}
if (res.upgradedHash) {
await prisma.user.update({
where: { id: user.id },
@@ -117,7 +126,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
await prisma.websiteLoginLogs.create({
data: {
userId: user.id,
ip: await clientIp(),
ip,
userAgent: ua,
createdAt: new Date(),
},
+3
View File
@@ -242,6 +242,9 @@ export function handleActionError(error: unknown): ActionFailure {
if (error instanceof DatabaseError) {
return fail("A database error occurred");
}
if (error instanceof Error && error.name === "ActionError") {
return fail(error.message);
}
if (error instanceof Error && error.name === "ZodError") {
return fail("Validation failed");
}
+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();
},
});
+86
View File
@@ -0,0 +1,86 @@
import { toast } from "sonner";
import { adminFetch } from "@/lib/admin-fetch";
export type SseEvent = Record<string, unknown>;
/**
* Read an SSE response body and invoke `onEvent` for each `data:` JSON payload.
*/
export async function readSseStream(
body: ReadableStream<Uint8Array>,
onEvent: (event: SseEvent) => void,
signal?: AbortSignal,
): Promise<void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
try {
while (true) {
if (signal?.aborted) {
await reader.cancel();
break;
}
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n\n");
buf = parts.pop() ?? "";
for (const part of parts) {
if (!part.startsWith("data: ")) continue;
try {
onEvent(JSON.parse(part.slice(6)) as SseEvent);
} catch {
/* skip malformed events */
}
}
}
} finally {
reader.releaseLock();
}
}
/**
* POST JSON to an admin SSE import endpoint and drive the standard
* item_progress / batch_complete callbacks used by clothing & clone clients.
*/
export async function runSseImport(
url: string,
body: unknown,
onDone: (classname: string) => void,
onComplete: (succeeded: number, failed: number) => void,
signal?: AbortSignal,
): Promise<void> {
const res = await adminFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
if (!res.body) {
toast.error("No response stream");
return;
}
let succeeded = 0;
let failed = 0;
await readSseStream(
res.body,
(evt) => {
if (
evt.type === "item_progress" &&
(evt.status === "done" || evt.status === "failed")
) {
onDone(String(evt.classname ?? ""));
}
if (evt.type === "batch_complete") {
succeeded = Number(evt.succeeded ?? 0);
failed = Number(evt.failed ?? 0);
}
},
signal,
);
onComplete(succeeded, failed);
}
+33 -7
View File
@@ -6,17 +6,30 @@ type UseEventSourceOptions = {
onMessage?: (data: unknown) => void;
onError?: (event: Event) => void;
enabled?: boolean;
/** Max reconnect attempts after errors (default 8). Set 0 to disable reconnect. */
maxRetries?: number;
/** Initial reconnect delay in ms (default 1000); doubles each attempt. */
baseDelayMs?: number;
/** Cap on reconnect delay in ms (default 30000). */
maxDelayMs?: number;
};
/**
* Subscribe to a Server-Sent Events (SSE) endpoint.
* Reconnects automatically on connection loss.
* Reconnects with exponential backoff up to `maxRetries`.
*/
export function useEventSource<T = unknown>(
url: string | null,
options?: UseEventSourceOptions,
) {
const { onMessage, onError, enabled = true } = options ?? {};
const {
onMessage,
onError,
enabled = true,
maxRetries = 8,
baseDelayMs = 1000,
maxDelayMs = 30_000,
} = options ?? {};
const [data, setData] = useState<T | null>(null);
const [connected, setConnected] = useState(false);
const onMessageRef = useRef(onMessage);
@@ -29,12 +42,18 @@ export function useEventSource<T = unknown>(
const endpoint: string = url;
let es: EventSource | null = null;
let reconnectTimer: ReturnType<typeof setTimeout>;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let attempt = 0;
let disposed = false;
function connect() {
if (disposed) return;
es = new EventSource(endpoint);
es.onopen = () => setConnected(true);
es.onopen = () => {
attempt = 0;
setConnected(true);
};
es.onmessage = (event) => {
try {
@@ -48,20 +67,27 @@ export function useEventSource<T = unknown>(
es.onerror = (event) => {
es?.close();
es = null;
setConnected(false);
onErrorRef.current?.(event);
reconnectTimer = setTimeout(connect, 3000);
if (disposed || attempt >= maxRetries) return;
const delay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
attempt++;
reconnectTimer = setTimeout(connect, delay);
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
disposed = true;
if (reconnectTimer !== undefined) clearTimeout(reconnectTimer);
es?.close();
setConnected(false);
};
}, [url, enabled]);
}, [url, enabled, maxRetries, baseDelayMs, maxDelayMs]);
return { data, connected };
}
+5
View File
@@ -67,8 +67,13 @@ export const eventWinnerSchema = z.object({
position: z.coerce.number().int().positive().default(1),
});
export const registerForEventSchema = z.object({
eventId: z.coerce.number().int().positive(),
});
export type EventTypeInput = z.infer<typeof eventTypeSchema>;
export type CreateEventInput = z.infer<typeof createEventSchema>;
export type UpdateEventInput = z.infer<typeof updateEventSchema>;
export type EventPrizeInput = z.infer<typeof eventPrizeSchema>;
export type EventWinnerInput = z.infer<typeof eventWinnerSchema>;
export type RegisterForEventInput = z.infer<typeof registerForEventSchema>;
+6
View File
@@ -25,7 +25,13 @@ export const pollVoteSchema = z.object({
answer: z.string().min(1).max(500),
});
export const voteOnPollSchema = z.object({
pollId: z.coerce.number().int().positive(),
votes: z.array(pollVoteSchema).min(1).max(50),
});
export type CreatePollInput = z.infer<typeof createPollSchema>;
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
export type PollQuestionInput = z.infer<typeof pollQuestionSchema>;
export type PollVoteInput = z.infer<typeof pollVoteSchema>;
export type VoteOnPollInput = z.infer<typeof voteOnPollSchema>;