Remove 19 unused source files (no importers anywhere in src/):
- src/actions/admin-permissions.ts, admin-radio.ts, admin-user-edit.ts,
admin-users.ts (functionality lives in @/actions/users and
@/actions/permissions)
- src/components/admin/confirm-action.tsx, page-header.tsx
- src/components/motion-elements.tsx
- src/lib/format-date.ts
- src/lib/catalog-categories/* (incl. re-export barrel)
- src/lib/foundation/{index,database,middleware,validation}.ts (errors/action
kept, still imported directly)
- src/lib/services/imager/{avatar-renderer,memory-cache}.ts
- src/lib/services/nitro-assets.ts
Update admin-operations-contract test to drop the two removed action-file
gates (their permission coverage already exists in users.ts/permissions.ts).
Add knip.json for repeatable dead-code audits.
Verified: tsc --noEmit clean, next build succeeds, full test suite green
(301/301).
This commit is contained in:
1 parent
60eb45be73
commit
3c9c1311ec
23 files changed
+586
-2158
No files matched your search
@@ -1,156 +0,0 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { env } from "@/env";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { DatabaseError } from "./errors";
|
||||
import { getRequestId } from "./request-context";
|
||||
|
||||
const globalForDb = globalThis as unknown as { _db?: DbService };
|
||||
|
||||
interface HealthStatus {
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
poolSize: number;
|
||||
activeQueries: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class DbService {
|
||||
private readonly client: PrismaClient;
|
||||
private lastHealthCheck = 0;
|
||||
private healthCache: HealthStatus | null = null;
|
||||
private readonly healthTtlMs = 10_000;
|
||||
|
||||
constructor() {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
const adapter = new PrismaMariaDb({
|
||||
host: url.hostname,
|
||||
port: Number(url.port) || 3306,
|
||||
user: decodeURIComponent(url.username),
|
||||
password: decodeURIComponent(url.password),
|
||||
database: url.pathname.replace(/^\//, ""),
|
||||
connectionLimit: env.DATABASE_POOL_SIZE,
|
||||
connectTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
acquireTimeout: env.DATABASE_CONNECT_TIMEOUT_MS,
|
||||
idleTimeout: env.DATABASE_IDLE_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
this.client = new PrismaClient({
|
||||
adapter,
|
||||
log:
|
||||
env.NODE_ENV === "development"
|
||||
? [
|
||||
{ emit: "event", level: "query" },
|
||||
{ emit: "event", level: "error" },
|
||||
]
|
||||
: [{ emit: "event", level: "error" }],
|
||||
});
|
||||
|
||||
if (env.NODE_ENV === "development") {
|
||||
this.client.$on("query" as never, (e: unknown) => {
|
||||
const ev = e as { query: string; duration: number };
|
||||
logger.debug("DB query", {
|
||||
query: ev.query.slice(0, 200),
|
||||
durationMs: ev.duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.client.$on("error" as never, (e: unknown) => {
|
||||
const ev = e as { message: string };
|
||||
logger.error("DB error", {
|
||||
message: ev.message,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get prisma(): PrismaClient {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async health(): Promise<HealthStatus> {
|
||||
const now = Date.now();
|
||||
if (this.healthCache && now - this.lastHealthCheck < this.healthTtlMs) {
|
||||
return this.healthCache;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
await this.client.$queryRaw`SELECT 1`;
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
this.healthCache = {
|
||||
ok: true,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
} catch (cause) {
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const message =
|
||||
cause instanceof Error ? cause.message : "Unknown database error";
|
||||
this.healthCache = {
|
||||
ok: false,
|
||||
latencyMs,
|
||||
poolSize: env.DATABASE_POOL_SIZE,
|
||||
activeQueries: 0,
|
||||
error: message,
|
||||
};
|
||||
this.lastHealthCheck = now;
|
||||
return this.healthCache;
|
||||
}
|
||||
}
|
||||
|
||||
async execute<T>(fn: (client: PrismaClient) => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn(this.client);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
fn: (
|
||||
tx: Omit<
|
||||
PrismaClient,
|
||||
"$connect" | "$disconnect" | "$on" | "$use" | "$extends"
|
||||
>,
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$transaction(fn);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Transaction failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
async rawQuery<T>(
|
||||
strings: TemplateStringsArray,
|
||||
...values: unknown[]
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await this.client.$queryRaw<T>(strings, ...values);
|
||||
} catch (cause) {
|
||||
throw new DatabaseError("Raw query failed", cause);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ?? new DbService();
|
||||
if (env.NODE_ENV !== "production") globalForDb._db = db;
|
||||
@@ -1,79 +0,0 @@
|
||||
export {
|
||||
actionError,
|
||||
actionOk,
|
||||
adminAction,
|
||||
authAction,
|
||||
handleActionError,
|
||||
} from "./action";
|
||||
|
||||
export { DbService, db } from "./database";
|
||||
export {
|
||||
ConflictError,
|
||||
DatabaseError,
|
||||
DomainError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
UnauthorizedError,
|
||||
ValidationError,
|
||||
} from "./errors";
|
||||
export {
|
||||
addSecurityHeaders,
|
||||
chain,
|
||||
protectAdminRoutes,
|
||||
withRequestContext,
|
||||
} from "./middleware";
|
||||
|
||||
export {
|
||||
createStore,
|
||||
elapsed,
|
||||
getClientIp,
|
||||
getRequestId,
|
||||
getRequestStore,
|
||||
runWithStore,
|
||||
setContextUserId,
|
||||
} from "./request-context";
|
||||
export {
|
||||
canonicalize,
|
||||
canonicalizeFormData,
|
||||
canonicalizeFormValue,
|
||||
extractClientIpAsync,
|
||||
redirectSafe,
|
||||
safeRedirect,
|
||||
sanitizeFilename,
|
||||
setCsrfCookie,
|
||||
validateCsrfToken,
|
||||
} from "./security";
|
||||
export type {
|
||||
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,102 +0,0 @@
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
export function withRequestContext(
|
||||
handler: MiddlewareHandler,
|
||||
): MiddlewareHandler {
|
||||
return async (req: NextRequest) => {
|
||||
const ip = await extractClientIpAsync();
|
||||
const store = createStore(ip);
|
||||
return runWithStore(store, async () => {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await handler(req);
|
||||
const duration = Date.now() - start;
|
||||
logger.info("Request completed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
status: result?.status ?? 200,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - start;
|
||||
logger.error("Request failed", {
|
||||
method: req.method,
|
||||
path: req.nextUrl.pathname,
|
||||
durationMs: duration,
|
||||
requestId: getRequestId(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
const safeUrl = new URL(
|
||||
safeRedirect(req.nextUrl.pathname, "/"),
|
||||
req.url,
|
||||
);
|
||||
return NextResponse.redirect(safeUrl);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function protectAdminRoutes(req: NextRequest): NextResponse | null {
|
||||
const { pathname } = req.nextUrl;
|
||||
|
||||
if (!pathname.startsWith("/admin")) return null;
|
||||
|
||||
const authToken =
|
||||
req.cookies.get("next-auth.session-token")?.value ??
|
||||
req.cookies.get("__Secure-next-auth.session-token")?.value;
|
||||
|
||||
if (!authToken) {
|
||||
const loginUrl = new URL("/login", req.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function addSecurityHeaders(req: NextRequest): NextResponse | null {
|
||||
if (req.method === "OPTIONS") return null;
|
||||
|
||||
const response = NextResponse.next();
|
||||
const csp = [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-eval' 'unsafe-inline'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: https: http:",
|
||||
"font-src 'self' https:",
|
||||
"connect-src 'self' https: wss:",
|
||||
"frame-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("X-Frame-Options", "DENY");
|
||||
response.headers.set("X-XSS-Protection", "0");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9\-_.]+$/;
|
||||
|
||||
export const username = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Username is required")
|
||||
.max(32, "Username must be at most 32 characters")
|
||||
.regex(
|
||||
USERNAME_RE,
|
||||
"Username may only contain letters, numbers, hyphens, underscores, and dots",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const password = z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.max(128, "Password must be at most 128 characters");
|
||||
|
||||
export const email = z
|
||||
.string()
|
||||
.trim()
|
||||
.email("Invalid email address")
|
||||
.max(255, "Email must be at most 255 characters")
|
||||
.transform((v) => v.normalize("NFC").toLowerCase());
|
||||
|
||||
const HEX_COLOR_RE = /^#[0-9a-f]{6}$/i;
|
||||
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const hexColor = z
|
||||
.string()
|
||||
.length(7, "Must be exactly 7 characters (e.g. #ff0000)")
|
||||
.regex(HEX_COLOR_RE, "Must be a valid hex color (e.g. #ff0000)");
|
||||
|
||||
export const slug = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Slug is required")
|
||||
.max(64, "Slug must be at most 64 characters")
|
||||
.regex(
|
||||
SLUG_RE,
|
||||
"Slug must be lowercase alphanumeric with hyphens only between characters",
|
||||
)
|
||||
.refine(
|
||||
(v) => !v.startsWith("-") && !v.endsWith("-"),
|
||||
"Slug must not start or end with a hyphen",
|
||||
)
|
||||
.transform((v) => v.normalize("NFC"));
|
||||
|
||||
export const url = z
|
||||
.string()
|
||||
.url("Invalid URL")
|
||||
.max(2048, "URL must be at most 2048 characters");
|
||||
|
||||
export const look = z
|
||||
.string()
|
||||
.max(512, "Look string must be at most 512 characters")
|
||||
.regex(/^[a-zA-Z0-9.\-_\s]+$/, "Invalid look format")
|
||||
.optional();
|
||||
|
||||
export const positiveInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.positive("Must be positive");
|
||||
|
||||
export const nonNegativeInt = z
|
||||
.number()
|
||||
.int("Must be a whole number")
|
||||
.nonnegative("Must not be negative");
|
||||
|
||||
export const bigIntString = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "Must be a numeric string")
|
||||
.transform(BigInt);
|
||||
|
||||
export const idParam = z
|
||||
.string()
|
||||
.regex(/^\d+$/, "ID must be numeric")
|
||||
.transform(Number);
|
||||
|
||||
export const pagination = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
perPage: z.coerce.number().int().min(1).max(250).default(50),
|
||||
sort: z.string().optional(),
|
||||
order: z.enum(["asc", "desc"]).optional(),
|
||||
search: z.string().max(256).optional(),
|
||||
});
|
||||
|
||||
export const boolString = z
|
||||
.string()
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
.or(z.boolean());
|
||||
|
||||
export function buildSearchQuery(fields: string[], search: string | undefined) {
|
||||
if (!search?.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