style: format code biome
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");
|
||||
}
|
||||
Reference in new issue
Block a user