* Fix ActivityFeed: Remove dynamic import that was causing it to disappear in production * Fix ActivityFeed hydration error: Move localStorage read to useEffect to prevent server/client mismatch * Update Node.js version to 25 in Gitea workflows - Fix EBADENGINE error for camera-controls@3.1.2 which requires Node.js >=22 - Update production-deploy.yml, dev-deploy.yml, and ci-cd-with-gitea-vars.yml.disabled - Node.js v25 matches local development environment * Update Dockerfile to use Node.js 25 - Update base image from node:20 to node:25 - Matches Gitea workflow configuration and camera-controls@3.1.2 requirements * Fix production deployment: Start database dependencies - Remove --no-deps flag which prevented postgres and redis from starting - Remove --build flag as image is already built in previous step - This fixes 'Can't reach database server at postgres:5432' error * Fix postgres health check in production - Remove init-db.sql volume mount (not available in CI/CD environment) - Init script not needed as Prisma handles schema migrations - Postgres will initialize empty database automatically * Fix cache permission error in Docker container - Create cache directories AFTER copying standalone files - Create both fetch-cache and images subdirectories - Set proper ownership for nextjs user - Fixes EACCES permission denied errors for prerender cache * Fix German jogging fallback text * Use Directus content in production * fix: Security vulnerability - block malicious file requests * fix: Switch projects to Directus, add security fixes and example projects
159 lines
4.9 KiB
TypeScript
159 lines
4.9 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import type { NextRequest } from "next/server";
|
|
|
|
const SUPPORTED_LOCALES = ["en", "de"] as const;
|
|
type SupportedLocale = (typeof SUPPORTED_LOCALES)[number];
|
|
|
|
// Security: Block common malicious file patterns
|
|
const BLOCKED_PATTERNS = [
|
|
/\.php$/i,
|
|
/\.asp$/i,
|
|
/\.aspx$/i,
|
|
/\.jsp$/i,
|
|
/\.cgi$/i,
|
|
/\.env$/i,
|
|
/\.sql$/i,
|
|
/\.gz$/i,
|
|
/\.tar$/i,
|
|
/\.zip$/i,
|
|
/\.rar$/i,
|
|
/\.bash_history$/i,
|
|
/ftpsync\.settings$/i,
|
|
/__MACOSX/i,
|
|
/\.well-known\.zip$/i,
|
|
];
|
|
|
|
function isBlockedPath(pathname: string): boolean {
|
|
return BLOCKED_PATTERNS.some((pattern) => pattern.test(pathname));
|
|
}
|
|
|
|
function pickLocaleFromHeader(acceptLanguage: string | null): SupportedLocale {
|
|
if (!acceptLanguage) return "en";
|
|
const lower = acceptLanguage.toLowerCase();
|
|
// Very small parser: prefer de, then en
|
|
if (lower.includes("de")) return "de";
|
|
if (lower.includes("en")) return "en";
|
|
return "en";
|
|
}
|
|
|
|
function hasLocalePrefix(pathname: string): boolean {
|
|
return SUPPORTED_LOCALES.some((l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`));
|
|
}
|
|
|
|
export function middleware(request: NextRequest) {
|
|
const { pathname, search } = request.nextUrl;
|
|
|
|
// Security: Block malicious/suspicious requests immediately
|
|
if (isBlockedPath(pathname)) {
|
|
return new NextResponse(null, { status: 404 });
|
|
}
|
|
|
|
// If a locale-prefixed request hits a public asset path (e.g. /de/images/me.jpg),
|
|
// redirect to the non-prefixed asset path.
|
|
if (hasLocalePrefix(pathname)) {
|
|
const rest = pathname.replace(/^\/(en|de)/, "") || "/";
|
|
if (rest.includes(".")) {
|
|
const responseUrl = request.nextUrl.clone();
|
|
responseUrl.pathname = rest;
|
|
const res = NextResponse.redirect(responseUrl);
|
|
return addHeaders(request, res);
|
|
}
|
|
}
|
|
|
|
// Do not locale-route public assets (anything with a dot), robots, sitemap, etc.
|
|
if (pathname.includes(".")) {
|
|
return addHeaders(request, NextResponse.next());
|
|
}
|
|
|
|
// Keep admin + APIs unlocalized for simplicity
|
|
const isAdminOrApi =
|
|
pathname.startsWith("/api/") ||
|
|
pathname === "/api" ||
|
|
pathname.startsWith("/manage") ||
|
|
pathname.startsWith("/editor") ||
|
|
pathname === "/sentry-example-page" ||
|
|
pathname.startsWith("/sentry-example-page/");
|
|
|
|
// Locale routing for public site pages
|
|
const responseUrl = request.nextUrl.clone();
|
|
|
|
if (!isAdminOrApi) {
|
|
if (hasLocalePrefix(pathname)) {
|
|
// Persist locale preference
|
|
const locale = pathname.split("/")[1] as SupportedLocale;
|
|
const res = NextResponse.next();
|
|
res.cookies.set("NEXT_LOCALE", locale, { path: "/" });
|
|
|
|
// Continue below to add security headers
|
|
return addHeaders(request, res);
|
|
}
|
|
|
|
// Redirect bare routes to locale-prefixed ones
|
|
const preferred = pickLocaleFromHeader(request.headers.get("accept-language"));
|
|
const redirectTarget =
|
|
pathname === "/" ? `/${preferred}` : `/${preferred}${pathname}${search || ""}`;
|
|
responseUrl.pathname = redirectTarget;
|
|
const res = NextResponse.redirect(responseUrl);
|
|
res.cookies.set("NEXT_LOCALE", preferred, { path: "/" });
|
|
return addHeaders(request, res);
|
|
}
|
|
|
|
// Fix for 421 Misdirected Request with Nginx Proxy Manager
|
|
// Ensure proper host header handling for reverse proxy
|
|
const hostname = request.headers.get("host") || request.headers.get("x-forwarded-host") || "";
|
|
|
|
// Add security headers to all responses
|
|
const response = NextResponse.next();
|
|
|
|
return addHeaders(request, response, hostname);
|
|
}
|
|
|
|
function addHeaders(request: NextRequest, response: NextResponse, hostnameOverride?: string) {
|
|
const hostname =
|
|
hostnameOverride ??
|
|
request.headers.get("host") ??
|
|
request.headers.get("x-forwarded-host") ??
|
|
"";
|
|
|
|
// Set proper headers for Nginx Proxy Manager
|
|
if (hostname) {
|
|
response.headers.set("X-Forwarded-Host", hostname);
|
|
response.headers.set(
|
|
"X-Real-IP",
|
|
request.headers.get("x-real-ip") || request.headers.get("x-forwarded-for") || "",
|
|
);
|
|
}
|
|
|
|
// Security headers (complementing next.config.ts headers)
|
|
response.headers.set("X-DNS-Prefetch-Control", "on");
|
|
response.headers.set("X-Frame-Options", "DENY");
|
|
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
response.headers.set("X-XSS-Protection", "1; mode=block");
|
|
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
response.headers.set(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()",
|
|
);
|
|
|
|
// Rate limiting headers for API routes
|
|
if (request.nextUrl.pathname.startsWith("/api/")) {
|
|
response.headers.set("X-RateLimit-Limit", "100");
|
|
response.headers.set("X-RateLimit-Remaining", "99");
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - api (all API routes)
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
*/
|
|
"/((?!api|_next/static|_next/image|favicon.ico).*)",
|
|
],
|
|
};
|