-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathproxy.ts
More file actions
296 lines (266 loc) · 11.1 KB
/
Copy pathproxy.ts
File metadata and controls
296 lines (266 loc) · 11.1 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
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
import createMiddleware from "next-intl/middleware";
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import type { JWT } from "next-auth/jwt";
import { routing } from "./i18n/routing";
import {
classifyPath,
getLocaleSegment,
isStaffTwoFactorExempt,
loginCallbackOf,
signedInRedirectPath,
stripLocale,
} from "./lib/route-access";
import { buildCsp, cspEnvironment, generateNonce } from "./lib/csp";
import { isSessionExpired } from "./lib/session-api-token";
const intlMiddleware = createMiddleware(routing);
const LOCALES = routing.locales as readonly string[];
const DEFAULT_LOCALE = routing.defaultLocale;
/** The cookie next-intl reads when resolving a locale — see LOCALE_COOKIE below. */
const LOCALE_COOKIE = "NEXT_LOCALE";
const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
function isSupportedLocale(value: unknown): value is string {
return typeof value === "string" && LOCALES.includes(value);
}
/**
* The locale this request should be served in, when the URL itself doesn't
* say — in preference order:
*
* 1. The language saved on the account, carried in the session token
* since sign-in (see lib/auth.ts) and kept in step by the settings
* page. Once signed in, the account decides.
* 2. The `NEXT_LOCALE` cookie, which records the last locale used on
* this device (next-intl writes it on the public pages too).
*
* Returning null hands the decision back to next-intl, which falls back to
* the `Accept-Language` header and then the default locale.
*
* Without step 2 a freshly installed PWA — which launches at its
* locale-less `start_url` with no cookie yet — is resolved purely from the
* browser's own language, so an account set to Portuguese would open in
* English until the person toggled the setting to something else and back
* (the only path that used to write the cookie). That is the bug this
* function exists to close.
*/
function resolvePreferredLocale(
req: NextRequest,
token: JWT | null,
): string | null {
if (isSupportedLocale(token?.language)) return token.language;
const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
if (isSupportedLocale(cookieLocale)) return cookieLocale;
return null;
}
/** Redirects to `target`, remembering `locale` for later requests. */
function redirectWithLocale(target: URL, locale: string): NextResponse {
const response = NextResponse.redirect(target);
response.cookies.set(LOCALE_COOKIE, locale, {
path: "/",
maxAge: LOCALE_COOKIE_MAX_AGE,
sameSite: "lax",
});
return response;
}
const CSP_ENV = cspEnvironment();
/**
* Marks every page rendered while a staff member views the app as someone
* else. The service worker refuses to store such a response (public/sw.js),
* so the viewed account's pages never end up in the staff member's
* offline cache.
*/
const IMPERSONATION_HEADER = "x-setlyst-impersonating";
function isStaffRole(role: unknown): boolean {
return role === "admin" || role === "moderator";
}
/**
* Public pages outside the locale segment (share links, status page,
* the link-preview image):
* they only need the per-request CSP, never the auth gate or next-intl's
* locale redirect.
*/
function isLocaleFreePage(pathname: string): boolean {
return (
pathname.startsWith("/s/") ||
pathname.startsWith("/g/") ||
pathname === "/status" ||
pathname.startsWith("/status/") ||
// The generated link-preview image (app/opengraph-image.tsx) lives at
// the root; a locale redirect would make it 404.
pathname === "/opengraph-image"
);
}
/**
* Every page gets its own nonce: Next.js reads it from the request's
* `Content-Security-Policy` header and adds it to its scripts, and server
* components read `x-nonce` (lib/server/nonce.ts) for the few inline
* scripts of their own (next-themes). The same policy goes on the response.
*/
export default async function middleware(incoming: NextRequest) {
const nonce = generateNonce();
const csp = buildCsp(CSP_ENV, nonce);
const requestHeaders = new Headers(incoming.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const req = new NextRequest(incoming, { headers: requestHeaders });
const response = await route(req, requestHeaders);
response.headers.set("Content-Security-Policy", csp);
return response;
}
async function route(
req: NextRequest,
requestHeaders: Headers,
): Promise<NextResponse> {
const { pathname } = req.nextUrl;
if (isLocaleFreePage(pathname)) {
return NextResponse.next({ request: { headers: requestHeaders } });
}
const pathWithoutLocale = stripLocale(pathname, LOCALES);
const localeSegment = getLocaleSegment(pathname, LOCALES);
const hasLocalePrefix = localeSegment !== null;
// The public site (landing, pricing, changelog, legal texts,
// unsubscribe) is classified "public": it never needs a session and a
// signed-in visitor is never redirected away from it. See
// lib/route-access.ts.
const kind = classifyPath(pathWithoutLocale);
const isProtected = kind === "protected";
const isChangePassword = kind === "changePassword";
const isAuthPage = kind === "auth";
// Resolved once and shared by the auth gate and the locale fallback
// below, so a request never decrypts the session token twice.
const needsToken =
isProtected || isAuthPage || isChangePassword || !hasLocalePrefix;
const token = needsToken
? await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
: null;
// The locale every redirect below is built with. Resolved *before* the
// auth gate rather than after it: a URL with no locale prefix used to
// fall straight back to the default, so someone whose account is set to
// Portuguese, opening the app at a locale-less URL while signed out,
// was sent to the English login page.
const locale = localeSegment ?? resolvePreferredLocale(req, token);
// A session is usable only while the API token it carries is: see
// isSessionExpired for why `token.error` alone isn't enough here.
const expired = isSessionExpired(token);
const session = expired ? null : token;
if ((isProtected || isChangePassword) && !session) {
const loginUrl = new URL(
`/${locale ?? DEFAULT_LOCALE}/login`,
req.nextUrl.origin,
);
loginUrl.searchParams.set(
"callbackUrl",
loginCallbackOf(pathname, req.nextUrl.search),
);
// A session that existed and ran out: the login page clears the
// offline copy of the account's data (see LoginForm).
if (token) loginUrl.searchParams.set("reason", "expired");
return NextResponse.redirect(loginUrl);
}
// An account flagged for a mandatory password change (temporary
// password, or one below the current policy) can't reach anything else
// until it's done — the API refuses every other call anyway.
if (isProtected && session?.mustChangePassword) {
return NextResponse.redirect(
new URL(
`/${locale ?? DEFAULT_LOCALE}/change-password`,
req.nextUrl.origin,
),
);
}
// Two-factor authentication is mandatory for staff: until it's on, the
// API refuses everything but the account's own security endpoints
// (STAFF_TWO_FACTOR_REQUIRED), so the dashboard would be a wall of
// errors. Such an account is kept on the security settings, which
// explain why.
if (
isProtected &&
session &&
!session.impersonator &&
isStaffRole(session.role) &&
session.twoFactorEnabled === false &&
!isStaffTwoFactorExempt(pathWithoutLocale)
) {
const target = new URL(
`/${locale ?? DEFAULT_LOCALE}/dashboard/settings/security`,
req.nextUrl.origin,
);
target.searchParams.set("reason", "staff2fa");
return NextResponse.redirect(target);
}
if (isChangePassword && session && !session.mustChangePassword) {
return NextResponse.redirect(
new URL(`/${locale ?? DEFAULT_LOCALE}/dashboard`, req.nextUrl.origin),
);
}
// Already signed in: straight to where the sign-in would have led (an
// invite link, Live Mode...), not blindly to the dashboard.
if (isAuthPage && session) {
return NextResponse.redirect(
new URL(
signedInRedirectPath(
req.nextUrl.searchParams.get("callbackUrl"),
locale ?? DEFAULT_LOCALE,
LOCALES,
),
req.nextUrl.origin,
),
);
}
// Signed in, the account's language wins over the one in the URL. The
// public pages follow the browser, so without this someone whose
// account is in English but whose browser is in Portuguese signed in
// from /pt-BR/login and stayed in Portuguese all the way through the
// dashboard. Only page loads (GET): a server action posts to the page's
// URL and must not be redirected.
if (
isProtected &&
session &&
hasLocalePrefix &&
(req.method === "GET" || req.method === "HEAD") &&
isSupportedLocale(session.language) &&
session.language !== localeSegment
) {
const target = new URL(req.nextUrl.href);
target.pathname = `/${session.language}${pathWithoutLocale === "/" ? "" : pathWithoutLocale}`;
return redirectWithLocale(target, session.language);
}
// No locale in the URL: send the person to their preferred one rather
// than letting `Accept-Language` decide, and remember it so every later
// request — including ones this middleware never sees — agrees.
//
// When nothing authoritative is known, fall through to next-intl, which
// negotiates from the `Accept-Language` header as before. Guessing the
// default here instead would overwrite a perfectly good header match.
if (!hasLocalePrefix && locale) {
const target = new URL(req.nextUrl.href);
target.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
return redirectWithLocale(target, locale);
}
const response = intlMiddleware(req);
if (isProtected && session?.impersonator) {
response.headers.set(IMPERSONATION_HEADER, "1");
}
return response;
}
export const config = {
// Static assets must bypass this middleware entirely. next-intl's
// middleware treats any matched path as a page and 307-redirects it to
// add a locale prefix (e.g. "/sw.js" -> "/en/sw.js"), which breaks:
// - `navigator.serviceWorker.register("/sw.js")`, which receives a
// redirected response — disallowed by the Service Worker spec, so
// registration fails outright and offline support silently stops
// working, even after a previously-successful install.
// - the service worker's own `caches.addAll(["/offline.html"])`, which
// would be redirected to a locale-prefixed path that 404s.
// - the App Router's generated icon routes (`/icon0.svg`,
// `/apple-icon.png`, …), which exist only at the root.
//
// Only root-level files with a known static extension are skipped (see
// MIDDLEWARE_MATCHER in lib/csp.ts, which this literal must equal; Next.js
// reads it statically). A page URL that merely contains a dot, such as
// /pt-BR/dashboard/tours/abc.def, still goes through the auth gate.
// Skipped paths get the static fallback CSP from next.config.ts.
matcher: [
"/((?!api/|api$|_next/|[^/]+\\.(?:js|mjs|css|map|json|webmanifest|txt|xml|html|ico|png|jpe?g|gif|svg|webp|avif|woff2?|ttf|otf)$).*)",
],
};