Merge pull request 'chore: CSP script nonces, deploy health check, dead-code cleanup' (#14) from chore/phase1-csp-deploy-knip into main
Reviewed-on: #14
This commit is contained in:
commit
160c05a511
20 files changed
+141
-358
No files matched your search
@@ -309,4 +309,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for HTTP health check..."
|
||||
HEALTH_URL="${DEPLOY_HEALTH_URL:-http://127.0.0.1:3000/api/health}"
|
||||
HEALTH_OK=0
|
||||
for i in $(seq 1 15); do
|
||||
BODY="$(curl -sf --max-time 5 "${HEALTH_URL}" 2>/dev/null || true)"
|
||||
if echo "${BODY}" | grep -q '"database":true'; then
|
||||
echo "Health OK (${HEALTH_URL})"
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
echo "Health attempt ${i}/15 failed, retrying..."
|
||||
sleep 2
|
||||
done
|
||||
if [ "${HEALTH_OK}" != "1" ]; then
|
||||
echo "ERROR: Health check failed after deploy (${HEALTH_URL})" >&2
|
||||
echo "Last body: ${BODY:-<empty>}" >&2
|
||||
journalctl -u atom-nexst.service -n 40 --no-pager >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Deployed successfully ---"
|
||||
+1
-15
@@ -16,21 +16,7 @@ const securityHeaders = [
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/ https://static.cloudflareinsights.com",
|
||||
"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("; "),
|
||||
},
|
||||
// CSP is set per-request in src/proxy.ts with a script nonce (no 'unsafe-inline' for scripts).
|
||||
];
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@
|
||||
"music-metadata": "^11.14.0",
|
||||
"mysql2": "^3.23.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
"next-intl": "^4.13.3",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^7.0.13",
|
||||
|
||||
Generated
+11
-11
@@ -92,8 +92,8 @@ importers:
|
||||
specifier: ^16.2.11
|
||||
version: 16.2.11(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
next-auth:
|
||||
specifier: 5.0.0-beta.31
|
||||
version: 5.0.0-beta.31([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
specifier: 5.0.0-beta.32
|
||||
version: 5.0.0-beta.32([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
next-intl:
|
||||
specifier: ^4.13.3
|
||||
version: 4.13.3([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected])
|
||||
@@ -221,12 +221,12 @@ packages:
|
||||
'@apm-js-collab/[email protected]':
|
||||
resolution: {integrity: sha512-mTvWz9rnQwx1U3h0XPTHaX7bgfkpipLLTQyjlC2cdhQpQEuoLT0AGzoydeoq2NxfEVv6fWOOETcSbb2nptleyw==}
|
||||
|
||||
'@auth/[email protected].2':
|
||||
resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==}
|
||||
'@auth/[email protected].3':
|
||||
resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
nodemailer: ^7.0.7
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
optional: true
|
||||
@@ -3508,13 +3508,13 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
|
||||
|
||||
[email protected]1:
|
||||
resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==}
|
||||
[email protected]2:
|
||||
resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==}
|
||||
peerDependencies:
|
||||
'@simplewebauthn/browser': ^9.0.1
|
||||
'@simplewebauthn/server': ^9.0.2
|
||||
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
|
||||
nodemailer: ^7.0.7
|
||||
nodemailer: ^7.0.7 || ^8.0.5
|
||||
react: ^18.2.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@simplewebauthn/browser':
|
||||
@@ -4325,7 +4325,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@auth/[email protected].2([email protected])':
|
||||
'@auth/[email protected].3([email protected])':
|
||||
dependencies:
|
||||
'@panva/hkdf': 1.2.1
|
||||
jose: 6.2.3
|
||||
@@ -7123,9 +7123,9 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]1([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
[email protected]2([email protected](@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected]))([email protected])([email protected]):
|
||||
dependencies:
|
||||
'@auth/core': 0.41.2([email protected])
|
||||
'@auth/core': 0.41.3([email protected])
|
||||
next: 16.2.11(@babel/[email protected])(@opentelemetry/[email protected])([email protected])([email protected]([email protected]))([email protected])
|
||||
react: 19.2.8
|
||||
optionalDependencies:
|
||||
|
||||
@@ -58,56 +58,3 @@ export async function saveLogo(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveLogoFromUrl(
|
||||
gifUrl: string,
|
||||
): Promise<{ success: boolean; url?: string; error?: string }> {
|
||||
try {
|
||||
const res = await fetch(gifUrl);
|
||||
if (!res.ok)
|
||||
return { success: false, error: `Failed to fetch GIF: ${res.status}` };
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "image/gif";
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
const ext =
|
||||
contentType === "image/png"
|
||||
? "png"
|
||||
: contentType === "image/gif"
|
||||
? "gif"
|
||||
: contentType === "image/jpeg"
|
||||
? "jpg"
|
||||
: contentType === "image/webp"
|
||||
? "webp"
|
||||
: "gif";
|
||||
const filename = `logo-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
||||
const baseDir = MEDIA_DIR;
|
||||
const filePath = path.resolve(baseDir, filename);
|
||||
if (!filePath.startsWith(baseDir + path.sep)) {
|
||||
return { success: false, error: "Invalid path" };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await mkdir(baseDir, { recursive: true });
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const url = `/api/media/logo/${filename}`;
|
||||
|
||||
await prisma.websiteSetting.upsert({
|
||||
where: { key: "cms_logo" },
|
||||
update: { value: url },
|
||||
create: { key: "cms_logo", value: url, comment: "Logo (generator)" },
|
||||
});
|
||||
|
||||
siteSettings.reload();
|
||||
revalidatePath("/", "layout");
|
||||
|
||||
return { success: true, url };
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { headers } from "next/headers";
|
||||
import { RegisterForm } from "@/components/auth/register-form";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
@@ -12,6 +13,7 @@ export default async function RegisterPage({
|
||||
}) {
|
||||
const t = await getTranslations("pages.register");
|
||||
const { error } = await searchParams;
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
const [hotelName, captcha] = await Promise.all([
|
||||
siteSettings.get("hotel_name", "Atom"),
|
||||
@@ -26,6 +28,7 @@ export default async function RegisterPage({
|
||||
hotelName={hotelName ?? "Atom"}
|
||||
captcha={captcha}
|
||||
error={error}
|
||||
nonce={nonce}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
+7
-1
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Nunito, Pixelify_Sans } from "next/font/google";
|
||||
import { headers } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getLocale, getMessages } from "next-intl/server";
|
||||
@@ -61,6 +62,7 @@ export default async function RootLayout({
|
||||
const messages = await getMessages();
|
||||
const defaultDark = await siteSettings.getBool("default_dark", false);
|
||||
const nitroUrl = await siteSettings.get("nitro_client_url", "");
|
||||
const nonce = (await headers()).get("x-nonce") ?? undefined;
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -69,7 +71,11 @@ export default async function RootLayout({
|
||||
>
|
||||
<head>
|
||||
<meta name="theme-default-dark" content={String(defaultDark)} />
|
||||
<Script src="/scripts/theme-init.js" strategy="beforeInteractive" />
|
||||
<Script
|
||||
src="/scripts/theme-init.js"
|
||||
strategy="beforeInteractive"
|
||||
nonce={nonce}
|
||||
/>
|
||||
<link rel="preconnect" href="https://www.habbo.com" />
|
||||
<link rel="dns-prefetch" href="https://www.habbo.com" />
|
||||
{nitroUrl?.startsWith("http") ? (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Script from "next/script";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useState } from "react";
|
||||
import { register } from "@/actions/register";
|
||||
@@ -10,9 +11,15 @@ interface RegisterFormProps {
|
||||
hotelName: string;
|
||||
captcha: { provider: string; siteKey?: string };
|
||||
error?: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
export function RegisterForm({
|
||||
hotelName,
|
||||
captcha,
|
||||
error,
|
||||
nonce,
|
||||
}: RegisterFormProps) {
|
||||
const t = useTranslations("pages.register");
|
||||
const showCaptcha = captcha.provider !== "none" && !!captcha.siteKey;
|
||||
const [serverError, formAction, isPending] = useActionState(register, null);
|
||||
@@ -20,6 +27,22 @@ export function RegisterForm({ hotelName, captcha, error }: RegisterFormProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[800px]">
|
||||
{showCaptcha && captcha.provider === "turnstile" ? (
|
||||
<Script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer
|
||||
nonce={nonce}
|
||||
/>
|
||||
) : null}
|
||||
{showCaptcha && captcha.provider === "recaptcha" ? (
|
||||
<Script
|
||||
src="https://www.google.com/recaptcha/api.js"
|
||||
async
|
||||
defer
|
||||
nonce={nonce}
|
||||
/>
|
||||
) : null}
|
||||
{/* Header Banner */}
|
||||
<div
|
||||
className="relative overflow-hidden bg-center bg-cover rounded-t-lg"
|
||||
|
||||
@@ -22,34 +22,3 @@ export function Reveal({ children, className, delay = 0 }: RevealProps) {
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RevealStagger({ children, className }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-50px" }}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.08, delayChildren: 0.05 } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RevealItem({ children, className }: RevealProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.35 } },
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
|
||||
import { shouldRedirectAdminRequest } from "@/lib/proxy-access";
|
||||
import { proxyAuth } from "@/lib/proxy-auth";
|
||||
|
||||
@@ -18,8 +19,12 @@ export const proxy = proxyAuth((req) => {
|
||||
return NextResponse.redirect(new URL("/login", req.url));
|
||||
}
|
||||
|
||||
const nonce = createCspNonce();
|
||||
const csp = buildContentSecurityPolicy(nonce);
|
||||
|
||||
const headers = new Headers(req.headers);
|
||||
headers.set("x-pathname", req.nextUrl.pathname);
|
||||
headers.set("x-nonce", nonce);
|
||||
|
||||
const ip =
|
||||
req.headers.get("cf-connecting-ip") ??
|
||||
@@ -33,6 +38,7 @@ export const proxy = proxyAuth((req) => {
|
||||
for (const [key, value] of Object.entries(SECURITY_HEADERS)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user