-
-
Notifications
You must be signed in to change notification settings - Fork 124
/
Copy pathmiddleware.js
296 lines (251 loc) · 11.9 KB
/
middleware.js
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 { NextResponse, URLPattern } from 'next/server'
import { cachedFetcher } from '@/lib/fetch'
const referrerPattern = new URLPattern({ pathname: ':pathname(*)/r/:referrer([\\w_]+)' })
const itemPattern = new URLPattern({ pathname: '/items/:id(\\d+){/:other(\\w+)}?' })
const profilePattern = new URLPattern({ pathname: '/:name([\\w_]+){/:type(\\w+)}?' })
const territoryPattern = new URLPattern({ pathname: '/~:name([\\w_]+){/*}?' })
// key for /r/... link referrers
const SN_REFERRER = 'sn_referrer'
// we use this to hold /r/... referrers through the redirect
const SN_REFERRER_NONCE = 'sn_referrer_nonce'
// key for referred pages
const SN_REFEREE_LANDING = 'sn_referee_landing'
const TERRITORY_PATHS = ['/~', '/recent', '/random', '/top', '/post', '/edit']
const NO_REWRITE_PATHS = ['/api', '/_next', '/_error', '/404', '/500', '/offline', '/static', '/items']
// fetch custom domain mappings from our API, caching it for 5 minutes
const getDomainMappingsCache = cachedFetcher(async function fetchDomainMappings () {
const url = `${process.env.NEXT_PUBLIC_URL}/api/domains/map`
try {
const response = await fetch(url)
if (!response.ok) {
console.error(`Cannot fetch domain mappings: ${response.status} ${response.statusText}`)
return null
}
const data = await response.json()
return Object.keys(data).length > 0 ? data : null
} catch (error) {
console.error('Cannot fetch domain mappings:', error)
return null
}
}, {
cacheExpiry: 300000, // 5 minutes cache
forceRefreshThreshold: 600000, // 10 minutes before force refresh
keyGenerator: () => 'domain_mappings'
})
export async function customDomainMiddleware (request, referrerResp) {
const host = request.headers.get('host')
const referer = request.headers.get('referer')
const url = request.nextUrl.clone()
const pathname = url.pathname
const mainDomain = process.env.NEXT_PUBLIC_URL + '/'
console.log('host', host)
console.log('mainDomain', mainDomain)
console.log('referer', referer)
const domainMapping = await getDomainMappingsCache()
const domainInfo = domainMapping?.[host.toLowerCase()]
if (!domainInfo) {
return NextResponse.redirect(new URL(pathname, mainDomain))
}
if (NO_REWRITE_PATHS.some(p => pathname.startsWith(p)) || pathname.includes('.')) {
return NextResponse.next()
}
console.log('pathname', pathname)
console.log('query', url.searchParams)
// if the url contains the territory path, remove it
if (pathname.startsWith(`/~${domainInfo.subName}`)) {
// remove the territory prefix from the path
const cleanPath = pathname.replace(`/~${domainInfo.subName}`, '') || '/'
console.log('Redirecting to clean path:', cleanPath)
return NextResponse.redirect(new URL(cleanPath + url.search, url.origin))
}
// if territory path, retain custom domain
if (pathname === '/' || TERRITORY_PATHS.some(p => pathname.startsWith(p))) {
// if coming from main domain, handle auth automatically
if (referer && referer === mainDomain) {
const authResp = customDomainAuthMiddleware(request, url)
if (authResp && authResp.status !== 200) {
// copy referrer cookies to auth redirect
for (const [key, value] of referrerResp.cookies.getAll()) {
authResp.cookies.set(key, value.value, value)
}
return authResp
}
}
const internalUrl = new URL(url)
// rewrite to the territory path if we're at the root
internalUrl.pathname = `/~${domainInfo.subName}${pathname === '/' ? '' : pathname}`
console.log('Rewrite to:', internalUrl.pathname)
// rewrite to the territory path
return NextResponse.rewrite(internalUrl)
}
// redirect to main domain for non-territory paths
// create redirect response but preserve referrer cookies
const redirectResp = NextResponse.redirect(new URL(pathname, mainDomain))
// copy referrer cookies
for (const [key, value] of referrerResp.cookies.getAll()) {
redirectResp.cookies.set(key, value.value, value)
}
return redirectResp
}
// TODO: dirty of previous iterations, refactor
// UNSAFE UNSAFE UNSAFE tokens are visible in the URL
export function customDomainAuthMiddleware (request, url) {
const pathname = url.pathname
const host = request.headers.get('host')
const authDomain = process.env.NEXT_PUBLIC_URL
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
const secure = process.env.NODE_ENV === 'development'
// check for session both in session token and in multi_auth cookie
const sessionCookieName = secure ? '__Secure-next-auth.session-token' : 'next-auth.session-token'
const multiAuthUserId = request.cookies.get('multi_auth.user-id')?.value
// 1. We have a session token directly, or
// 2. We have a multi_auth user ID and the corresponding multi_auth cookie
const hasActiveSession = !!request.cookies.get(sessionCookieName)?.value
const hasMultiAuthSession = multiAuthUserId && !!request.cookies.get(`multi_auth.${multiAuthUserId}`)?.value
const hasSession = hasActiveSession || hasMultiAuthSession
const response = NextResponse.next()
if (!hasSession && isCustomDomain) {
// TODO: original request url points to localhost, this is a workaround atm
const protocol = secure ? 'https' : 'http'
const originalDomain = `${protocol}://${host}`
const redirectTarget = `${originalDomain}${pathname}`
// Create the auth sync URL with the correct original domain
const syncUrl = new URL(`${authDomain}/api/auth/sync`)
syncUrl.searchParams.set('redirectUrl', redirectTarget)
console.log('AUTH: Redirecting to:', syncUrl.toString())
console.log('AUTH: With redirect back to:', redirectTarget)
const redirectResponse = NextResponse.redirect(syncUrl)
return redirectResponse
}
console.log('No redirect')
return response
}
function getContentReferrer (request, url) {
if (itemPattern.test(url)) {
let id = request.nextUrl.searchParams.get('commentId')
if (!id) {
({ id } = itemPattern.exec(url).pathname.groups)
}
return `item-${id}`
}
if (profilePattern.test(url)) {
const { name } = profilePattern.exec(url).pathname.groups
return `profile-${name}`
}
if (territoryPattern.test(url)) {
const { name } = territoryPattern.exec(url).pathname.groups
return `territory-${name}`
}
}
// we store the referrers in cookies for a future signup event
// we pass the referrers in the request headers so we can use them in referral rewards for logged in stackers
function referrerMiddleware (request) {
if (referrerPattern.test(request.url)) {
const { pathname, referrer } = referrerPattern.exec(request.url).pathname.groups
const url = new URL(pathname || '/', request.url)
url.search = request.nextUrl.search
url.hash = request.nextUrl.hash
const response = NextResponse.redirect(url)
// explicit referrers are set for a day and can only be overriden by other explicit
// referrers. Content referrers do not override explicit referrers because
// explicit referees might click around before signing up.
response.cookies.set(SN_REFERRER, referrer, { maxAge: 60 * 60 * 24 })
// we record the first page the user lands on and keep it for 24 hours
// in addition to the explicit referrer, this allows us to tell the referrer
// which share link the user clicked on
const contentReferrer = getContentReferrer(request, url)
if (contentReferrer) {
response.cookies.set(SN_REFEREE_LANDING, contentReferrer, { maxAge: 60 * 60 * 24 })
}
// store the explicit referrer for one page load
// this allows us to attribute both explicit and implicit referrers after the redirect
// e.g. items/<num>/r/<referrer> links should attribute both the item op and the referrer
// without this the /r/<referrer> would be lost on redirect
response.cookies.set(SN_REFERRER_NONCE, referrer, { maxAge: 1 })
return response
}
const contentReferrer = getContentReferrer(request, request.url)
// pass the referrers to SSR in the request headers for one day referrer attribution
const requestHeaders = new Headers(request.headers)
const referrers = [request.cookies.get(SN_REFERRER_NONCE)?.value, contentReferrer].filter(Boolean)
if (referrers.length) {
requestHeaders.set('x-stacker-news-referrer', referrers.join('; '))
}
const response = NextResponse.next({
request: {
headers: requestHeaders
}
})
// if we don't already have an explicit referrer, give them the content referrer as one
if (!request.cookies.has(SN_REFERRER) && contentReferrer) {
response.cookies.set(SN_REFERRER, contentReferrer, { maxAge: 60 * 60 * 24 })
}
return response
}
export function applySecurityHeaders (resp) {
const isDev = process.env.NODE_ENV === 'development'
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
// we want to load media from other localhost ports during development
const devSrc = isDev ? ' localhost:* http: ws:' : ''
// unsafe-eval is required during development due to react-refresh.js
// see https://github.com/vercel/next.js/issues/14221
const devScriptSrc = isDev ? " 'unsafe-eval'" : ''
const cspHeader = [
// if something is not explicitly allowed, we don't allow it.
"default-src 'self' a.stacker.news",
"font-src 'self' a.stacker.news",
// we want to load images from everywhere but we can limit to HTTPS at least
"img-src 'self' a.stacker.news m.stacker.news https: data: blob:" + devSrc,
"media-src 'self' a.stacker.news m.stacker.news https: blob:" + devSrc,
// Using nonces and strict-dynamic deploys a strict CSP.
// see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy.
// Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline
`script-src 'self' a.stacker.news 'unsafe-inline' 'wasm-unsafe-eval' 'nonce-${nonce}' 'strict-dynamic' https:` + devScriptSrc,
// unsafe-inline for styles is not ideal but okay if script-src is using nonces
"style-src 'self' a.stacker.news 'unsafe-inline'",
"manifest-src 'self'",
'frame-src www.youtube.com platform.twitter.com njump.me open.spotify.com rumble.com embed.wavlake.com bitcointv.com peertube.tv',
"connect-src 'self' https: wss:" + devSrc,
// disable dangerous plugins like Flash
"object-src 'none'",
// blocks injection of <base> tags
"base-uri 'none'",
// tell user agents to replace HTTP with HTTPS
isDev ? '' : 'upgrade-insecure-requests',
// prevents any domain from framing the content (defense against clickjacking attacks)
"frame-ancestors 'none'"
].join('; ')
resp.headers.set('Content-Security-Policy', cspHeader)
// for browsers that don't support CSP
resp.headers.set('X-Frame-Options', 'DENY')
// more useful headers
resp.headers.set('X-Content-Type-Options', 'nosniff')
resp.headers.set('Referrer-Policy', 'origin-when-cross-origin')
resp.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains')
return resp
}
export async function middleware (request) {
const host = request.headers.get('host')
const isCustomDomain = host !== process.env.NEXT_PUBLIC_URL.replace(/^https?:\/\//, '')
// First run referrer middleware to capture referrer data
const referrerResp = referrerMiddleware(request)
// If we're on a custom domain, handle that next
if (isCustomDomain) {
const customDomainResp = await customDomainMiddleware(request, referrerResp)
return applySecurityHeaders(customDomainResp)
}
return applySecurityHeaders(referrerResp)
}
export const config = {
matcher: [
// NextJS recommends to not add the CSP header to prefetches and static assets
// See https://nextjs.org/docs/app/building-your-application/configuring/content-security-policy
{
source: '/((?!api|_next/static|_error|404|500|offline|_next/image|_next/webpack-hmr|favicon.ico).*)',
missing: [
{ type: 'header', key: 'next-router-prefetch' },
{ type: 'header', key: 'purpose', value: 'prefetch' }
]
}
]
}