Add EpicNext CMS foundation layer and fix critical security gaps
Local Build and Deploy / deploy (push) Successful in 1m1s
Local Build and Deploy / deploy (push) Successful in 1m1s
- Create src/lib/foundation/ (860 LOC, 9 files): typed action wrappers, DbService with health checks, CSRF validation, safe redirects, AsyncLocalStorage request tracing, branded types, reusable Zod schemas - Migrate moderation.ts and user-settings.ts to foundation patterns - Fix abuse-guard.ts: bound in-memory Maps with LRU eviction (was unbounded) - Fix access-guard.ts: separate try/catch per check, log degradation instead of blanket fail-open - Replace raw redirect() calls with safeRedirect() in guard.ts and permissions.ts to prevent open-redirect attacks - Add CSRF validation to api-handler.ts for mutating methods - Add canonicalizeFormData() utility for FormData input sanitization
This commit is contained in:
1 parent
8bf1aa2fa7
commit
f6ad030c5b
16 files changed
+1012
-96
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { env } from "@/env";
|
||||
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 queryCount = 0;
|
||||
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> {
|
||||
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 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 executeRaw(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());
|
||||
if (env.NODE_ENV !== "production") globalForDb._db = db;
|
||||
Reference in new issue
Block a user