-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathmiddleware.ts
More file actions
186 lines (153 loc) · 6.8 KB
/
middleware.ts
File metadata and controls
186 lines (153 loc) · 6.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
import { NextResponse } from 'next/server'
import {
DEFAULT_LANGUAGE,
DEPRECATED_LANGUAGES,
SUPPORTED_LANGUAGES,
getLanguageFromPath,
} from '@/app/[lang]/_utils/i18nconfig'
import type { NextRequest } from 'next/server'
const strapiHostname = process.env.NEXT_PUBLIC_STRAPI_URL ? new URL(process.env.NEXT_PUBLIC_STRAPI_URL).hostname : null
/**
* Extract language from subdomain if present
*/
function extractLanguageFromSubdomain(hostname: string): string | null {
const hostParts = hostname.split('.')
if (hostParts.length <= 2) {
return null
}
const possibleLang = hostParts[0]
const isValidLanguage = SUPPORTED_LANGUAGES.some((lang) => lang.code === possibleLang)
const isValidDeprecatedLanguage = DEPRECATED_LANGUAGES.includes(possibleLang)
return isValidLanguage || isValidDeprecatedLanguage ? possibleLang : null
}
/**
* Check if pathname contains a locale
*/
function hasLocaleInPath(pathname: string): boolean {
return SUPPORTED_LANGUAGES.some((lang) => pathname.startsWith(`/${lang.code}/`) || pathname === `/${lang.code}`)
}
/**
* Create headers with locale information
*/
function createLocaleHeaders(requestHeaders: Headers, pathname: string, locale: string): Headers {
const headers = new Headers(requestHeaders)
headers.set('x-current-path', pathname)
headers.set('x-locale', locale)
return headers
}
/**
* Set locale cookie on response
*/
function setLocaleCookie(response: NextResponse, locale: string): void {
response.cookies.set('locale', locale, {
httpOnly: false,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
})
}
/**
* Handle subdomain-based language redirect
*/
function handleSubdomainRedirect(request: NextRequest, hostname: string, subdomainLang: string): NextResponse {
const hostParts = hostname.split('.')
const mainDomain = hostParts.slice(1).join('.')
const newUrl = new URL(request.url)
newUrl.hostname = mainDomain
// Redirect deprecated languages to default language without prefix
if (DEPRECATED_LANGUAGES.includes(subdomainLang)) {
newUrl.pathname = request.nextUrl.pathname // Preserve path
return NextResponse.redirect(newUrl, 301)
}
newUrl.pathname = `/${subdomainLang}${request.nextUrl.pathname}`
return NextResponse.redirect(newUrl, 301)
}
/**
* Handle locale-based routing logic
*/
function handleLocaleRouting(
request: NextRequest,
response: NextResponse,
locale: string,
pathHasLocale: boolean
): NextResponse {
const pathname = request.nextUrl.pathname
// If path has locale, return as is (Next.js will handle the routing)
if (pathHasLocale) {
console.log('returning response - path has locale')
return response
}
// If no locale in path and it's the default language, rewrite to include it
if (locale === DEFAULT_LANGUAGE) {
request.nextUrl.pathname = `/${DEFAULT_LANGUAGE}${pathname}`
console.log('rewriting to', request.nextUrl.pathname)
return NextResponse.rewrite(request.nextUrl)
}
// Redirect to include locale in path for non-default languages
request.nextUrl.pathname = `/${locale}${pathname}`
console.log('redirecting to', request.nextUrl.pathname)
return NextResponse.redirect(request.nextUrl)
}
export function middleware(request: NextRequest): NextResponse {
const pathname = request.nextUrl.pathname
const hostname = request.headers.get('host') || ''
// Generate a nonce for CSP
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
// Check for language subdomain
const subdomainLang = extractLanguageFromSubdomain(hostname)
// Handle subdomain-based language redirect
if (subdomainLang && subdomainLang !== DEFAULT_LANGUAGE) {
return handleSubdomainRedirect(request, hostname, subdomainLang)
}
// Check if path has locale
const isPathWithLocale = hasLocaleInPath(pathname)
// Get saved locale from cookie
const savedLocale = request.cookies.get('locale')?.value
// Get browser language
const acceptLanguage = request.headers.get('accept-language') || ''
const browserLang = acceptLanguage.split(',')[0]?.split('-')[0]?.toLowerCase()
const isBrowserLangSupported = SUPPORTED_LANGUAGES.some((lang) => lang.code === browserLang)
// Determine locale priority: URL path > saved cookie > browser language > default
let locale: string
if (isPathWithLocale) {
locale = getLanguageFromPath(pathname) || DEFAULT_LANGUAGE
} else if (savedLocale && SUPPORTED_LANGUAGES.some((lang) => lang.code === savedLocale)) {
locale = savedLocale
} else if (!savedLocale && isBrowserLangSupported && browserLang) {
// Only use browser language if no cookie is set
locale = browserLang
} else {
locale = DEFAULT_LANGUAGE
}
// Create response with locale headers and nonce
const headers = createLocaleHeaders(request.headers, pathname, locale)
headers.set('x-nonce', nonce)
const response = NextResponse.next({ headers })
// Only set locale cookie if user explicitly changed language (cookie already exists)
// This prevents auto-setting cookie based on browser language
if (savedLocale) {
setLocaleCookie(response, locale)
}
// Set security headers including COEP
response.headers.set('Cross-Origin-Embedder-Policy', 'credentialless')
response.headers.set('Cross-Origin-Resource-Policy', 'cross-origin')
response.headers.set('Cross-Origin-Opener-Policy', 'same-origin')
// Set the CSP header with the nonce
const cspHeader = `default-src 'self'; script-src 'self' 'nonce-${nonce}' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com https://cdn.weglot.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.weglot.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: blob:; media-src 'self' https:; connect-src 'self' https://api.hypelab.com https://app.chatwoot.com https://widget.chatwoot.com ${strapiHostname} https://cdn.weglot.com https://api.weglot.com https://cdn-api-weglot.com wss://app.chatwoot.com https://api.thorchain.shapeshift.com; frame-src 'self' https://widget.chatwoot.com https://app.chatwoot.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self' https://app.chatwoot.com; frame-ancestors 'self'; upgrade-insecure-requests;`
response.headers.set('Content-Security-Policy', cspHeader)
// Handle locale routing
return handleLocaleRouting(request, response, locale, isPathWithLocale)
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico, sitemap.xml, robots.txt
* - public assets with extensions (images, fonts, etc.)
*/
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|webmanifest|ico|css|js|woff|woff2|ttf|otf)).*)',
],
}