Add EpicNext CMS foundation layer and fix critical security gaps
Local Build and Deploy / deploy (push) Successful in 1m1s
Local Build and Deploy / deploy (push) Successful in 1m1s
- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers, DbService with health checks, CSRF validation, safe redirects, AsyncLocalStorage request tracing, branded types, reusable Zod schemas - Migrate moderation.ts and user-settings.ts to foundation patterns - Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded) - Fix access-guard.ts: separate try/catch per check, log degradation instead of blanket fail-open - Replace raw redirect() calls with safeRedirect() in guard.ts and permissions.ts to prevent open-redirect attacks - Add CSRF validation to api-handler.ts for mutating methods - Add canonicalizeFormData() utility for FormData input sanitization
This commit is contained in:
1 parent
8bf1aa2fa7
commit
f6ad030c5b
16 files changed
+1012
-96
No files matched your search
@@ -3,8 +3,8 @@
|
||||
import { z } from "zod";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { adminAction, actionOk } from "@/lib/foundation/action";
|
||||
import { NotFoundError } from "@/lib/foundation/errors";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
@@ -16,7 +16,7 @@ export const assignCfhTicket = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhIdSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -43,7 +43,7 @@ export const updateCfhState = adminAction(
|
||||
{ permission: PERMS.MODERATION_EDIT, schema: cfhStateSchema },
|
||||
async (ctx) => {
|
||||
const ticket = await prisma.supportTickets.findUnique({ where: { id: ctx.data.ticketId } });
|
||||
if (!ticket) throw new ActionError("Ticket not found");
|
||||
if (!ticket) throw new NotFoundError("SupportTicket", ctx.data.ticketId);
|
||||
|
||||
await prisma.supportTickets.update({
|
||||
where: { id: ctx.data.ticketId },
|
||||
@@ -104,7 +104,7 @@ export const quickKick = adminAction(
|
||||
|
||||
const muteSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
duration: z.coerce.number().int().min(0).max(525600), // max 1 year in minutes
|
||||
duration: z.coerce.number().int().min(0).max(525600),
|
||||
});
|
||||
|
||||
export const quickMute = adminAction(
|
||||
|
||||
@@ -1,40 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import { authAction, actionOk } from "@/lib/foundation/action";
|
||||
import { DatabaseError } from "@/lib/foundation/errors";
|
||||
|
||||
// Emulator motto column is VARCHAR(127); keep the CMS-side write within bounds.
|
||||
const MOTTO_MAX = 127;
|
||||
|
||||
/**
|
||||
* Update the SIGNED-IN user's motto. The id is taken from the session
|
||||
* (re-fetched via auth()), never from the submitted FormData, so a crafted
|
||||
* form cannot mutate another account. Mirrors AtomCMS: persist + RCON setmotto
|
||||
* so an online user sees the change live.
|
||||
*/
|
||||
const mottoSchema = z.object({
|
||||
motto: z.string().max(MOTTO_MAX, `Motto must be at most ${MOTTO_MAX} characters`),
|
||||
});
|
||||
|
||||
const updateMottoAction = authAction(
|
||||
{ schema: mottoSchema },
|
||||
async (ctx) => {
|
||||
try {
|
||||
await prisma.user.update({ where: { id: ctx.session.user.id }, data: { motto: ctx.data.motto } });
|
||||
} catch {
|
||||
throw new DatabaseError("Failed to update motto");
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(ctx.session.user.id, ctx.data.motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
return actionOk();
|
||||
},
|
||||
);
|
||||
|
||||
export async function updateMotto(formData: FormData): Promise<void> {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return;
|
||||
|
||||
const id = Number(session.user.id);
|
||||
if (!Number.isFinite(id)) return;
|
||||
|
||||
const motto = String(formData.get("motto") ?? "").slice(0, MOTTO_MAX);
|
||||
|
||||
try {
|
||||
await prisma.user.update({ where: { id }, data: { motto } });
|
||||
} catch {
|
||||
// DB unavailable — fail soft; nothing to persist.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rcon.setMotto(id, motto);
|
||||
} catch {
|
||||
// RCON is best-effort; the change is already persisted.
|
||||
}
|
||||
|
||||
revalidatePath("/settings");
|
||||
await updateMottoAction({ motto });
|
||||
}
|
||||
|
||||
export { updateMottoAction };
|
||||
+36
-25
@@ -4,56 +4,67 @@ import { auth } from "@/lib/auth";
|
||||
import { isIpBlacklisted, recordRequest } from "@/lib/services/abuse-guard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { safeRedirect } from "@/lib/foundation/security";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
// Paths that must never be gated (otherwise banned/maintenance loop forever).
|
||||
const EXEMPT = ["/banned", "/maintenance", "/login", "/register", "/forgot", "/reset", "/api"];
|
||||
|
||||
function isExempt(path: string): boolean {
|
||||
return EXEMPT.some((p) => path === p || path.startsWith(`${p}/`));
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-wide access enforcement (called from the root layout): routes non-staff
|
||||
* to /maintenance when maintenance mode is on, and banned users to /banned.
|
||||
* Runs in the Node runtime so it can query the DB. The redirect decision is
|
||||
* computed inside try/catch and the redirect() (which throws NEXT_REDIRECT) is
|
||||
* issued OUTSIDE it.
|
||||
*/
|
||||
export async function enforceSiteAccess(): Promise<void> {
|
||||
const h = await headers();
|
||||
const path = h.get("x-pathname") ?? "/";
|
||||
const ip = h.get("x-real-client-ip") ?? h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "0.0.0.0";
|
||||
|
||||
// Abuse/DDoS guard: count this request and block flooding IPs (no-op unless
|
||||
// enabled in settings). Best-effort — never let it throw past the guard.
|
||||
void recordRequest(ip).catch(() => {});
|
||||
|
||||
if (isExempt(path)) return;
|
||||
|
||||
let target: string | null = null;
|
||||
try {
|
||||
// App-level IP blacklist (auto-populated by the abuse guard + /admin/ip).
|
||||
if (await isIpBlacklisted(ip)) target = "/banned";
|
||||
let checksDegraded = false;
|
||||
|
||||
try {
|
||||
if (await isIpBlacklisted(ip)) target = "/banned";
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
const rank = session?.user?.rank ?? 0;
|
||||
|
||||
if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
|
||||
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
|
||||
if (rank < minLogin) target = "/maintenance";
|
||||
try {
|
||||
if (!target && (await siteSettings.getBool("maintenance_enabled", false))) {
|
||||
const minLogin = Number(await siteSettings.get("min_maintenance_login_rank", "7")) || 7;
|
||||
if (rank < minLogin) target = "/maintenance";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
if (!target && session?.user?.id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (ban) target = "/banned";
|
||||
try {
|
||||
if (!target && session?.user?.id) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const ban = await prisma.ban.findFirst({
|
||||
where: { userId: Number(session.user.id), banExpire: { gt: now } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (ban) target = "/banned";
|
||||
}
|
||||
} catch {
|
||||
checksDegraded = true;
|
||||
}
|
||||
} catch {
|
||||
// On any failure, fail open (don't lock the whole site out on a DB hiccup).
|
||||
checksDegraded = true;
|
||||
}
|
||||
|
||||
if (target) redirect(target);
|
||||
if (target) {
|
||||
redirect(safeRedirect(target, target));
|
||||
}
|
||||
|
||||
if (checksDegraded) {
|
||||
logger.warn("Access guard degraded — some checks skipped", { ip, path });
|
||||
}
|
||||
}
|
||||
+3
-11
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { isStaff } from "@/lib/admin/is-staff";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { redirectSafe } from "@/lib/foundation/security";
|
||||
|
||||
export { isStaff };
|
||||
|
||||
@@ -11,23 +11,15 @@ export interface StaffUser {
|
||||
username: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for admin pages and actions: redirects to /login when unauthenticated
|
||||
* and to / when authenticated but not staff. Returns the staff user otherwise.
|
||||
*/
|
||||
export async function requireStaff(): Promise<StaffUser> {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirect("/");
|
||||
if (!canAccess(permissions, PERMS.ADMIN_DASHBOARD, session.user.rank)) redirectSafe("/", "/");
|
||||
return { id: session.user.id, rank: session.user.rank, username: session.user.username };
|
||||
}
|
||||
|
||||
/**
|
||||
* Like requireStaff but also rate-limits the action per staff user (30 requests
|
||||
* per minute). Use on sensitive admin actions (ban, rank-change, settings edit).
|
||||
*/
|
||||
export async function requireStaffRateLimited(): Promise<StaffUser> {
|
||||
const staff = await requireStaff();
|
||||
const ip = await clientIp();
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirect("/admin?error=ratelimit");
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok) redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
+12
-1
@@ -3,6 +3,9 @@ import { NextResponse } from "next/server";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { validateCsrfToken } from "@/lib/foundation/security";
|
||||
|
||||
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
|
||||
type AdminContext = NonNullable<Awaited<ReturnType<typeof getApiAdminContext>>>;
|
||||
type RouteContext = { params?: Promise<Record<string, string | string[]>> };
|
||||
@@ -12,8 +15,16 @@ type AdminHandler = (
|
||||
routeContext: RouteContext,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export function withAdmin(options: { permission?: string }, handler: AdminHandler) {
|
||||
export function withAdmin(options: { permission?: string; requireCsrf?: boolean }, handler: AdminHandler) {
|
||||
return async (request: NextRequest, routeContext: RouteContext = {}) => {
|
||||
if (options.requireCsrf === true && MUTATING_METHODS.has(request.method)) {
|
||||
const csrfToken = request.headers.get("x-csrf-token") ?? request.headers.get("csrf-token") ?? "";
|
||||
const valid = await validateCsrfToken(csrfToken);
|
||||
if (!valid) {
|
||||
return NextResponse.json({ ok: false, error: "Invalid or missing CSRF token" }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
const context = await getApiAdminContext();
|
||||
if (!context) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import type { z } from "zod";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { canAccess, getApiAdminContext } from "@/lib/permissions";
|
||||
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
|
||||
import { getRequestId, runWithStore, createStore, setContextUserId } from "./request-context";
|
||||
import { NotFoundError, UnauthorizedError, ForbiddenError, ValidationError, RateLimitError, DatabaseError } from "./errors";
|
||||
import type { ActionResult, ActionSuccess, ActionFailure, AppSession, AdminActionContext, IpAddress, RequestId } from "./types";
|
||||
import { extractClientIpAsync } from "./security";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
function ok<T = Record<string, unknown>>(data?: T): ActionSuccess<T> {
|
||||
return { ok: true, data: (data ?? {}) as T } as unknown as ActionSuccess<T>;
|
||||
}
|
||||
|
||||
function fail(error: string, fieldErrors?: Record<string, string[]>): ActionFailure {
|
||||
return { ok: false, error, fieldErrors };
|
||||
}
|
||||
|
||||
export { ok as actionOk, fail as actionError };
|
||||
|
||||
interface AdminOpts<TSchema extends z.ZodType | undefined> {
|
||||
permission?: string;
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
type ActionHandler<TSchema extends z.ZodType | undefined> = (
|
||||
ctx: AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>),
|
||||
) => Promise<ActionResult>;
|
||||
|
||||
export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AdminOpts<TSchema>,
|
||||
handler: ActionHandler<TSchema>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const apiCtx = await getApiAdminContext();
|
||||
if (!apiCtx) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(apiCtx.session.user.id) as never);
|
||||
|
||||
if (opts.permission) {
|
||||
if (!canAccess(apiCtx.permissions, opts.permission, apiCtx.session.user.rank)) {
|
||||
await logAuthorizationEvent({
|
||||
kind: "permission.denied",
|
||||
userId: Number(apiCtx.session.user.id),
|
||||
username: apiCtx.session.user.name ?? undefined,
|
||||
rank: apiCtx.session.user.rank,
|
||||
permission: opts.permission,
|
||||
source: "adminAction",
|
||||
reason: "Permission check denied",
|
||||
});
|
||||
return fail("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: apiCtx.session,
|
||||
permissions: apiCtx.permissions,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
...(opts.schema ? { data: data as z.infer<NonNullable<TSchema>> } : {}),
|
||||
} as AdminActionContext & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthOpts<TSchema extends z.ZodType | undefined> {
|
||||
schema?: TSchema;
|
||||
rateLimitKey?: string;
|
||||
rateLimitMax?: number;
|
||||
rateLimitWindowMs?: number;
|
||||
}
|
||||
|
||||
export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
opts: AuthOpts<TSchema>,
|
||||
handler: (ctx: { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>)) => Promise<ActionResult>,
|
||||
) {
|
||||
return async (
|
||||
input: TSchema extends z.ZodType ? z.input<TSchema> : void,
|
||||
): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
return runWithStore(store, async () => {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user) return fail("Unauthorized");
|
||||
|
||||
setContextUserId(Number(session.user.id) as never);
|
||||
|
||||
if (opts.rateLimitKey && opts.rateLimitMax && opts.rateLimitWindowMs) {
|
||||
const rlKey = `${opts.rateLimitKey}:${ip}`;
|
||||
const result = await rateLimit(rlKey, opts.rateLimitMax, opts.rateLimitWindowMs);
|
||||
if (!result.ok) return fail(`Rate limited. Retry in ${result.retryAfter}s.`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
if (opts.schema) {
|
||||
const parsed = opts.schema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return fail("Validation failed", parsed.error.flatten().fieldErrors as Record<string, string[]>);
|
||||
}
|
||||
data = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
session: session as unknown as AppSession,
|
||||
requestId: getRequestId(),
|
||||
ip,
|
||||
} as { session: AppSession; requestId: RequestId; ip: IpAddress } & (TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
if (opts.schema) {
|
||||
(ctx as Record<string, unknown>).data = data as z.infer<NonNullable<TSchema>>;
|
||||
}
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
return handleActionError(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function handleActionError(error: unknown): ActionFailure {
|
||||
if (error instanceof ValidationError) {
|
||||
return fail(error.message, error.fieldErrors);
|
||||
}
|
||||
if (error instanceof UnauthorizedError || error instanceof ForbiddenError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof NotFoundError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof RateLimitError) {
|
||||
return fail(error.message);
|
||||
}
|
||||
if (error instanceof DatabaseError) {
|
||||
return fail("A database error occurred");
|
||||
}
|
||||
if (error instanceof Error && error.name === "ZodError") {
|
||||
return fail("Validation failed");
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return fail("Not found");
|
||||
}
|
||||
|
||||
console.error("[Action error]", error instanceof Error ? { message: error.message, name: error.name } : error);
|
||||
return fail("Internal server error");
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { env } from "@/env";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { DatabaseError } from "./errors";
|
||||
import { getRequestId } from "./request-context";
|
||||
|
||||
const globalForDb = globalThis as unknown as { _db?: DbService };
|
||||
|
||||
interface HealthStatus {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
poolSize: number;
|
||||
activeQueries: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DbService {
|
||||
private readonly client: PrismaClient;
|
||||
private queryCount = 0;
|
||||
private lastHealthCheck = 0;
|
||||
private healthCache: HealthStatus | null = null;
|
||||
private readonly healthTtlMs = 10_000;
|
||||
|
||||
constructor() {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? [{ emit: "event", level: "query" }, { emit: "event", level: "error" }] : [{ emit: "event", level: "error" }],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", { query: ev.query.slice(0, 200), durationMs: ev.duration, requestId: getRequestId() });
|
||||
});
|
||||
}
|
||||
|
||||
this.client.$on("error" as never, (e: unknown) => {
|
||||
const ev = e as { message: string };
|
||||
logger.error("DB error", { message: ev.message, requestId: getRequestId() });
|
||||
});
|
||||
}
|
||||
|
||||
get prisma(): PrismaClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
const now = Date.now();
|
||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
||||
return this.healthCache;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await this.client.$queryRaw`SELECT 1`;
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
this.healthCache = { ok: true, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0 };
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message = cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = { ok: false, latencyMs, poolSize: env.DATABASE_POOL_SIZE, activeQueries: 0, error: message };
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
}
|
||||
|
||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
||||
this.queryCount++;
|
||||
try {
|
||||
return await fn(this.client);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Transaction failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async rawQuery<T>(strings: TemplateStringsArray, ...values: unknown[]): Promise<T> {
|
||||
try {
|
||||
return await this.client.$queryRaw<T>(strings, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Raw query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async executeRaw(query: string, ...values: unknown[]): Promise<number> {
|
||||
try {
|
||||
return await this.client.$executeRawUnsafe(query, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Execute raw failed", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const db = globalForDb._db ?? (globalForDb._db = new DbService());
|
||||
if (env.NODE_ENV !== "production") globalForDb._db = db;
|
||||
@@ -0,0 +1,67 @@
|
||||
export class DomainError extends Error {
|
||||
public readonly status: number;
|
||||
|
||||
constructor(message: string, status: number = 500) {
|
||||
super(message);
|
||||
this.name = "DomainError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DomainError {
|
||||
constructor(entity: string, id?: number | string) {
|
||||
super(id ? `${entity} #${id} not found` : `${entity} not found`, 404);
|
||||
this.name = "NotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends DomainError {
|
||||
constructor(message: string = "Unauthorized") {
|
||||
super(message, 401);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends DomainError {
|
||||
constructor(message: string = "Forbidden") {
|
||||
super(message, 403);
|
||||
this.name = "ForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
public readonly fieldErrors: Record<string, string[]>;
|
||||
|
||||
constructor(fieldErrors: Record<string, string[]>) {
|
||||
super("Validation failed", 422);
|
||||
this.name = "ValidationError";
|
||||
this.fieldErrors = fieldErrors;
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends DomainError {
|
||||
public readonly retryAfter: number;
|
||||
|
||||
constructor(retryAfter: number) {
|
||||
super(`Rate limited. Try again in ${retryAfter}s.`, 429);
|
||||
this.name = "RateLimitError";
|
||||
this.retryAfter = retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends DomainError {
|
||||
constructor(message: string) {
|
||||
super(message, 409);
|
||||
this.name = "ConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class DatabaseError extends DomainError {
|
||||
public readonly cause: unknown;
|
||||
|
||||
constructor(message: string, cause?: unknown) {
|
||||
super(message, 500);
|
||||
this.name = "DatabaseError";
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export {
|
||||
adminAction,
|
||||
authAction,
|
||||
actionOk,
|
||||
actionError,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
|
||||
export {
|
||||
DbService,
|
||||
db,
|
||||
} from "./database";
|
||||
|
||||
export {
|
||||
safeRedirect,
|
||||
redirectSafe,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
canonicalize,
|
||||
sanitizeFilename,
|
||||
canonicalizeFormValue,
|
||||
canonicalizeFormData,
|
||||
extractClientIpAsync,
|
||||
} from "./security";
|
||||
|
||||
export {
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
RateLimitError,
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
} from "./errors";
|
||||
|
||||
export {
|
||||
getRequestStore,
|
||||
getRequestId,
|
||||
getClientIp,
|
||||
setContextUserId,
|
||||
elapsed,
|
||||
createStore,
|
||||
runWithStore,
|
||||
} from "./request-context";
|
||||
|
||||
export {
|
||||
chain,
|
||||
withRequestContext,
|
||||
protectAdminRoutes,
|
||||
addSecurityHeaders,
|
||||
} from "./middleware";
|
||||
|
||||
export {
|
||||
username,
|
||||
password,
|
||||
email,
|
||||
hexColor,
|
||||
slug,
|
||||
url,
|
||||
look,
|
||||
positiveInt,
|
||||
nonNegativeInt,
|
||||
bigIntString,
|
||||
idParam,
|
||||
pagination,
|
||||
boolString,
|
||||
buildSearchQuery,
|
||||
} from "./validation";
|
||||
|
||||
export type {
|
||||
UserId,
|
||||
RankId,
|
||||
IpAddress,
|
||||
RequestId,
|
||||
SessionUser,
|
||||
AppSession,
|
||||
ActionContext,
|
||||
AdminActionContext,
|
||||
ActionResult,
|
||||
ActionSuccess,
|
||||
ActionFailure,
|
||||
PaginatedQuery,
|
||||
PaginatedResult,
|
||||
PermissionSet,
|
||||
RequestContext,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { extractClientIpAsync, safeRedirect } from "./security";
|
||||
import { runWithStore, createStore, getRequestId } from "./request-context";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
type MiddlewareHandler = (req: NextRequest) => Promise<NextResponse | null>;
|
||||
|
||||
export function chain(...handlers: MiddlewareHandler[]): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
for (const handler of handlers) {
|
||||
const result = await handler(req);
|
||||
if (result) return result;
|
||||
}
|
||||
return NextResponse.next();
|
||||
};
|
||||
}
|
||||
|
||||
export function withRequestContext(handler: MiddlewareHandler): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return runWithStore(store, async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(req);
|
||||
const duration = Date.now() - start;
|
||||
logger.info("Request completed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
status: result?.status ?? 200,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
logger.error("Request failed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const safeUrl = new URL(safeRedirect(req.nextUrl.pathname, "/"), req.url);
|
||||
return NextResponse.redirect(safeUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
|
||||
const authToken = req.cookies.get("next-auth.session-token")?.value
|
||||
?? req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
||||
if (req.method === "OPTIONS") return null;
|
||||
|
||||
const response = NextResponse.next();
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: http:",
|
||||
"font-src 'self' https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "0");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { IpAddress, RequestId, UserId } from "./types";
|
||||
|
||||
export interface RequestStore {
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
const als = new AsyncLocalStorage<RequestStore>();
|
||||
|
||||
let counter = 0;
|
||||
|
||||
function generateRequestId(): RequestId {
|
||||
counter = (counter + 1) & 0xffff;
|
||||
return `${Date.now().toString(36)}-${counter.toString(36)}-${crypto.randomUUID().slice(0, 8)}` as RequestId;
|
||||
}
|
||||
|
||||
export function createStore(ip: IpAddress): RequestStore {
|
||||
return {
|
||||
requestId: generateRequestId(),
|
||||
ip,
|
||||
userId: null,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function runWithStore<T>(store: RequestStore, fn: () => T): T {
|
||||
return als.run(store, fn);
|
||||
}
|
||||
|
||||
export function getRequestStore(): RequestStore | null {
|
||||
return als.getStore() ?? null;
|
||||
}
|
||||
|
||||
export function getRequestId(): RequestId {
|
||||
return als.getStore()?.requestId ?? (generateRequestId());
|
||||
}
|
||||
|
||||
export function getClientIp(): IpAddress {
|
||||
return als.getStore()?.ip ?? ("0.0.0.0" as IpAddress);
|
||||
}
|
||||
|
||||
export function setContextUserId(userId: UserId): void {
|
||||
const store = als.getStore();
|
||||
if (store) store.userId = userId;
|
||||
}
|
||||
|
||||
export function elapsed(): number {
|
||||
const store = als.getStore();
|
||||
return store ? Date.now() - store.startedAt : 0;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { headers } from "next/headers";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import crypto from "node:crypto";
|
||||
import { env } from "@/env";
|
||||
import type { IpAddress } from "./types";
|
||||
|
||||
const CSRF_BYTES = 32;
|
||||
const CSRF_COOKIE = "__Host-csrf-token";
|
||||
const CSRF_COOKIE_MAX_AGE = 86400; // 24h
|
||||
|
||||
const ALLOWED_HOSTS: ReadonlySet<string> = new Set([
|
||||
env.APP_URL ? new URL(env.APP_URL).host : "",
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
].filter(Boolean));
|
||||
|
||||
const SAFE_REDIRECT_PATHS = new Set([
|
||||
"/login", "/register", "/forgot", "/reset", "/verify",
|
||||
"/banned", "/maintenance", "/", "/me", "/settings",
|
||||
]);
|
||||
|
||||
function isSafePath(path: string): boolean {
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (SAFE_REDIRECT_PATHS.has(path)) return true;
|
||||
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function safeRedirect(destination: string, fallback: string = "/"): string {
|
||||
try {
|
||||
const url = new URL(destination, env.APP_URL || "http://localhost:3000");
|
||||
if (ALLOWED_HOSTS.has(url.host)) return destination;
|
||||
if (url.host === "localhost" || url.host === "127.0.0.1") return destination;
|
||||
} catch {
|
||||
if (isSafePath(destination)) return destination;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function redirectSafe(destination: string, fallback: string = "/"): never {
|
||||
redirect(safeRedirect(destination, fallback));
|
||||
}
|
||||
|
||||
function csrfCookieOpts(): { name: string; value: string; httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } {
|
||||
return {
|
||||
name: CSRF_COOKIE,
|
||||
value: crypto.randomBytes(CSRF_BYTES).toString("hex"),
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
maxAge: CSRF_COOKIE_MAX_AGE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function setCsrfCookie(): Promise<string> {
|
||||
const c = await cookies();
|
||||
const existing = c.get(CSRF_COOKIE);
|
||||
if (existing?.value && existing.value.length === CSRF_BYTES * 2) return existing.value;
|
||||
const opts = csrfCookieOpts();
|
||||
c.set(opts.name, opts.value, opts);
|
||||
return opts.value;
|
||||
}
|
||||
|
||||
export async function validateCsrfToken(token: string): Promise<boolean> {
|
||||
if (!token || token.length !== CSRF_BYTES * 2) return false;
|
||||
try {
|
||||
const c = await cookies();
|
||||
const stored = c.get(CSRF_COOKIE)?.value;
|
||||
if (!stored || stored.length !== CSRF_BYTES * 2) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(token), Buffer.from(stored));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalize(input: string): string {
|
||||
return input.normalize("NFC").trim();
|
||||
}
|
||||
|
||||
const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/;
|
||||
|
||||
function removeControlChars(s: string): string {
|
||||
let result = "";
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const code = s.charCodeAt(i);
|
||||
if (code >= 32) result += s.charAt(i);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return removeControlChars(
|
||||
name
|
||||
.normalize("NFC")
|
||||
.replace(INVALID_FILENAME_CHARS, "")
|
||||
.replace(/\.\.(?:\/|$)/g, ""),
|
||||
)
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?: number): string {
|
||||
const s = canonicalize(String(value ?? ""));
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
export function canonicalizeFormData(formData: FormData, fields: Record<string, number | undefined>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, maxLen]) => [key, canonicalizeFormValue(formData.get(key), maxLen)]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
h.get("cf-connecting-ip") ??
|
||||
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
h.get("x-real-ip") ??
|
||||
"0.0.0.0"
|
||||
) as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
declare const Brand: unique symbol;
|
||||
type Branded<T, B extends string> = T & { readonly [Brand]: B };
|
||||
|
||||
export type UserId = Branded<number, "UserId">;
|
||||
export type RankId = Branded<number, "RankId">;
|
||||
export type IpAddress = Branded<string, "IpAddress">;
|
||||
export type RequestId = Branded<string, "RequestId">;
|
||||
|
||||
export interface SessionUser {
|
||||
id: UserId;
|
||||
username: string;
|
||||
rank: RankId;
|
||||
look: string;
|
||||
mail: string;
|
||||
}
|
||||
|
||||
export interface AppSession {
|
||||
user: SessionUser;
|
||||
expires: string;
|
||||
}
|
||||
|
||||
export interface ActionContext {
|
||||
session: AppSession;
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
}
|
||||
|
||||
export interface AdminActionContext extends ActionContext {
|
||||
permissions: PermissionSet;
|
||||
}
|
||||
|
||||
export interface ActionSuccess<T = Record<string, unknown>> {
|
||||
ok: true;
|
||||
data: T;
|
||||
}
|
||||
export interface ActionFailure {
|
||||
ok: false;
|
||||
error: string;
|
||||
fieldErrors?: Record<string, string[]>;
|
||||
}
|
||||
export type ActionResult<T = Record<string, unknown>> = ActionSuccess<T> | ActionFailure;
|
||||
|
||||
export interface PaginatedQuery {
|
||||
page: number;
|
||||
perPage: number;
|
||||
sort?: string;
|
||||
order?: "asc" | "desc";
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
rows: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
}
|
||||
|
||||
export interface PermissionSet {
|
||||
has(permission: string): boolean;
|
||||
hasAny(...permissions: string[]): boolean;
|
||||
hasAll(...permissions: string[]): boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface RequestContext {
|
||||
requestId: RequestId;
|
||||
ip: IpAddress;
|
||||
userId: UserId | null;
|
||||
startedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
||||
|
||||
export const username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(USERNAME_RE, "Username may only contain letters, numbers, hyphens, underscores, and dots")
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
|
||||
export const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
|
||||
export const slug = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(SLUG_RE, "Slug must be lowercase alphanumeric with hyphens only between characters")
|
||||
.refine((v) => !v.startsWith("-") && !v.endsWith("-"), "Slug must not start or end with a hyphen")
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const boolString = z
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
|
||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
||||
if (!search || !search.trim()) return undefined;
|
||||
const sanitized = search.normalize("NFC").trim().slice(0, 256);
|
||||
return fields.map((f) => ({ [f]: { contains: sanitized } }));
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { auth } from "./auth";
|
||||
import { sessionUserId } from "./auth/session-user";
|
||||
@@ -7,6 +6,7 @@ import { prisma } from "./prisma";
|
||||
import { logAuthorizationEvent } from "./admin/authorization-events";
|
||||
import { isDynamicSuperAdmin } from "./admin/authorization-policy";
|
||||
import { resolveAuthorizationState } from "./admin/rank-authority";
|
||||
import { redirectSafe } from "./foundation/security";
|
||||
|
||||
// Re-export PERMS from the standalone file (safe for client components)
|
||||
export { PERMS } from "./permission-slugs";
|
||||
@@ -121,13 +121,13 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
|
||||
export async function getAdminContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirect("/login");
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirect("/login");
|
||||
if (!state) redirectSafe("/login", "/login");
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
return {
|
||||
session: {
|
||||
@@ -175,15 +175,15 @@ export function canAccess(permissions: PermissionSet, slug: string, _rank?: numb
|
||||
export async function getModContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/login");
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirect("/login");
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirect("/");
|
||||
if (!state) redirectSafe("/", "/");
|
||||
const permissions = await loadUserPermissions(userId, state.actor.rank, state.highestRank);
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirect("/");
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/");
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
|
||||
@@ -2,17 +2,6 @@ import { ddosDetected } from "@/lib/services/alert";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* App-level abuse / DDoS guard — the web-tier-feasible half of AtomCMS's DDoS
|
||||
* protection. It can't touch iptables (that's a host-only optimisation), but it
|
||||
* DOES the actual mitigation a CMS needs: count requests per IP and, when one
|
||||
* floods past the threshold, add it to website_ip_blacklist (which the access
|
||||
* guard then enforces) and fire the existing ddosDetected() alert.
|
||||
*
|
||||
* OFF by default; staff enable + tune it via website_settings:
|
||||
* abuse_guard_enabled ("1"), abuse_guard_threshold (req, default 200),
|
||||
* abuse_guard_window_seconds (default 10).
|
||||
*/
|
||||
type Bucket = { count: number; resetAt: number };
|
||||
const buckets = new Map<string, Bucket>();
|
||||
const recentlyBlocked = new Set<string>();
|
||||
@@ -21,6 +10,32 @@ let blacklist = new Set<string>();
|
||||
let blacklistLoadedAt = 0;
|
||||
const BLACKLIST_TTL = 30_000;
|
||||
|
||||
const MAX_BUCKETS = 10_000;
|
||||
const MAX_RECENTLY_BLOCKED = 1_000;
|
||||
const CLEANUP_INTERVAL = 300_000;
|
||||
let lastCleanup = Date.now();
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now();
|
||||
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
||||
lastCleanup = now;
|
||||
|
||||
for (const [k, v] of buckets) {
|
||||
if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
if (buckets.size > MAX_BUCKETS) {
|
||||
const sorted = [...buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt);
|
||||
const toRemove = Math.floor(sorted.length * 0.2);
|
||||
const keys = sorted.slice(0, toRemove).map((entry) => entry[0]);
|
||||
for (const key of keys) buckets.delete(key);
|
||||
}
|
||||
|
||||
if (recentlyBlocked.size > MAX_RECENTLY_BLOCKED) {
|
||||
recentlyBlocked.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivate(ip: string): boolean {
|
||||
return (
|
||||
!ip ||
|
||||
@@ -32,7 +47,6 @@ function isPrivate(ip: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Cached blacklist lookup (refreshed every 30s — no DB hit per request). */
|
||||
export async function isIpBlacklisted(ip: string): Promise<boolean> {
|
||||
if (isPrivate(ip)) return false;
|
||||
const now = Date.now();
|
||||
@@ -48,7 +62,6 @@ export async function isIpBlacklisted(ip: string): Promise<boolean> {
|
||||
return blacklist.has(ip);
|
||||
}
|
||||
|
||||
/** Count a request; auto-blacklist + alert the IP if it floods (when enabled). */
|
||||
export async function recordRequest(ip: string): Promise<void> {
|
||||
if (isPrivate(ip)) return;
|
||||
if (!(await siteSettings.getBool("abuse_guard_enabled", false))) return;
|
||||
@@ -57,9 +70,8 @@ export async function recordRequest(ip: string): Promise<void> {
|
||||
const windowMs = (Number(await siteSettings.get("abuse_guard_window_seconds", "10")) || 10) * 1000;
|
||||
|
||||
const now = Date.now();
|
||||
if (buckets.size > 10_000) {
|
||||
for (const [k, v] of buckets) if (now >= v.resetAt) buckets.delete(k);
|
||||
}
|
||||
|
||||
cleanupStaleEntries();
|
||||
|
||||
const b = buckets.get(ip);
|
||||
if (!b || now >= b.resetAt) {
|
||||
@@ -75,7 +87,7 @@ export async function recordRequest(ip: string): Promise<void> {
|
||||
await prisma.websiteIpBlacklist.create({
|
||||
data: { ipAddress: ip, createdAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
blacklistLoadedAt = 0; // force a refresh so the block takes effect at once
|
||||
blacklistLoadedAt = 0;
|
||||
await ddosDetected(ip, b.count);
|
||||
} catch {
|
||||
/* ignore — alert/blacklist best-effort */
|
||||
|
||||
Reference in new issue
Block a user