-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathproxy.ts
More file actions
224 lines (199 loc) · 9.24 KB
/
Copy pathproxy.ts
File metadata and controls
224 lines (199 loc) · 9.24 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
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
/**
* Next.js proxy (formerly "middleware" — renamed in Next.js 16).
*
* Composes two concerns:
* 1. next-intl locale routing (`createMiddleware`) — resolves the active
* locale from URL/cookie/Accept-Language and rewrites/redirects to the
* `[locale]` segment. Hebrew (defaultLocale) stays prefix-less.
* 2. Authentication gating — optimistic check via Better Auth's session
* cookie. Runs AFTER i18n so we reason about the locale-stripped pathname
* and keep any `/en` prefix on redirects.
*
* Auth state is detected via `getSessionCookie` (not a hardcoded cookie name),
* so it stays correct across cookie-name/prefix changes. This is an optimistic
* check — it only verifies a session cookie is present, not that it is valid.
* Full validation happens server-side via `getUser()` in protected routes.
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
import createMiddleware from "next-intl/middleware";
import { routing, type Locale } from "./src/i18n/routing";
const handleI18nRouting = createMiddleware(routing);
// Routes that don't require authentication (locale prefix is stripped before matching)
const publicRoutes = [
"/login",
"/register",
"/forgot-password",
"/reset-password",
"/offline",
"/privacy",
"/terms",
"/accessibility",
"/contact",
"/pricing",
"/monitoring",
"/doc", // public charge-document share links (/doc/[token]) — no login required
];
function localeFromCookie(request: NextRequest): Locale | null {
const value = request.cookies.get("NEXT_LOCALE")?.value;
return (routing.locales as readonly string[]).includes(value ?? "")
? (value as Locale)
: null;
}
/**
* `localeDetection: false` deliberately disables next-intl's cookie lookup so
* geo routing stays under our control. Restore only the explicit preference:
* an unprefixed URL follows a valid NEXT_LOCALE cookie, while an explicit URL
* prefix remains authoritative.
*/
function cookieLocaleRedirect(request: NextRequest): NextResponse | null {
const preferredLocale = localeFromCookie(request);
if (!preferredLocale || preferredLocale === routing.defaultLocale) return null;
const firstSegment = request.nextUrl.pathname.split("/")[1];
if ((routing.locales as readonly string[]).includes(firstSegment)) return null;
const url = request.nextUrl.clone();
url.pathname = `/${preferredLocale}${request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname}`;
return NextResponse.redirect(url);
}
/**
* Strip a leading locale prefix (`/en`) so route checks reason about the
* canonical path. Hebrew is prefix-less, so only non-default locales appear.
*/
function stripLocale(pathname: string): { locale: string; rest: string } {
// Normalize a trailing slash off any path except the root "/", so both the
// prefix-less (Hebrew) and prefixed (English) branches behave identically.
const normalize = (path: string): string =>
path === "/" ? "/" : path.replace(/\/$/, "");
const segments = pathname.split("/"); // ["", "en", "dashboard"]
const maybeLocale = segments[1];
if ((routing.locales as readonly string[]).includes(maybeLocale)) {
return { locale: maybeLocale, rest: normalize("/" + segments.slice(2).join("/")) };
}
return { locale: routing.defaultLocale, rest: normalize(pathname) };
}
/**
* Geo-based default locale for FIRST-TIME, UNAUTHENTICATED visitors.
*
* Runs ONLY when ALL of these hold:
* - no `NEXT_LOCALE` cookie (a returning/explicit visitor already chose), AND
* - the pathname has NO explicit locale prefix (not `/en…` and not `/he…`).
*
* (The matcher already excludes /api, /_next, static assets, etc.)
*
* Decision: read Vercel's `x-vercel-ip-country` request header.
* - header present AND country !== 'IL' -> 'en'
* - otherwise (incl. no header, e.g. local/dev) -> 'he'
*
* This means the no-geo path falls back to Hebrew = current behavior, unchanged.
*
* When 'en': redirect to `/en` + current path (+ query) and stamp the
* `NEXT_LOCALE=en` cookie on the redirect so it sticks and happens once.
* When 'he': stamp `NEXT_LOCALE=he` and continue (prefix-less is already Hebrew).
*
* Loop-safety: the cookie set here (and explicit /en|/he prefixes, and any
* existing cookie) all cause this function to be skipped on the next request.
*
* Returns a `NextResponse` to short-circuit the proxy, or `null` to continue.
*/
/**
* Heuristic: a bot / non-browser client (search crawler, link unfurler, or a
* reachability/uptime checker like Polar's product-URL validator). These must
* NOT be geo-redirected — a 307 on `/` makes non-redirect-following checkers
* report the site as "unreachable" and is worse for SEO. They should get a
* direct 200 at the prefix-less root (the canonical Hebrew page, which already
* emits hreflang alternates to `/en`). Real browsers always send a "Mozilla" UA.
*/
function isBotRequest(request: NextRequest): boolean {
const ua = request.headers.get("user-agent") ?? "";
if (!ua || !ua.includes("Mozilla")) return true;
return /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|preview|monitor|headless/i.test(ua);
}
function geoDefaultLocale(request: NextRequest): NextResponse | null {
// Explicit cookie always wins over geo.
if (localeFromCookie(request)) {
return null;
}
// Explicit locale prefix (`/en…` or `/he…`) always wins over geo.
const segments = request.nextUrl.pathname.split("/"); // ["", "en", "dashboard"]
const maybeLocale = segments[1];
if ((routing.locales as readonly string[]).includes(maybeLocale)) {
return null;
}
// Bots / non-browser clients (crawlers, unfurlers, reachability checkers like
// Polar) are never redirected — serve the prefix-less 200 root instead.
if (isBotRequest(request)) {
return null;
}
// Vercel geo header (absent locally / in dev -> treated as Israel -> Hebrew).
const country = request.headers.get("x-vercel-ip-country");
const desired = country && country !== "IL" ? "en" : "he";
if (desired === "en") {
// Redirect prefix-less path to its /en equivalent, preserving query.
const url = request.nextUrl.clone();
url.pathname = `/en${request.nextUrl.pathname === "/" ? "" : request.nextUrl.pathname}`;
const redirect = NextResponse.redirect(url);
redirect.cookies.set("NEXT_LOCALE", "en", { path: "/", secure: process.env.NODE_ENV === "production" });
return redirect;
}
// desired === "he": continue normally, but stamp the cookie so this runs once.
// We return null and let the caller set the cookie on the final response.
return null;
}
export function proxy(request: NextRequest) {
// Step 0: honor a previously chosen locale, then apply geo for first visits.
const cookieRedirect = cookieLocaleRedirect(request);
if (cookieRedirect) {
return cookieRedirect;
}
// Geo-based default locale for first-time unauthenticated visitors.
// Returns a redirect (English) to short-circuit; otherwise we continue and
// (when geo applies and resolves to Hebrew) stamp NEXT_LOCALE=he below.
const noCookie = !localeFromCookie(request);
const segs = request.nextUrl.pathname.split("/");
const hasLocalePrefix = (routing.locales as readonly string[]).includes(segs[1]);
const geoRedirect = geoDefaultLocale(request);
if (geoRedirect) {
return geoRedirect;
}
// Step 1: let next-intl resolve the locale and produce the base response.
const response = handleI18nRouting(request);
// If geo applied (no cookie, no explicit prefix) and resolved to Hebrew,
// stamp NEXT_LOCALE=he so the geo pre-step is skipped on subsequent requests.
if (noCookie && !hasLocalePrefix) {
response.cookies.set("NEXT_LOCALE", "he", { path: "/", secure: process.env.NODE_ENV === "production" });
}
// Step 2: layer auth gating on top, using the locale-stripped path.
const { locale, rest } = stripLocale(request.nextUrl.pathname);
const localePrefix = locale === routing.defaultLocale ? "" : `/${locale}`;
// Landing page "/" is handled by the page itself (server component checks session).
if (rest === "/") {
return response;
}
const isPublicRoute = publicRoutes.some((route) => rest.startsWith(route));
const sessionCookie = getSessionCookie(request);
// Unauthenticated user hitting a protected route -> redirect to login,
// preserving the active locale prefix.
if (!sessionCookie && !isPublicRoute) {
return NextResponse.redirect(new URL(`${localePrefix}/login`, request.url));
}
// NOTE: we intentionally do NOT redirect authenticated users away from public
// routes here. `getSessionCookie` only confirms a cookie is PRESENT, not valid;
// doing so caused a redirect loop for stale/expired cookies. The login page does
// its own valid-session check client-side and redirects to /dashboard.
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - api (API routes, including Better Auth)
* - _next (all Next internals: static, image, and dev HMR endpoints)
* - _vercel
* - monitoring (Sentry tunnelRoute)
* - favicon.ico, sw.js, manifest.webmanifest
* - any file with an extension (.png, .svg, etc.)
*/
"/((?!api|_next|_vercel|monitoring|favicon.ico|sw\\.js|manifest\\.webmanifest|.*\\..*).*)",
],
};