102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { createGuestToken, GUEST_COOKIE_NAME } from './lib/guest-token'
|
|
import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './i18n/routing'
|
|
|
|
/**
|
|
* Middleware to:
|
|
* 1. Detect and set locale based on Accept-Language header or cookie
|
|
* 2. Ensure every visitor gets a guest token
|
|
* 3. Add pathname and locale to headers for Server Components
|
|
*/
|
|
export async function middleware(request: NextRequest) {
|
|
const response = NextResponse.next()
|
|
|
|
// Detect locale from cookie or Accept-Language header
|
|
let locale = request.cookies.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined
|
|
|
|
if (!locale || !locales.includes(locale)) {
|
|
// Parse Accept-Language header
|
|
const acceptLanguage = request.headers.get('accept-language')
|
|
if (acceptLanguage) {
|
|
const preferred = acceptLanguage
|
|
.split(',')
|
|
.map((lang) => lang.split(';')[0].trim().slice(0, 2))
|
|
.find((lang) => locales.includes(lang as Locale))
|
|
locale = (preferred as Locale) || defaultLocale
|
|
} else {
|
|
locale = defaultLocale
|
|
}
|
|
|
|
// Set locale cookie
|
|
response.cookies.set(LOCALE_COOKIE_NAME, locale, {
|
|
path: '/',
|
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
sameSite: 'lax',
|
|
})
|
|
}
|
|
|
|
// Add locale to headers for Server Components
|
|
response.headers.set('x-locale', locale)
|
|
|
|
// Add pathname to headers so Server Components can access it
|
|
response.headers.set('x-pathname', request.nextUrl.pathname)
|
|
|
|
// Check if guest cookie already exists
|
|
let existing = request.cookies.get(GUEST_COOKIE_NAME)?.value
|
|
let guestId: string | null = null
|
|
|
|
if (existing) {
|
|
// Verify and extract guest ID from existing token
|
|
try {
|
|
const { verifyGuestToken } = await import('./lib/guest-token')
|
|
const verified = await verifyGuestToken(existing)
|
|
guestId = verified.sid
|
|
} catch {
|
|
// Invalid token, will create new one
|
|
existing = undefined
|
|
}
|
|
}
|
|
|
|
if (!existing) {
|
|
// Generate new stable session ID
|
|
const sid = crypto.randomUUID()
|
|
guestId = sid
|
|
|
|
// Create signed guest token
|
|
const token = await createGuestToken(sid)
|
|
|
|
// Set cookie with security flags
|
|
response.cookies.set({
|
|
name: GUEST_COOKIE_NAME,
|
|
value: token,
|
|
httpOnly: true, // Not accessible via JavaScript
|
|
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
|
|
sameSite: 'lax', // CSRF protection
|
|
path: '/', // Required for __Host- prefix
|
|
maxAge: 60 * 60 * 24 * 30, // 30 days
|
|
})
|
|
}
|
|
|
|
// Pass guest ID to route handlers via header
|
|
if (guestId) {
|
|
response.headers.set('x-guest-id', guestId)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
/*
|
|
* Match all request paths except for the ones starting with:
|
|
* - _next/static (static files)
|
|
* - _next/image (image optimization files)
|
|
* - favicon.ico (favicon file)
|
|
* - api routes (next-intl doesn't need to handle these)
|
|
*
|
|
* Note: This matcher handles both i18n routing and guest tokens
|
|
*/
|
|
'/((?!api|_next|_vercel|.*\\..*).*)',
|
|
],
|
|
}
|