This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+212
-159
@@ -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
@@ -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());
|
||||
|
||||
@@ -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
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 } }));
|
||||
}
|
||||
Reference in new issue
Block a user