This commit is contained in:
1 parent
e6d7f2280b
commit
8efd032cc6
71 files changed
+6796
-3751
No files matched your search
@@ -20,6 +20,7 @@ export async function requireStaff(): Promise<StaffUser> {
|
||||
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) redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
if (!(await rateLimit(`admin:${staff.id}:${ip}`, 30, 60_000)).ok)
|
||||
redirectSafe("/admin?error=ratelimit", "/admin");
|
||||
return staff;
|
||||
}
|
||||
@@ -16,7 +16,10 @@ type AdminHandler = (
|
||||
routeContext: RouteContext,
|
||||
) => Promise<Response> | Response;
|
||||
|
||||
export function withAdmin(options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number }, handler: AdminHandler) {
|
||||
export function withAdmin(
|
||||
options: { permission?: string; requireCsrf?: boolean; maxBodyBytes?: number },
|
||||
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") ?? "";
|
||||
@@ -30,7 +33,10 @@ export function withAdmin(options: { permission?: string; requireCsrf?: boolean;
|
||||
const contentLength = request.headers.get("content-length");
|
||||
const maxBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
|
||||
if (contentLength && Number(contentLength) > maxBytes) {
|
||||
return NextResponse.json({ ok: false, error: `Request body exceeds ${maxBytes} bytes` }, { status: 413 });
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: `Request body exceeds ${maxBytes} bytes` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,23 @@ 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 {
|
||||
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";
|
||||
|
||||
@@ -34,9 +49,7 @@ 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> => {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
@@ -83,7 +96,8 @@ export function adminAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
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>);
|
||||
} as AdminActionContext &
|
||||
(TSchema extends z.ZodType ? { data: z.infer<TSchema> } : Record<string, never>);
|
||||
|
||||
return await handler(ctx);
|
||||
} catch (error) {
|
||||
@@ -102,11 +116,13 @@ interface AuthOpts<TSchema extends z.ZodType | undefined> {
|
||||
|
||||
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>,
|
||||
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> => {
|
||||
return async (input: TSchema extends z.ZodType ? z.input<TSchema> : void): Promise<ActionResult> => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
|
||||
@@ -136,7 +152,9 @@ export function authAction<TSchema extends z.ZodType | undefined = undefined>(
|
||||
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>);
|
||||
} 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>>;
|
||||
@@ -171,12 +189,16 @@ export function handleActionError(error: unknown): ActionFailure {
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" || error.name === "PrismaClientKnownRequestError") &&
|
||||
(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);
|
||||
console.error(
|
||||
"[Action error]",
|
||||
error instanceof Error ? { message: error.message, name: error.name } : error,
|
||||
);
|
||||
return fail("Internal server error");
|
||||
}
|
||||
@@ -38,13 +38,23 @@ export class DbService {
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log: env.NODE_ENV === "development" ? [{ emit: "event", level: "query" }, { emit: "event", level: "error" }] : [{ emit: "event", level: "error" }],
|
||||
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() });
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +84,13 @@ export class DbService {
|
||||
} 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.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
@@ -89,7 +105,9 @@ export class DbService {
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(fn: (tx: Omit<PrismaClient, "$connect" | "$disconnect" | "$on" | "$use" | "$extends">) => Promise<T>): Promise<T> {
|
||||
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) {
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
export {
|
||||
adminAction,
|
||||
authAction,
|
||||
actionOk,
|
||||
actionError,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
export { adminAction, authAction, actionOk, actionError, handleActionError } from "./action";
|
||||
|
||||
export {
|
||||
DbService,
|
||||
db,
|
||||
} from "./database";
|
||||
export { DbService, db } from "./database";
|
||||
|
||||
export {
|
||||
safeRedirect,
|
||||
@@ -44,12 +35,7 @@ export {
|
||||
runWithStore,
|
||||
} from "./request-context";
|
||||
|
||||
export {
|
||||
chain,
|
||||
withRequestContext,
|
||||
protectAdminRoutes,
|
||||
addSecurityHeaders,
|
||||
} from "./middleware";
|
||||
export { chain, withRequestContext, protectAdminRoutes, addSecurityHeaders } from "./middleware";
|
||||
|
||||
export {
|
||||
username,
|
||||
|
||||
@@ -53,8 +53,9 @@ export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
|
||||
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;
|
||||
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);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function getRequestStore(): RequestStore | null {
|
||||
}
|
||||
|
||||
export function getRequestId(): RequestId {
|
||||
return als.getStore()?.requestId ?? (generateRequestId());
|
||||
return als.getStore()?.requestId ?? generateRequestId();
|
||||
}
|
||||
|
||||
export function getClientIp(): IpAddress {
|
||||
|
||||
@@ -9,15 +9,21 @@ 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 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",
|
||||
"/login",
|
||||
"/register",
|
||||
"/forgot",
|
||||
"/reset",
|
||||
"/verify",
|
||||
"/banned",
|
||||
"/maintenance",
|
||||
"/",
|
||||
"/me",
|
||||
"/settings",
|
||||
]);
|
||||
|
||||
function isSafePath(path: string): boolean {
|
||||
@@ -42,7 +48,15 @@ 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 } {
|
||||
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"),
|
||||
@@ -106,7 +120,10 @@ export function canonicalizeFormValue(value: FormDataEntryValue | null, maxLen?:
|
||||
return maxLen ? s.slice(0, maxLen) : s;
|
||||
}
|
||||
|
||||
export function canonicalizeFormData(formData: FormData, fields: Record<string, number | undefined>): Record<string, string> {
|
||||
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)]),
|
||||
);
|
||||
@@ -115,13 +132,11 @@ export function canonicalizeFormData(formData: FormData, fields: Record<string,
|
||||
export async function extractClientIpAsync(): Promise<IpAddress> {
|
||||
try {
|
||||
const h = await headers();
|
||||
return (
|
||||
h.get("x-real-client-ip") ??
|
||||
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;
|
||||
"0.0.0.0") as IpAddress;
|
||||
} catch {
|
||||
return "0.0.0.0" as IpAddress;
|
||||
}
|
||||
|
||||
@@ -39,10 +39,7 @@ export const slug = z
|
||||
.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 url = z.string().url("Invalid URL").max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
@@ -50,25 +47,13 @@ export const look = z
|
||||
.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 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 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 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 idParam = z.string().regex(/^\d+$/, "ID must be numeric").transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
|
||||
@@ -2,8 +2,7 @@ import { ZodError } from "zod";
|
||||
import { handleActionError as foundationHandle } from "@/lib/foundation/action";
|
||||
|
||||
export type ActionResult<T = Record<string, unknown>> =
|
||||
| { ok: true; data?: T }
|
||||
| { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
{ ok: true; data?: T } | { ok: false; error: string; fieldErrors?: Record<string, string[]> };
|
||||
|
||||
export function actionOk<T = Record<string, unknown>>(data?: T): ActionResult<T> {
|
||||
return { ok: true, data: data ?? ({} as T) };
|
||||
|
||||
@@ -24,12 +24,12 @@ function cleanupStaleEntries(): void {
|
||||
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 (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();
|
||||
|
||||
@@ -56,13 +56,21 @@ export function deriveAdminPalette(
|
||||
...defaults,
|
||||
...overrides,
|
||||
accent,
|
||||
accentText: readableColor(accent, [overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface, overrides.surfaceElevated ?? defaults.surfaceElevated]),
|
||||
accentText: readableColor(accent, [
|
||||
overrides.canvas ?? defaults.canvas,
|
||||
overrides.surface ?? defaults.surface,
|
||||
overrides.surfaceElevated ?? defaults.surfaceElevated,
|
||||
]),
|
||||
accentForeground: readableColor("#ffffff", [accent]),
|
||||
success: overrides.success ?? defaults.success,
|
||||
warning: overrides.warning ?? defaults.warning,
|
||||
error: overrides.error ?? defaults.error,
|
||||
info: overrides.info ?? defaults.info,
|
||||
sidebarText: readableColor(overrides.text ?? defaults.text, [overrides.sidebar ?? defaults.sidebar, overrides.canvas ?? defaults.canvas, overrides.surface ?? defaults.surface]),
|
||||
sidebarText: readableColor(overrides.text ?? defaults.text, [
|
||||
overrides.sidebar ?? defaults.sidebar,
|
||||
overrides.canvas ?? defaults.canvas,
|
||||
overrides.surface ?? defaults.surface,
|
||||
]),
|
||||
overlay: defaults.overlay,
|
||||
focusRing: accent,
|
||||
};
|
||||
|
||||
+11
-2
@@ -1,4 +1,9 @@
|
||||
import { deriveAdminPalette, derivePublicForegrounds, readableColor, type AdminPalette } from "@/lib/theme-contrast";
|
||||
import {
|
||||
deriveAdminPalette,
|
||||
derivePublicForegrounds,
|
||||
readableColor,
|
||||
type AdminPalette,
|
||||
} from "@/lib/theme-contrast";
|
||||
import type { ThemePalette } from "@/lib/theme-presets";
|
||||
|
||||
const CSS_VARIABLES: Record<keyof ThemePalette, string> = {
|
||||
@@ -62,7 +67,11 @@ export function adminPaletteCss(admin: AdminPalette): string {
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
export function themePaletteCss(selector: string, palette: ThemePalette, adminOverrides?: Partial<AdminPalette>): string {
|
||||
export function themePaletteCss(
|
||||
selector: string,
|
||||
palette: ThemePalette,
|
||||
adminOverrides?: Partial<AdminPalette>,
|
||||
): string {
|
||||
const semantic = derivePublicForegrounds(palette);
|
||||
const admin = deriveAdminPalette(palette, adminOverrides);
|
||||
const declarations = Object.entries(CSS_VARIABLES).map(
|
||||
|
||||
@@ -27,15 +27,12 @@ const EXTRA_KEYS = [
|
||||
"theme_preset",
|
||||
];
|
||||
|
||||
const ALL_KEYS: string[] = [
|
||||
...THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]),
|
||||
...EXTRA_KEYS,
|
||||
];
|
||||
const ALL_KEYS: string[] = [...THEME_COLOR_KEYS.flatMap((k) => [k, `${k}_dark`]), ...EXTRA_KEYS];
|
||||
|
||||
function fallbackFor(key: string): string {
|
||||
const preset = PRESETS["Atom (golden)"];
|
||||
if (key.endsWith("_dark")) {
|
||||
const base = key.slice(0, -("_dark".length)) as ThemeColorKey;
|
||||
const base = key.slice(0, -"_dark".length) as ThemeColorKey;
|
||||
return preset.dark[base] ?? ""; // eslint-disable-line security/detect-object-injection -- key derived from internal THEME_COLOR_KEYS
|
||||
}
|
||||
if (key in preset.light) {
|
||||
@@ -87,7 +84,11 @@ async function persist(themes: CustomTheme[]): Promise<void> {
|
||||
siteSettings.reload();
|
||||
}
|
||||
|
||||
export async function upsertCustomTheme(name: string, settings: Record<string, string>, id?: string): Promise<CustomTheme> {
|
||||
export async function upsertCustomTheme(
|
||||
name: string,
|
||||
settings: Record<string, string>,
|
||||
id?: string,
|
||||
): Promise<CustomTheme> {
|
||||
const themes = await listCustomThemes();
|
||||
const trimmed = name.trim() || "Untitled theme";
|
||||
if (id) {
|
||||
|
||||
Reference in new issue
Block a user