style: format code biome
Local Build and Deploy / deploy (push) Failing after 46s

This commit is contained in:
openhands committed 2026-07-13 21:57:41 +02:00
1 parent 8efd032cc6
commit df38dccbf1
735 files changed
+128321 -120870

No files matched your search

+212 -159
View File
@@ -1,204 +1,257 @@
import type { z } from "zod";
import { logAuthorizationEvent } from "@/lib/admin/authorization-events";
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";
import {
DatabaseError,
ForbiddenError,
NotFoundError,
RateLimitError,
UnauthorizedError,
ValidationError,
} from "./errors";
import {
createStore,
getRequestId,
runWithStore,
setContextUserId,
} from "./request-context";
import { extractClientIpAsync } from "./security";
import type {
ActionFailure,
ActionResult,
ActionSuccess,
AdminActionContext,
AppSession,
IpAddress,
RequestId,
} from "./types";
function ok<T = Record<string, unknown>>(data?: T): ActionSuccess<T> {
return { ok: true, data: (data ?? {}) as T } as unknown as 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 };
function fail(
error: string,
fieldErrors?: Record<string, string[]>,
): ActionFailure {
return { ok: false, error, fieldErrors };
}
export { ok as actionOk, fail as actionError };
export { fail as actionError, ok as actionOk };
interface AdminOpts<TSchema extends z.ZodType | undefined> {
permission?: string;
schema?: TSchema;
rateLimitKey?: string;
rateLimitMax?: number;
rateLimitWindowMs?: number;
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>),
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>,
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 async (
input: TSchema extends z.ZodType ? z.input<TSchema> : undefined,
): Promise<ActionResult> => {
const ip = await extractClientIpAsync();
const store = createStore(ip);
return runWithStore(store, async () => {
try {
const apiCtx = await getApiAdminContext();
if (!apiCtx) return fail("Unauthorized");
return runWithStore(store, async () => {
try {
const apiCtx = await getApiAdminContext();
if (!apiCtx) return fail("Unauthorized");
setContextUserId(Number(apiCtx.session.user.id) as never);
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.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.`);
}
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;
}
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>);
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);
}
});
};
return await handler(ctx);
} catch (error) {
return handleActionError(error);
}
});
};
}
interface AuthOpts<TSchema extends z.ZodType | undefined> {
schema?: TSchema;
rateLimitKey?: string;
rateLimitMax?: number;
rateLimitWindowMs?: number;
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>,
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 async (
input: TSchema extends z.ZodType ? z.input<TSchema> : undefined,
): 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");
return runWithStore(store, async () => {
try {
const session = await auth();
if (!session?.user) return fail("Unauthorized");
setContextUserId(Number(session.user.id) as never);
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.`);
}
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;
}
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>);
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>>;
}
if (opts.schema) {
(ctx as Record<string, unknown>).data = data as z.infer<
NonNullable<TSchema>
>;
}
return await handler(ctx);
} catch (error) {
return handleActionError(error);
}
});
};
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");
}
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");
console.error(
"[Action error]",
error instanceof Error
? { message: error.message, name: error.name }
: error,
);
return fail("Internal server error");
}
+130 -114
View File
@@ -1,6 +1,6 @@
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
import { PrismaClient } from "@/generated/prisma/client";
import { env } from "@/env";
import { PrismaClient } from "@/generated/prisma/client";
import { logger } from "@/lib/logger";
import { DatabaseError } from "./errors";
import { getRequestId } from "./request-context";
@@ -8,133 +8,149 @@ 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;
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;
private readonly client: PrismaClient;
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,
});
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" }],
});
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(),
});
});
}
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() });
});
}
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;
}
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;
}
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;
}
}
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 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 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 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);
}
}
/**
* Execute a raw SQL string with parameterized ? placeholders.
* Named "Unsafe" because the caller is responsible for using ? placeholders
* and never interpolating user input directly into the query string.
*/
async executeRawUnsafe(query: string, ...values: unknown[]): Promise<number> {
try {
return await this.client.$executeRawUnsafe(query, ...values);
} catch (cause) {
throw new DatabaseError("Execute raw failed", cause);
}
}
/**
* Execute a raw SQL string with parameterized ? placeholders.
* Named "Unsafe" because the caller is responsible for using ? placeholders
* and never interpolating user input directly into the query string.
*/
async executeRawUnsafe(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());
+40 -40
View File
@@ -1,67 +1,67 @@
export class DomainError extends Error {
public readonly status: number;
public readonly status: number;
constructor(message: string, status: number = 500) {
super(message);
this.name = "DomainError";
this.status = status;
}
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";
}
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";
}
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";
}
constructor(message: string = "Forbidden") {
super(message, 403);
this.name = "ForbiddenError";
}
}
export class ValidationError extends DomainError {
public readonly fieldErrors: Record<string, string[]>;
public readonly fieldErrors: Record<string, string[]>;
constructor(fieldErrors: Record<string, string[]>) {
super("Validation failed", 422);
this.name = "ValidationError";
this.fieldErrors = fieldErrors;
}
constructor(fieldErrors: Record<string, string[]>) {
super("Validation failed", 422);
this.name = "ValidationError";
this.fieldErrors = fieldErrors;
}
}
export class RateLimitError extends DomainError {
public readonly retryAfter: number;
public readonly retryAfter: number;
constructor(retryAfter: number) {
super(`Rate limited. Try again in ${retryAfter}s.`, 429);
this.name = "RateLimitError";
this.retryAfter = retryAfter;
}
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";
}
constructor(message: string) {
super(message, 409);
this.name = "ConflictError";
}
}
export class DatabaseError extends DomainError {
public readonly cause: unknown;
public readonly cause: unknown;
constructor(message: string, cause?: unknown) {
super(message, 500);
this.name = "DatabaseError";
this.cause = cause;
}
constructor(message: string, cause?: unknown) {
super(message, 500);
this.name = "DatabaseError";
this.cause = cause;
}
}
+69 -63
View File
@@ -1,73 +1,79 @@
export { adminAction, authAction, actionOk, actionError, handleActionError } from "./action";
export {
actionError,
actionOk,
adminAction,
authAction,
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,
ConflictError,
DatabaseError,
DomainError,
ForbiddenError,
NotFoundError,
RateLimitError,
UnauthorizedError,
ValidationError,
} from "./errors";
export {
addSecurityHeaders,
chain,
protectAdminRoutes,
withRequestContext,
} from "./middleware";
export {
getRequestStore,
getRequestId,
getClientIp,
setContextUserId,
elapsed,
createStore,
runWithStore,
createStore,
elapsed,
getClientIp,
getRequestId,
getRequestStore,
runWithStore,
setContextUserId,
} 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";
canonicalize,
canonicalizeFormData,
canonicalizeFormValue,
extractClientIpAsync,
redirectSafe,
safeRedirect,
sanitizeFilename,
setCsrfCookie,
validateCsrfToken,
} from "./security";
export type {
UserId,
RankId,
IpAddress,
RequestId,
SessionUser,
AppSession,
ActionContext,
AdminActionContext,
ActionResult,
ActionSuccess,
ActionFailure,
PaginatedQuery,
PaginatedResult,
PermissionSet,
RequestContext,
ActionContext,
ActionFailure,
ActionResult,
ActionSuccess,
AdminActionContext,
AppSession,
IpAddress,
PaginatedQuery,
PaginatedResult,
PermissionSet,
RankId,
RequestContext,
RequestId,
SessionUser,
UserId,
} from "./types";
export {
bigIntString,
boolString,
buildSearchQuery,
email,
hexColor,
idParam,
look,
nonNegativeInt,
pagination,
password,
positiveInt,
slug,
url,
username,
} from "./validation";
+81 -73
View File
@@ -1,94 +1,102 @@
import { NextResponse, type NextRequest } from "next/server";
import { extractClientIpAsync, safeRedirect } from "./security";
import { runWithStore, createStore, getRequestId } from "./request-context";
import { type NextRequest, NextResponse } from "next/server";
import { logger } from "@/lib/logger";
import { createStore, getRequestId, runWithStore } from "./request-context";
import { extractClientIpAsync, safeRedirect } from "./security";
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();
};
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 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;
const { pathname } = req.nextUrl;
if (!pathname.startsWith("/admin")) return 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);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
if (!authToken) {
const loginUrl = new URL("/login", req.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
return null;
return null;
}
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
if (req.method === "OPTIONS") return 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("; ");
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=()");
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;
return response;
}
+20 -20
View File
@@ -2,10 +2,10 @@ 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;
requestId: RequestId;
ip: IpAddress;
userId: UserId | null;
startedAt: number;
}
const als = new AsyncLocalStorage<RequestStore>();
@@ -13,41 +13,41 @@ 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;
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(),
};
return {
requestId: generateRequestId(),
ip,
userId: null,
startedAt: Date.now(),
};
}
export function runWithStore<T>(store: RequestStore, fn: () => T): T {
return als.run(store, fn);
return als.run(store, fn);
}
export function getRequestStore(): RequestStore | null {
return als.getStore() ?? null;
return als.getStore() ?? null;
}
export function getRequestId(): RequestId {
return als.getStore()?.requestId ?? generateRequestId();
return als.getStore()?.requestId ?? generateRequestId();
}
export function getClientIp(): IpAddress {
return als.getStore()?.ip ?? ("0.0.0.0" as 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;
const store = als.getStore();
if (store) store.userId = userId;
}
export function elapsed(): number {
const store = als.getStore();
return store ? Date.now() - store.startedAt : 0;
const store = als.getStore();
return store ? Date.now() - store.startedAt : 0;
}
+110 -93
View File
@@ -1,7 +1,6 @@
import { headers } from "next/headers";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import crypto from "node:crypto";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { env } from "@/env";
import type { IpAddress } from "./types";
@@ -10,134 +9,152 @@ 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),
[
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 {
if (!path.startsWith("/")) return false;
if (SAFE_REDIRECT_PATHS.has(path)) return true;
if (path.startsWith("/admin/") || path.startsWith("/api/")) return true;
return false;
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 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));
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;
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,
};
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;
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;
}
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();
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;
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);
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 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>,
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)]),
);
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;
}
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;
}
}
+37 -35
View File
@@ -7,65 +7,67 @@ 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;
id: UserId;
username: string;
rank: RankId;
look: string;
mail: string;
}
export interface AppSession {
user: SessionUser;
expires: string;
user: SessionUser;
expires: string;
}
export interface ActionContext {
session: AppSession;
requestId: RequestId;
ip: IpAddress;
session: AppSession;
requestId: RequestId;
ip: IpAddress;
}
export interface AdminActionContext extends ActionContext {
permissions: PermissionSet;
permissions: PermissionSet;
}
export interface ActionSuccess<T = Record<string, unknown>> {
ok: true;
data?: T;
ok: true;
data?: T;
}
export interface ActionFailure {
ok: false;
error: string;
fieldErrors?: Record<string, string[]>;
ok: false;
error: string;
fieldErrors?: Record<string, string[]>;
}
export type ActionResult<T = Record<string, unknown>> = ActionSuccess<T> | ActionFailure;
export type ActionResult<T = Record<string, unknown>> =
| ActionSuccess<T>
| ActionFailure;
export interface PaginatedQuery {
page: number;
perPage: number;
sort?: string;
order?: "asc" | "desc";
search?: string;
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;
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;
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;
requestId: RequestId;
ip: IpAddress;
userId: UserId | null;
startedAt: number;
}
+68 -44
View File
@@ -3,73 +3,97 @@ 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"));
.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");
.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());
.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)");
.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"));
.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 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();
.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 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),
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(),
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());
.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 } }));
if (!search?.trim()) return undefined;
const sanitized = search.normalize("NFC").trim().slice(0, 256);
return fields.map((f) => ({ [f]: { contains: sanitized } }));
}