7ff2dbb139
Build & Deploy / Build & Push Docker Image (push) Successful in 2m55s
Test / Type Check (all packages) (push) Successful in 54s
Build & Deploy / Deploy to VPS (push) Successful in 4s
Test / API Unit Tests (push) Failing after 48s
Test / Homepage Unit Tests (push) Successful in 43s
Test / Storefront Unit Tests (push) Failing after 40s
Test / Admin Unit Tests (push) Successful in 40s
Test / Dashboard Unit Tests (push) Failing after 42s
Test / API Integration Tests (push) Successful in 1m0s
191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
import {
|
|
defaultMode,
|
|
defaultLocale,
|
|
isMode,
|
|
isLocale,
|
|
localeCookie,
|
|
localeCookieMaxAgeSeconds,
|
|
localeFromAcceptLanguage,
|
|
localizedModePath,
|
|
routeIdFromAnyLocaleSlug,
|
|
routeIdFromSlug,
|
|
} from '@/lib/localization/config';
|
|
import { buildContentSecurityPolicy, createNonce } from '@/lib/security/csp';
|
|
import {
|
|
isThemePreference,
|
|
themeCookie,
|
|
themeCookieMaxAgeSeconds,
|
|
} from '@/lib/theme/config';
|
|
import type { NextRequest } from 'next/server';
|
|
import { NextResponse } from 'next/server';
|
|
|
|
const dashboardLanguageCookie = 'rentaldrivego-language';
|
|
const reservedProxySegments = new Set(['dashboard', 'admin', 'carplace']);
|
|
|
|
function applySecurityHeaders(response: NextResponse, contentSecurityPolicy: string): NextResponse {
|
|
response.headers.set('Content-Security-Policy', contentSecurityPolicy);
|
|
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
response.headers.set('X-Content-Type-Options', 'nosniff');
|
|
response.headers.set('X-Frame-Options', 'DENY');
|
|
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin');
|
|
response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
|
return response;
|
|
}
|
|
|
|
function resolveApiOrigin(): string | undefined {
|
|
const origins = new Set<string>();
|
|
const urls = [process.env.API_INTERNAL_URL, process.env.NEXT_PUBLIC_API_URL];
|
|
for (const url of urls) {
|
|
if (!url) continue;
|
|
try {
|
|
origins.add(new URL(url).origin);
|
|
} catch {
|
|
// ignore malformed URLs
|
|
}
|
|
}
|
|
return origins.size > 0 ? Array.from(origins).join(' ') : undefined;
|
|
}
|
|
|
|
function resolveRequestLocale(request: NextRequest) {
|
|
const cookieLocale = request.cookies.get(localeCookie)?.value;
|
|
return isLocale(cookieLocale)
|
|
? cookieLocale
|
|
: localeFromAcceptLanguage(request.headers.get('accept-language')) || defaultLocale;
|
|
}
|
|
|
|
function resolveRequestMode(request: NextRequest) {
|
|
const cookieMode = request.cookies.get(themeCookie)?.value;
|
|
return isThemePreference(cookieMode) ? cookieMode : defaultMode;
|
|
}
|
|
|
|
function setLocaleCookie(response: NextResponse, locale: string): void {
|
|
response.cookies.set(localeCookie, locale, {
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
maxAge: localeCookieMaxAgeSeconds,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: false,
|
|
});
|
|
}
|
|
|
|
function setModeCookie(response: NextResponse, mode: string): void {
|
|
response.cookies.set(themeCookie, mode, {
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
maxAge: themeCookieMaxAgeSeconds,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: false,
|
|
});
|
|
}
|
|
|
|
export function proxy(request: NextRequest): NextResponse {
|
|
const nonce = createNonce();
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
const apiOrigin = resolveApiOrigin();
|
|
const csp = buildContentSecurityPolicy(nonce, isDev, apiOrigin);
|
|
const requestHeaders = new Headers(request.headers);
|
|
requestHeaders.set('x-nonce', nonce);
|
|
requestHeaders.set('Content-Security-Policy', csp);
|
|
|
|
if (request.nextUrl.pathname === '/') {
|
|
const locale = resolveRequestLocale(request);
|
|
const mode = resolveRequestMode(request);
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = `/${locale}/${mode}`;
|
|
const response = NextResponse.redirect(destination);
|
|
setLocaleCookie(response, locale);
|
|
setModeCookie(response, mode);
|
|
return applySecurityHeaders(response, csp);
|
|
}
|
|
|
|
const pathSegments = request.nextUrl.pathname.split('/').filter(Boolean);
|
|
|
|
// Redirect locale-less paths (e.g. /sign-in → /en/system/sign-in), but not proxied app paths or files.
|
|
const firstSegment = request.nextUrl.pathname.split('/')[1];
|
|
const isFile = /\.\w+$/.test(request.nextUrl.pathname);
|
|
if (firstSegment && !isFile && !isLocale(firstSegment) && !reservedProxySegments.has(firstSegment)) {
|
|
const locale = resolveRequestLocale(request);
|
|
const mode = resolveRequestMode(request);
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = `/${locale}/${mode}${request.nextUrl.pathname}`;
|
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
|
}
|
|
|
|
if (
|
|
isLocale(pathSegments[0]) &&
|
|
isMode(pathSegments[1]) &&
|
|
request.nextUrl.pathname === `/${pathSegments[0]}/${pathSegments[1]}/subscription`
|
|
) {
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = '/dashboard/subscription';
|
|
const response = NextResponse.redirect(destination);
|
|
setLocaleCookie(response, pathSegments[0]);
|
|
setModeCookie(response, pathSegments[1]);
|
|
response.cookies.set(dashboardLanguageCookie, pathSegments[0], {
|
|
path: '/',
|
|
sameSite: 'lax',
|
|
maxAge: localeCookieMaxAgeSeconds,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
httpOnly: false,
|
|
});
|
|
return applySecurityHeaders(response, csp);
|
|
}
|
|
|
|
if (isLocale(pathSegments[0]) && !isMode(pathSegments[1])) {
|
|
const mode = resolveRequestMode(request);
|
|
const slug = pathSegments.slice(1).join('/');
|
|
if (!slug) {
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = localizedModePath('home', pathSegments[0], mode);
|
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
|
}
|
|
const routeId = routeIdFromSlug(pathSegments[0], slug) ?? routeIdFromAnyLocaleSlug(slug);
|
|
if (routeId) {
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = localizedModePath(routeId, pathSegments[0], mode);
|
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
|
}
|
|
if (slug === 'component-lab') {
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = `/${pathSegments[0]}/${mode}/component-lab`;
|
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
|
}
|
|
}
|
|
|
|
if (isLocale(pathSegments[0]) && isMode(pathSegments[1])) {
|
|
requestHeaders.set('x-theme-preference', pathSegments[1]);
|
|
const slug = pathSegments.slice(2).join('/');
|
|
if (slug && !routeIdFromSlug(pathSegments[0], slug)) {
|
|
const routeId = routeIdFromAnyLocaleSlug(slug);
|
|
if (routeId) {
|
|
const destination = request.nextUrl.clone();
|
|
destination.pathname = localizedModePath(routeId, pathSegments[0], pathSegments[1]);
|
|
return applySecurityHeaders(NextResponse.redirect(destination), csp);
|
|
}
|
|
}
|
|
}
|
|
|
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
const explicitLocale = pathSegments[0];
|
|
if (isLocale(explicitLocale)) {
|
|
setLocaleCookie(response, explicitLocale);
|
|
}
|
|
const explicitMode = pathSegments[1];
|
|
if (isMode(explicitMode)) {
|
|
setModeCookie(response, explicitMode);
|
|
}
|
|
return applySecurityHeaders(response, csp);
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
{
|
|
source: '/((?!api|_next/static|_next/image|assets|favicon.ico|robots.txt|sitemap.xml).*)',
|
|
missing: [
|
|
{ type: 'header', key: 'next-router-prefetch' },
|
|
{ type: 'header', key: 'purpose', value: 'prefetch' },
|
|
],
|
|
},
|
|
],
|
|
};
|