114 lines
3.8 KiB
TypeScript
114 lines
3.8 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];
|
|
|
|
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;
|
|
|
|
// Keep admin + APIs unlocalized for simplicity
|
|
const isAdminOrApi =
|
|
pathname.startsWith("/api/") ||
|
|
pathname === "/api" ||
|
|
pathname.startsWith("/manage") ||
|
|
pathname.startsWith("/editor");
|
|
|
|
// 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
|
|
// eslint-disable-next-line no-use-before-define
|
|
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: "/" });
|
|
// eslint-disable-next-line no-use-before-define
|
|
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).*)",
|
|
],
|
|
};
|