d68f003000
CI / Change detection (push) Has been cancelled
CI / Version drift (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Workflow RLM cache (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / npm wrapper smoke (push) Has been cancelled
CI / Mobile runtime smoke (push) Has been cancelled
CI / Workflow lint (push) Has been cancelled
CI / Documentation (push) Has been cancelled
Web Frontend / Lint & Type Check (push) Failing after 1s
Auto-close harvested PRs / close (push) Failing after 1s
cargo-deny / cargo-deny (bans licenses sources) (push) Failing after 1s
Security audit / cargo-audit (push) Failing after 1s
Sync to CNB / sync (push) Failing after 1s
Web Frontend / Deploy to Cloudflare (push) Waiting to run
cargo-deny / cargo-deny (advisories) (push) Failing after 3s
72 lines
2.4 KiB
TypeScript
72 lines
2.4 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { locales, defaultLocale } from "@/lib/i18n/config";
|
|
|
|
const COOKIE = "NEXT_LOCALE";
|
|
|
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
"X-Frame-Options": "DENY",
|
|
"X-Content-Type-Options": "nosniff",
|
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
|
"Strict-Transport-Security": "max-age=63072000; includeSubDomains; preload",
|
|
};
|
|
|
|
function applySecurityHeaders(res: NextResponse): NextResponse {
|
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.headers.set(k, v);
|
|
return res;
|
|
}
|
|
|
|
function detectLocale(req: NextRequest): string {
|
|
// 1. Cookie
|
|
const cookie = req.cookies.get(COOKIE)?.value;
|
|
if (cookie && (locales as readonly string[]).includes(cookie)) return cookie;
|
|
|
|
// 2. Accept-Language header — match against all shipped locales
|
|
const accept = req.headers.get("accept-language") ?? "";
|
|
if (accept) {
|
|
const preferred = accept.split(",").map((s) => s.split(";")[0].trim().split("-")[0].toLowerCase());
|
|
for (const lang of preferred) {
|
|
if ((locales as readonly string[]).includes(lang)) return lang;
|
|
}
|
|
}
|
|
|
|
return defaultLocale;
|
|
}
|
|
|
|
export function middleware(req: NextRequest) {
|
|
const { pathname } = req.nextUrl;
|
|
|
|
// Skip API routes, static files, _next, and the dot-less metadata route
|
|
// for the shared OG image (but still apply security headers).
|
|
if (
|
|
pathname.startsWith("/api/") ||
|
|
pathname.startsWith("/_next/") ||
|
|
pathname === "/opengraph-image" ||
|
|
pathname.includes(".")
|
|
) {
|
|
return applySecurityHeaders(NextResponse.next());
|
|
}
|
|
|
|
// Check if locale is already in path
|
|
const seg = pathname.split("/")[1];
|
|
if (locales.includes(seg as typeof locales[number])) {
|
|
const res = NextResponse.next();
|
|
res.cookies.set(COOKIE, seg, { path: "/", maxAge: 60 * 60 * 24 * 365 });
|
|
return applySecurityHeaders(res);
|
|
}
|
|
|
|
// Redirect bare paths to detected locale
|
|
const locale = detectLocale(req);
|
|
const url = req.nextUrl.clone();
|
|
url.pathname = `/${locale}${pathname}`;
|
|
const res = NextResponse.redirect(url);
|
|
res.cookies.set(COOKIE, locale, { path: "/", maxAge: 60 * 60 * 24 * 365 });
|
|
return applySecurityHeaders(res);
|
|
}
|
|
|
|
export const config = {
|
|
// Match everything so security headers apply globally; the function
|
|
// bypasses redirect/locale logic for /_next, /api, and dotted paths.
|
|
matcher: ["/:path*"],
|
|
};
|