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
+12
-3
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user