chore: CSP script nonces, deploy health check, dead-code cleanup
Add per-request CSP nonces (drop script unsafe-inline), post-deploy /api/health gate, bump next-auth to beta.32, and remove unused motion/cache/permission helpers. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
c46dadeda4
commit
9b47668fe9
20 files changed
+141
-358
No files matched your search
@@ -32,29 +32,6 @@ export function calcPagination(total: number, page: number, perPage: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Generate CSV content from rows */
|
||||
export function generateCsv(
|
||||
rows: Record<string, unknown>[],
|
||||
columns: { key: string; label: string }[],
|
||||
): string {
|
||||
const BOM = "\uFEFF";
|
||||
const header = columns.map((c) => escapeCsv(c.label)).join(",");
|
||||
const body = rows
|
||||
.map((row) =>
|
||||
columns.map((c) => escapeCsv(String(row[c.key] ?? ""))).join(","),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `${BOM + header}\n${body}`;
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: number): string {
|
||||
if (!ts) return "N/A";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError, type z } from "zod";
|
||||
import { reportError } from "@/lib/report-error";
|
||||
|
||||
/** Throwable API error with HTTP status code */
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number = 400) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Standard success response: { ok: true, ...data } */
|
||||
export function apiOk(data?: Record<string, unknown>) {
|
||||
@@ -21,32 +9,3 @@ export function apiOk(data?: Record<string, unknown>) {
|
||||
export function apiError(message: string, status: number = 400) {
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
/** Validation error from Zod: { error: fieldErrors } with 400 */
|
||||
export function apiValidationError(zodError: z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: zodError.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export function handleApiError(error: unknown): Response {
|
||||
if (error instanceof ApiError) {
|
||||
return apiError(error.message, error.status);
|
||||
}
|
||||
if (error instanceof ZodError) {
|
||||
return apiValidationError(error);
|
||||
}
|
||||
// Prisma P2025 "Record not found"
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error.constructor.name === "PrismaClientKnownRequestError" ||
|
||||
error.name === "PrismaClientKnownRequestError") &&
|
||||
(error as Error & { code?: string }).code === "P2025"
|
||||
) {
|
||||
return apiError("Not found", 404);
|
||||
}
|
||||
reportError(error, "API error");
|
||||
return apiError("Internal server error", 500);
|
||||
}
|
||||
@@ -15,8 +15,3 @@ export function cached<T>(
|
||||
return data;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export function bustCache(key: string): void {
|
||||
store.delete(key);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildContentSecurityPolicy, createCspNonce } from "./csp";
|
||||
|
||||
describe("csp", () => {
|
||||
it("creates a non-empty base64 nonce", () => {
|
||||
const nonce = createCspNonce();
|
||||
expect(nonce.length).toBeGreaterThan(8);
|
||||
expect(nonce).toMatch(/^[A-Za-z0-9+/=]+$/);
|
||||
});
|
||||
|
||||
it("uses a script nonce and omits script unsafe-inline", () => {
|
||||
const csp = buildContentSecurityPolicy("testNonce123");
|
||||
expect(csp).toContain("script-src");
|
||||
expect(csp).toContain("'nonce-testNonce123'");
|
||||
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/);
|
||||
expect(csp).toContain("style-src 'self' 'unsafe-inline'");
|
||||
expect(csp).toContain("https://challenges.cloudflare.com");
|
||||
expect(csp).toContain("https://cdn.jsdelivr.net");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build a Content-Security-Policy value.
|
||||
* Scripts: nonce for Next.js / first-party inline; host allowlists for captcha,
|
||||
* Cloudflare Insights, and TinyMCE CDN. style-src keeps 'unsafe-inline' for
|
||||
* theme CSS variables (ThemeVars) — nonce styles are a follow-up.
|
||||
*/
|
||||
export function buildContentSecurityPolicy(nonce: string): string {
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const scriptSrc = [
|
||||
"'self'",
|
||||
`'nonce-${nonce}'`,
|
||||
"https://challenges.cloudflare.com",
|
||||
"https://www.google.com/recaptcha/",
|
||||
"https://www.gstatic.com/recaptcha/",
|
||||
"https://static.cloudflareinsights.com",
|
||||
"https://cdn.jsdelivr.net",
|
||||
...(isDev ? ["'unsafe-eval'"] : []),
|
||||
].join(" ");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src ${scriptSrc}`,
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"frame-src 'self' https://challenges.cloudflare.com https://www.google.com/recaptcha/",
|
||||
"connect-src 'self' https: wss:",
|
||||
"font-src 'self' data:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
export function createCspNonce(): string {
|
||||
return Buffer.from(crypto.randomUUID()).toString("base64");
|
||||
}
|
||||
@@ -62,4 +62,16 @@ describe("production deploy workflow", () => {
|
||||
'export NEXT_PUBLIC_APP_VERSION="$' + "{APP_VERSION}\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("runs an HTTP health check before declaring deploy success", () => {
|
||||
expect(workflow).toContain("/api/health");
|
||||
expect(workflow).toContain('"database":true');
|
||||
const deployJob = workflow.slice(workflow.indexOf("\n deploy:"));
|
||||
const startAt = deployJob.indexOf("systemctl start atom-nexst.service");
|
||||
const healthAt = deployJob.indexOf("/api/health");
|
||||
const successAt = deployJob.indexOf("--- Deployed successfully ---");
|
||||
expect(startAt).toBeGreaterThan(-1);
|
||||
expect(healthAt).toBeGreaterThan(startAt);
|
||||
expect(successAt).toBeGreaterThan(healthAt);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +1,5 @@
|
||||
import type { Variants } from "framer-motion";
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const fadeIn: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const fadeInUp: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const fadeInDown: Variants = {
|
||||
hidden: { opacity: 0, y: -12 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.3 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const fadeInLeft: Variants = {
|
||||
hidden: { opacity: 0, x: -20 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const fadeInRight: Variants = {
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
visible: { opacity: 1, x: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const scaleIn: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: { opacity: 1, scale: 1, transition: { duration: 0.25 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const slideUp: Variants = {
|
||||
hidden: { opacity: 0, y: 30 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: [0.25, 0.1, 0.25, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.07,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const staggerItem: Variants = {
|
||||
hidden: { opacity: 0, y: 16 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||||
};
|
||||
|
||||
export const pageTransition: Variants = {
|
||||
initial: { opacity: 0, y: 8 },
|
||||
enter: {
|
||||
@@ -111,16 +48,3 @@ export const modalContentVariants: Variants = {
|
||||
},
|
||||
exit: { opacity: 0, scale: 0.95, y: 10, transition: { duration: 0.15 } },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const hoverScale = {
|
||||
whileHover: { scale: 1.03 },
|
||||
whileTap: { scale: 0.97 },
|
||||
transition: { type: "spring" as const, stiffness: 400, damping: 17 },
|
||||
};
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export const hoverLift = {
|
||||
whileHover: { y: -3, transition: { duration: 0.2 } },
|
||||
whileTap: { y: 0 },
|
||||
};
|
||||
@@ -203,79 +203,3 @@ export function canAccess(
|
||||
): boolean {
|
||||
return permissions.has(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* For mod panel server components: get session + load permissions.
|
||||
* Redirects to login if unauthenticated and to / without moderator ACL access.
|
||||
*/
|
||||
export async function getModContext() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirectSafe("/login", "/login");
|
||||
}
|
||||
|
||||
const userId = sessionUserId(session.user.id);
|
||||
if (!userId) redirectSafe("/login", "/login");
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) redirectSafe("/", "/");
|
||||
const permissions = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
if (!canAccess(permissions, PERMS.MOD_DASHBOARD)) redirectSafe("/", "/");
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: userId,
|
||||
username: state.actor.username,
|
||||
rank: state.actor.rank,
|
||||
},
|
||||
},
|
||||
permissions,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Legacy single-check functions (kept for backward compatibility) ──
|
||||
|
||||
/** Check if a user has a CMS permission using their current database rank. */
|
||||
export async function checkPermission(
|
||||
userId: number,
|
||||
_rank: number,
|
||||
permission: string,
|
||||
): Promise<boolean> {
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return perms.has(permission);
|
||||
}
|
||||
|
||||
/** Check multiple permissions (user needs ALL of them) */
|
||||
export async function checkAllPermissions(
|
||||
userId: number,
|
||||
_rank: number,
|
||||
permissions: string[],
|
||||
): Promise<boolean> {
|
||||
const state = await getCurrentAuthorizationState(userId);
|
||||
if (!state) return false;
|
||||
const perms = await loadUserPermissions(
|
||||
userId,
|
||||
state.actor.rank,
|
||||
state.highestRank,
|
||||
);
|
||||
return perms.hasAll(...permissions);
|
||||
}
|
||||
|
||||
/** Check if user has admin access */
|
||||
export async function hasAdminAccess(
|
||||
userId: number,
|
||||
rank: number,
|
||||
): Promise<boolean> {
|
||||
return checkPermission(userId, rank, PERMS.ADMIN_DASHBOARD);
|
||||
}
|
||||
@@ -33,16 +33,6 @@ export async function redisCache<T>(
|
||||
return fresh;
|
||||
}
|
||||
|
||||
// TODO: Check of dit weg kan — niet geïmporteerd in de codebase
|
||||
export async function invalidateCache(key: string): Promise<void> {
|
||||
if (!redis) return;
|
||||
try {
|
||||
await redis.del(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a namespaced cache key for API routes.
|
||||
*/
|
||||
|
||||
@@ -40,17 +40,3 @@ function createRedis(): Redis | null {
|
||||
export const redis: Redis | null =
|
||||
globalForRedis.redis !== undefined ? globalForRedis.redis : createRedis();
|
||||
if (globalForRedis.redis === undefined) globalForRedis.redis = redis;
|
||||
|
||||
export async function withRedis<T>(
|
||||
fallback: () => Promise<T>,
|
||||
redisFn: (client: Redis) => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (redis) {
|
||||
try {
|
||||
return await redisFn(redis);
|
||||
} catch {
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
return fallback();
|
||||
}
|
||||
Reference in new issue
Block a user