@unitninety/site-kit 0.0.0-stage → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1010 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +271 -0
- package/src/bridge/registry.ts +27 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +12 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
createHmac,
|
|
6
|
+
randomBytes,
|
|
7
|
+
randomUUID,
|
|
8
|
+
timingSafeEqual,
|
|
9
|
+
} from 'node:crypto'
|
|
10
|
+
import { ACCESS_COOKIE, HANDOFF_QUERY, LOCK_ENDPOINT, LOCK_PATH, safeReturnTo } from './contracts'
|
|
11
|
+
import type { PreviewLockAppearance, PreviewLockStatus } from './contracts'
|
|
12
|
+
|
|
13
|
+
export type PreviewLockOptions = {
|
|
14
|
+
platformUrl: string
|
|
15
|
+
apiKey: string
|
|
16
|
+
revalidateSecret: string
|
|
17
|
+
fetch?: typeof fetch
|
|
18
|
+
publicAssets?: readonly string[]
|
|
19
|
+
}
|
|
20
|
+
export type PreviewLockGate =
|
|
21
|
+
| { kind: 'response'; response: Response }
|
|
22
|
+
| { kind: 'next'; headers: Headers; requestHeaders: Headers; url: string }
|
|
23
|
+
const PRIVATE = {
|
|
24
|
+
'cache-control': 'private, no-store, max-age=0',
|
|
25
|
+
'x-robots-tag': 'noindex, nofollow, noarchive',
|
|
26
|
+
'referrer-policy': 'no-referrer',
|
|
27
|
+
}
|
|
28
|
+
const equal = (a: string, b: string) => {
|
|
29
|
+
const x = Buffer.from(a),
|
|
30
|
+
y = Buffer.from(b)
|
|
31
|
+
return x.length === y.length && timingSafeEqual(x, y)
|
|
32
|
+
}
|
|
33
|
+
const json = (body: unknown, status = 200, headers: Record<string, string> = {}) =>
|
|
34
|
+
Response.json(body, { status, headers: { ...PRIVATE, ...headers } })
|
|
35
|
+
const unavailable = () =>
|
|
36
|
+
json({ ok: false, error: 'Preview verification is unavailable. Please try again.' }, 503)
|
|
37
|
+
const tokenLike = (value: unknown): value is string =>
|
|
38
|
+
typeof value === 'string' && /^[A-Za-z0-9_.-]{1,4096}$/.test(value)
|
|
39
|
+
function cookie(request: Request, name: string): string | null {
|
|
40
|
+
const values = (request.headers.get('cookie') ?? '')
|
|
41
|
+
.split(';')
|
|
42
|
+
.map((x) => x.trim())
|
|
43
|
+
.filter((x) => x.startsWith(name + '='))
|
|
44
|
+
if (values.length !== 1) return null
|
|
45
|
+
const value = values[0].slice(name.length + 1)
|
|
46
|
+
return tokenLike(value) ? value : null
|
|
47
|
+
}
|
|
48
|
+
function statusValue(raw: unknown): PreviewLockStatus {
|
|
49
|
+
if (!raw || typeof raw !== 'object') throw Error('Invalid status')
|
|
50
|
+
const v = raw as PreviewLockStatus
|
|
51
|
+
if (
|
|
52
|
+
!['open', 'locked'].includes(v.state) ||
|
|
53
|
+
typeof v.protected !== 'boolean' ||
|
|
54
|
+
(v.state === 'locked' && !v.protected)
|
|
55
|
+
)
|
|
56
|
+
throw Error('Invalid status')
|
|
57
|
+
if (v.canvasVerified !== undefined && typeof v.canvasVerified !== 'boolean')
|
|
58
|
+
throw Error('Invalid Canvas proof')
|
|
59
|
+
if (v.appearance !== undefined) {
|
|
60
|
+
const a = v.appearance
|
|
61
|
+
for (const key of [
|
|
62
|
+
'partnerName',
|
|
63
|
+
'prospectName',
|
|
64
|
+
'heading',
|
|
65
|
+
'message',
|
|
66
|
+
'instructions',
|
|
67
|
+
'backgroundColor',
|
|
68
|
+
'textColor',
|
|
69
|
+
'accentColor',
|
|
70
|
+
] as const)
|
|
71
|
+
if (typeof a[key] !== 'string' || a[key].length > 4096) throw Error('Invalid appearance')
|
|
72
|
+
for (const key of ['partnerLogoUrl', 'prospectLogoUrl'] as const)
|
|
73
|
+
if (a[key] !== null && (typeof a[key] !== 'string' || a[key]!.length > 2048))
|
|
74
|
+
throw Error('Invalid logo')
|
|
75
|
+
for (const key of ['backgroundColor', 'textColor', 'accentColor'] as const)
|
|
76
|
+
if (!/^#[\da-f]{6}$/i.test(a[key])) throw Error('Invalid colour')
|
|
77
|
+
}
|
|
78
|
+
if (v.state === 'locked' && !v.appearance) throw Error('Missing appearance')
|
|
79
|
+
return v
|
|
80
|
+
}
|
|
81
|
+
async function boundedJSON(response: Response, limit = 16384): Promise<unknown> {
|
|
82
|
+
if (Number(response.headers.get('content-length') ?? 0) > limit || !response.body)
|
|
83
|
+
throw Error('Invalid body')
|
|
84
|
+
const reader = response.body.getReader(),
|
|
85
|
+
parts: Uint8Array[] = []
|
|
86
|
+
let length = 0
|
|
87
|
+
try {
|
|
88
|
+
for (;;) {
|
|
89
|
+
const { value, done } = await reader.read()
|
|
90
|
+
if (done) break
|
|
91
|
+
length += value.byteLength
|
|
92
|
+
if (length > limit) {
|
|
93
|
+
await reader.cancel()
|
|
94
|
+
throw Error('Body too large')
|
|
95
|
+
}
|
|
96
|
+
parts.push(value)
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
reader.releaseLock()
|
|
100
|
+
}
|
|
101
|
+
return JSON.parse(Buffer.concat(parts).toString('utf8'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Opt-in server boundary. Importing ordinary site-kit exports does not install this gate. */
|
|
105
|
+
export function createPreviewLock(options: PreviewLockOptions) {
|
|
106
|
+
const base = new URL(options.platformUrl.replace(/\/$/, '') + '/')
|
|
107
|
+
if (!['https:', 'http:'].includes(base.protocol) || !options.apiKey || !options.revalidateSecret)
|
|
108
|
+
throw Error('Preview lock configuration is incomplete')
|
|
109
|
+
const endpoint = new URL('api/u90/preview-lock', base),
|
|
110
|
+
fetcher = options.fetch ?? globalThis.fetch
|
|
111
|
+
const publicAssets = new Set(options.publicAssets ?? [])
|
|
112
|
+
for (const path of publicAssets)
|
|
113
|
+
if (
|
|
114
|
+
safeReturnTo(path) !== path ||
|
|
115
|
+
path.includes('?') ||
|
|
116
|
+
path.includes('#') ||
|
|
117
|
+
path.startsWith('/api/') ||
|
|
118
|
+
!/\.(?:svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf)$/i.test(path)
|
|
119
|
+
)
|
|
120
|
+
throw Error('Public branding assets must be exact local asset paths')
|
|
121
|
+
const key = createHash('sha256')
|
|
122
|
+
.update('u90-site-preview-handoff:v1:' + options.revalidateSecret)
|
|
123
|
+
.digest()
|
|
124
|
+
const mac = (v: string) => createHmac('sha256', key).update(v).digest('base64url')
|
|
125
|
+
function visitor(request: Request) {
|
|
126
|
+
const [id, signature] = (cookie(request, 'u90-preview-visitor') ?? '').split('.')
|
|
127
|
+
const authenticated = !!id && !!signature && equal(signature, mac(id))
|
|
128
|
+
const value = authenticated ? id : randomUUID()
|
|
129
|
+
return {
|
|
130
|
+
id: value,
|
|
131
|
+
authenticated,
|
|
132
|
+
cookie: `u90-preview-visitor=${value}.${mac(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function remote(init: RequestInit): Promise<{ response: Response; body: unknown }> {
|
|
136
|
+
const controller = new AbortController()
|
|
137
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
138
|
+
try {
|
|
139
|
+
return await Promise.race([
|
|
140
|
+
(async () => {
|
|
141
|
+
const response = await fetcher(endpoint, {
|
|
142
|
+
...init,
|
|
143
|
+
cache: 'no-store',
|
|
144
|
+
redirect: 'error',
|
|
145
|
+
signal: controller.signal,
|
|
146
|
+
headers: {
|
|
147
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
148
|
+
...Object.fromEntries(new Headers(init.headers)),
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
return { response, body: await boundedJSON(response) }
|
|
152
|
+
})(),
|
|
153
|
+
new Promise<never>((_, reject) => {
|
|
154
|
+
timer = setTimeout(() => {
|
|
155
|
+
controller.abort()
|
|
156
|
+
reject(Error('Timeout'))
|
|
157
|
+
}, 3000)
|
|
158
|
+
}),
|
|
159
|
+
])
|
|
160
|
+
} finally {
|
|
161
|
+
if (timer) clearTimeout(timer)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function handoff(request: Request): string | null {
|
|
165
|
+
const url = new URL(request.url),
|
|
166
|
+
encoded = url.searchParams.get(HANDOFF_QUERY)
|
|
167
|
+
if (!encoded || encoded.length > 8192) return null
|
|
168
|
+
try {
|
|
169
|
+
const [iv, data, tag, ...rest] = encoded.split('.')
|
|
170
|
+
if (rest.length) return null
|
|
171
|
+
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
|
|
172
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64url'))
|
|
173
|
+
const payload = JSON.parse(
|
|
174
|
+
Buffer.concat([
|
|
175
|
+
decipher.update(Buffer.from(data, 'base64url')),
|
|
176
|
+
decipher.final(),
|
|
177
|
+
]).toString(),
|
|
178
|
+
)
|
|
179
|
+
if (
|
|
180
|
+
!tokenLike(payload.token) ||
|
|
181
|
+
!Number.isFinite(payload.exp) ||
|
|
182
|
+
payload.exp <= Date.now() ||
|
|
183
|
+
payload.exp > Date.now() + 60000 ||
|
|
184
|
+
payload.path !== safeReturnTo(url.pathname + url.search)
|
|
185
|
+
)
|
|
186
|
+
return null
|
|
187
|
+
return payload.token
|
|
188
|
+
} catch {
|
|
189
|
+
return null
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function status(request: Request): Promise<PreviewLockStatus> {
|
|
193
|
+
const preview = handoff(request) ?? cookie(request, 'u90-preview-token'),
|
|
194
|
+
access = cookie(request, ACCESS_COOKIE)
|
|
195
|
+
const { response, body } = await remote({
|
|
196
|
+
method: 'GET',
|
|
197
|
+
headers: {
|
|
198
|
+
...(preview ? { 'X-U90-Preview-Token': preview } : {}),
|
|
199
|
+
...(access ? { 'X-U90-Preview-Access': access } : {}),
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
if (!response.ok) throw Error('Status unavailable')
|
|
203
|
+
return statusValue(body)
|
|
204
|
+
}
|
|
205
|
+
/** Opt-in draft adapter: verified Canvas access also works without Next's draft-mode cookie. */
|
|
206
|
+
async function draft(request: Request): Promise<{ enabled: boolean; token: string | null }> {
|
|
207
|
+
const token = handoff(request) ?? cookie(request, 'u90-preview-token')
|
|
208
|
+
if (!token?.startsWith('u90pt1.')) return { enabled: false, token: null }
|
|
209
|
+
const current = await status(request)
|
|
210
|
+
return current.canvasVerified === true
|
|
211
|
+
? { enabled: true, token }
|
|
212
|
+
: { enabled: false, token: null }
|
|
213
|
+
}
|
|
214
|
+
async function gate(request: Request): Promise<PreviewLockGate> {
|
|
215
|
+
const url = new URL(request.url),
|
|
216
|
+
path = url.pathname
|
|
217
|
+
const requestHeaders = new Headers(request.headers)
|
|
218
|
+
for (const name of ['x-u90-preview-token', 'x-u90-preview-access']) requestHeaders.delete(name)
|
|
219
|
+
const next = (headers: Record<string, string> = {}): PreviewLockGate => {
|
|
220
|
+
url.searchParams.delete(HANDOFF_QUERY)
|
|
221
|
+
return { kind: 'next', headers: new Headers(headers), requestHeaders, url: url.href }
|
|
222
|
+
}
|
|
223
|
+
if (path === LOCK_ENDPOINT && request.method === 'GET')
|
|
224
|
+
return {
|
|
225
|
+
kind: 'response',
|
|
226
|
+
response: equal(
|
|
227
|
+
request.headers.get('authorization') ?? '',
|
|
228
|
+
`Bearer ${options.revalidateSecret}`,
|
|
229
|
+
)
|
|
230
|
+
? json({ ready: true, version: 1 })
|
|
231
|
+
: json({ error: 'Unauthorised' }, 401),
|
|
232
|
+
}
|
|
233
|
+
if (path === LOCK_ENDPOINT && request.method === 'POST') return next(PRIVATE)
|
|
234
|
+
if (
|
|
235
|
+
[
|
|
236
|
+
'/api/u90/preview',
|
|
237
|
+
'/api/u90/revalidate',
|
|
238
|
+
'/api/u90/exit-preview',
|
|
239
|
+
'/api/u90/manifest',
|
|
240
|
+
].includes(path)
|
|
241
|
+
)
|
|
242
|
+
return next(PRIVATE)
|
|
243
|
+
if (
|
|
244
|
+
(request.method === 'GET' || request.method === 'HEAD') &&
|
|
245
|
+
(path.startsWith('/_next/static/') ||
|
|
246
|
+
path === '/_next/webpack-hmr' ||
|
|
247
|
+
path === '/favicon.ico' ||
|
|
248
|
+
publicAssets.has(path))
|
|
249
|
+
)
|
|
250
|
+
return next()
|
|
251
|
+
let current: PreviewLockStatus
|
|
252
|
+
try {
|
|
253
|
+
current = await status(request)
|
|
254
|
+
} catch {
|
|
255
|
+
return { kind: 'response', response: unavailable() }
|
|
256
|
+
}
|
|
257
|
+
if (path === LOCK_PATH) {
|
|
258
|
+
const entry = next(current.protected ? PRIVATE : {})
|
|
259
|
+
const identity = visitor(request)
|
|
260
|
+
if (current.protected && !identity.authenticated && entry.kind === 'next')
|
|
261
|
+
entry.headers.append('set-cookie', identity.cookie)
|
|
262
|
+
return entry
|
|
263
|
+
}
|
|
264
|
+
if (current.state === 'open') {
|
|
265
|
+
// Preserve verified Canvas context for layout and draft reads when browser cookies are blocked.
|
|
266
|
+
// This is a request-only cookie override, never an outgoing cookie or browser bypass flag.
|
|
267
|
+
const preview = handoff(request)
|
|
268
|
+
if (preview && current.canvasVerified === true) {
|
|
269
|
+
const existing = (requestHeaders.get('cookie') ?? '')
|
|
270
|
+
.split(';')
|
|
271
|
+
.map((x) => x.trim())
|
|
272
|
+
.filter((x) => x && !x.startsWith('u90-preview-token='))
|
|
273
|
+
requestHeaders.set('cookie', [...existing, `u90-preview-token=${preview}`].join('; '))
|
|
274
|
+
}
|
|
275
|
+
return next(current.protected || current.canvasVerified === true ? PRIVATE : {})
|
|
276
|
+
}
|
|
277
|
+
if (path.startsWith('/api/') || !['GET', 'HEAD'].includes(request.method))
|
|
278
|
+
return { kind: 'response', response: json({ error: 'Preview access required' }, 423) }
|
|
279
|
+
const destination = new URL(LOCK_PATH, url.origin)
|
|
280
|
+
destination.searchParams.set('returnTo', safeReturnTo(path + url.search))
|
|
281
|
+
return {
|
|
282
|
+
kind: 'response',
|
|
283
|
+
response: new Response(null, {
|
|
284
|
+
status: 307,
|
|
285
|
+
headers: { ...PRIVATE, location: destination.href },
|
|
286
|
+
}),
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async function unlock(request: Request): Promise<Response> {
|
|
290
|
+
if (request.method !== 'POST') return json({ error: 'Method not allowed' }, 405)
|
|
291
|
+
const origin = request.headers.get('origin')
|
|
292
|
+
if (
|
|
293
|
+
(origin && origin !== new URL(request.url).origin) ||
|
|
294
|
+
request.headers.get('sec-fetch-site') === 'cross-site'
|
|
295
|
+
)
|
|
296
|
+
return json({ error: 'Unauthorised origin' }, 403)
|
|
297
|
+
let input: { code: string; requestId: string; path: string }
|
|
298
|
+
try {
|
|
299
|
+
const raw = (await boundedJSON(
|
|
300
|
+
new Response(request.body, { headers: request.headers }),
|
|
301
|
+
4096,
|
|
302
|
+
)) as Partial<typeof input> | null
|
|
303
|
+
if (
|
|
304
|
+
!raw ||
|
|
305
|
+
typeof raw.code !== 'string' ||
|
|
306
|
+
!/^\d{6}$/.test(raw.code) ||
|
|
307
|
+
typeof raw.requestId !== 'string' ||
|
|
308
|
+
!/^[A-Za-z0-9_-][A-Za-z0-9_.:-]{7,127}$/.test(raw.requestId) ||
|
|
309
|
+
typeof raw.path !== 'string'
|
|
310
|
+
)
|
|
311
|
+
throw Error('Invalid body')
|
|
312
|
+
input = { code: raw.code, requestId: raw.requestId, path: safeReturnTo(raw.path) }
|
|
313
|
+
} catch {
|
|
314
|
+
return json({ ok: false, error: 'Enter a valid six-digit access code.' }, 400)
|
|
315
|
+
}
|
|
316
|
+
const identity = visitor(request),
|
|
317
|
+
visitorCookie = identity.cookie
|
|
318
|
+
if (!identity.authenticated) {
|
|
319
|
+
const response = json(
|
|
320
|
+
{ ok: false, error: 'Allow cookies for this site, then try again.' },
|
|
321
|
+
428,
|
|
322
|
+
)
|
|
323
|
+
response.headers.append('set-cookie', visitorCookie)
|
|
324
|
+
return response
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
const { response, body } = await remote({
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: {
|
|
330
|
+
'content-type': 'application/json',
|
|
331
|
+
'X-U90-Visitor': identity.id,
|
|
332
|
+
...(cookie(request, ACCESS_COOKIE)
|
|
333
|
+
? { 'X-U90-Preview-Access': cookie(request, ACCESS_COOKIE)! }
|
|
334
|
+
: {}),
|
|
335
|
+
},
|
|
336
|
+
body: JSON.stringify(input),
|
|
337
|
+
})
|
|
338
|
+
if ([400, 401, 429].includes(response.status)) {
|
|
339
|
+
const reply = json(
|
|
340
|
+
{
|
|
341
|
+
ok: false,
|
|
342
|
+
error:
|
|
343
|
+
response.status === 429
|
|
344
|
+
? 'Please wait before trying again.'
|
|
345
|
+
: 'That code was not accepted.',
|
|
346
|
+
},
|
|
347
|
+
response.status,
|
|
348
|
+
)
|
|
349
|
+
reply.headers.append('set-cookie', visitorCookie)
|
|
350
|
+
const retry = response.headers.get('retry-after')
|
|
351
|
+
if (retry && /^\d{1,4}$/.test(retry)) reply.headers.set('retry-after', retry)
|
|
352
|
+
return reply
|
|
353
|
+
}
|
|
354
|
+
const result = body as { ok?: unknown; token?: unknown; maxAge?: unknown }
|
|
355
|
+
if (
|
|
356
|
+
!response.ok ||
|
|
357
|
+
result?.ok !== true ||
|
|
358
|
+
!tokenLike(result.token) ||
|
|
359
|
+
typeof result.maxAge !== 'number' ||
|
|
360
|
+
!Number.isInteger(result.maxAge) ||
|
|
361
|
+
result.maxAge < 1 ||
|
|
362
|
+
result.maxAge > 3600
|
|
363
|
+
)
|
|
364
|
+
throw Error('Invalid unlock')
|
|
365
|
+
const reply = json({ ok: true, returnTo: input.path })
|
|
366
|
+
reply.headers.append(
|
|
367
|
+
'set-cookie',
|
|
368
|
+
`${ACCESS_COOKIE}=${result.token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${result.maxAge}${new URL(request.url).protocol === 'https:' ? '; Secure' : ''}`,
|
|
369
|
+
)
|
|
370
|
+
reply.headers.append('set-cookie', visitorCookie)
|
|
371
|
+
return reply
|
|
372
|
+
} catch {
|
|
373
|
+
const reply = unavailable()
|
|
374
|
+
reply.headers.append('set-cookie', visitorCookie)
|
|
375
|
+
return reply
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/** Called only inside the existing authenticated preview handler, before its redirect. */
|
|
379
|
+
async function previewHandoff(request: Request): Promise<string | null> {
|
|
380
|
+
const url = new URL(request.url),
|
|
381
|
+
token = url.searchParams.get('token')
|
|
382
|
+
if (
|
|
383
|
+
url.pathname !== '/api/u90/preview' ||
|
|
384
|
+
!equal(url.searchParams.get('secret') ?? '', options.revalidateSecret) ||
|
|
385
|
+
!tokenLike(token) ||
|
|
386
|
+
!token.startsWith('u90pt1.')
|
|
387
|
+
)
|
|
388
|
+
return null
|
|
389
|
+
const path = safeReturnTo(url.searchParams.get('path'))
|
|
390
|
+
const target = new URL(path, url.origin)
|
|
391
|
+
try {
|
|
392
|
+
const { response, body } = await remote({
|
|
393
|
+
method: 'GET',
|
|
394
|
+
headers: { 'X-U90-Preview-Token': token },
|
|
395
|
+
})
|
|
396
|
+
if (!response.ok || statusValue(body).canvasVerified !== true) return null
|
|
397
|
+
const iv = randomBytes(12),
|
|
398
|
+
cipher = createCipheriv('aes-256-gcm', key, iv)
|
|
399
|
+
const data = Buffer.concat([
|
|
400
|
+
cipher.update(
|
|
401
|
+
JSON.stringify({ token, path: target.pathname + target.search, exp: Date.now() + 60000 }),
|
|
402
|
+
),
|
|
403
|
+
cipher.final(),
|
|
404
|
+
])
|
|
405
|
+
target.searchParams.set(
|
|
406
|
+
HANDOFF_QUERY,
|
|
407
|
+
`${iv.toString('base64url')}.${data.toString('base64url')}.${cipher.getAuthTag().toString('base64url')}`,
|
|
408
|
+
)
|
|
409
|
+
return target.pathname + target.search + target.hash
|
|
410
|
+
} catch {
|
|
411
|
+
return null
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return { gate, status, draft, unlock, previewHandoff, safeReturnTo }
|
|
415
|
+
}
|
|
416
|
+
export type { PreviewLockAppearance, PreviewLockStatus }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Adds `env.U90_REDIRECTS_SNAPSHOT` to a Next config: the platform's redirect rules as read at build time. */
|
|
2
|
+
export function withRedirectsSnapshot<C extends { env?: Record<string, string | undefined> }>(
|
|
3
|
+
config: C,
|
|
4
|
+
options?: { env?: NodeJS.ProcessEnv; fetch?: typeof fetch },
|
|
5
|
+
): Promise<C>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bakes the platform's redirect rules into a build, so a fresh deploy applies them from its first
|
|
3
|
+
* request: `export default async () => withRedirectsSnapshot(nextConfig)`, a form every config file type accepts. The rules are read from the v1
|
|
4
|
+
* read API with the site's key and handed to the proxy as `env.U90_REDIRECTS_SNAPSHOT`. With no
|
|
5
|
+
* `PLATFORM_URL` the snapshot is empty. A platform that will not answer, or takes more than 10 s, stops the build.
|
|
6
|
+
*/
|
|
7
|
+
export async function withRedirectsSnapshot(config, { env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
8
|
+
const base = env.PLATFORM_URL ? env.PLATFORM_URL.replace(/\/+$/, '') : ''
|
|
9
|
+
let snapshot = { rules: [], generatedAt: new Date(0).toISOString() }
|
|
10
|
+
if (base) {
|
|
11
|
+
// The timeout covers the body as well as the headers: a platform that stalls mid-reply stops the build the same way.
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetchImpl(`${base}/api/u90/v1/redirects`, { headers: { authorization: `Bearer ${env.PAYLOAD_API_KEY ?? ''}` }, cache: 'no-store', signal: AbortSignal.timeout(10000) })
|
|
14
|
+
if (!res.ok) throw new Error(`The platform answered ${res.status} for the site's redirect rules, so the build stops rather than deploy without them.`)
|
|
15
|
+
snapshot = await res.json()
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err instanceof Error && err.name === 'TimeoutError') throw new Error('The platform timed out after 10 s on the site\'s redirect rules, so the build stops rather than deploy without them.')
|
|
18
|
+
throw err
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { ...config, env: { ...(config.env ?? {}), U90_REDIRECTS_SNAPSHOT: JSON.stringify(snapshot) } }
|
|
22
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Every run of `/` and `\` in a path as one `/`. */
|
|
2
|
+
export const collapseSeparators = (path: string): string => path.replace(/[/\\]+/g, '/')
|
|
3
|
+
|
|
4
|
+
/** A redirect's `Location`, or why none is sent: `unusable` when no URL can be made of the destination, `unsafe` when it would leave the site or land under `/_next/`. */
|
|
5
|
+
export type SiteLocation = { location: URL } | { refused: 'unusable' | 'unsafe' }
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `Location` a proxy sends for `destination`, made absolute against the request.
|
|
9
|
+
* Next's proxy adapter reads the response's `Location` with `new NextURL(location)`, which throws on a relative one (a 500),
|
|
10
|
+
* so the address is made absolute against the request. Next then rewrites a same-host `Location` back to a relative one:
|
|
11
|
+
* its path, query and hash. A same-origin path that starts with `//` or `/\` therefore reaches the browser as `//host`, another
|
|
12
|
+
* site. Dot segments resolve only here (`/.//evil.com` becomes `//evil.com`), so the resolved path is checked, not the built
|
|
13
|
+
* string. Next also strips a leading part of that path first: a basePath, the default locale (`/en//evil.com`), or
|
|
14
|
+
* `/_next/data/<buildId>` (`/_next/data///evil.com.json`), which turns an interior `//` into a leading one. So every run of
|
|
15
|
+
* `/` and `\` in a same-origin path becomes one `/`, and a same-origin path under `/_next/` is refused.
|
|
16
|
+
* A destination on another origin is returned as given.
|
|
17
|
+
*/
|
|
18
|
+
export function siteLocation(destination: string, requestUrl: URL): SiteLocation {
|
|
19
|
+
let location: URL
|
|
20
|
+
try {
|
|
21
|
+
location = new URL(destination, requestUrl)
|
|
22
|
+
} catch {
|
|
23
|
+
return { refused: 'unusable' }
|
|
24
|
+
}
|
|
25
|
+
if (location.origin === requestUrl.origin) {
|
|
26
|
+
const collapsed = collapseSeparators(location.pathname)
|
|
27
|
+
if (collapsed !== location.pathname) location.pathname = collapsed
|
|
28
|
+
if (location.origin !== requestUrl.origin || /[/\\]{2}/.test(location.pathname) || location.pathname.startsWith('/_next/')) return { refused: 'unsafe' }
|
|
29
|
+
}
|
|
30
|
+
return { location }
|
|
31
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { RedirectRule, RedirectStatus } from './wire'
|
|
2
|
+
|
|
3
|
+
/** Drop every trailing `/`. A loop, not `/\/+$/`: that pattern backtracks, so it is quadratic on a long run of slashes in a visitor's path. */
|
|
4
|
+
const dropTrailingSlashes = (path: string): string => {
|
|
5
|
+
let end = path.length
|
|
6
|
+
while (end > 0 && path.charCodeAt(end - 1) === 47) end -= 1
|
|
7
|
+
return path.slice(0, end)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Normalise a path so both sides of a comparison agree: percent-decode (a malformed escape keeps the input), NFC,
|
|
12
|
+
* strip the trailing slash except at the root, lowercase. The steps are ordered: a percent-encoded combining mark
|
|
13
|
+
* only becomes comparable to its precomposed form once it is decoded. Stored rule sources go through this too.
|
|
14
|
+
*/
|
|
15
|
+
export const normalisePath = (input: string): string => {
|
|
16
|
+
let path = input
|
|
17
|
+
try {
|
|
18
|
+
path = decodeURIComponent(path)
|
|
19
|
+
} catch {
|
|
20
|
+
// A bare `%` or `%zz` throws; the input is kept so one bad row does not fail the whole set.
|
|
21
|
+
}
|
|
22
|
+
path = path.normalize('NFC')
|
|
23
|
+
if (path.length > 1 && path.endsWith('/')) path = dropTrailingSlashes(path)
|
|
24
|
+
if (path === '') path = '/'
|
|
25
|
+
return path.toLowerCase()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip the trailing slash as `normalisePath` does and nothing else, so the raw path can be walked segment for segment with the normalised one. */
|
|
29
|
+
const trimTrailingSlash = (path: string): string => {
|
|
30
|
+
if (path.length > 1 && path.endsWith('/')) {
|
|
31
|
+
const trimmed = dropTrailingSlashes(path)
|
|
32
|
+
return trimmed === '' ? '/' : trimmed
|
|
33
|
+
}
|
|
34
|
+
return path
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Make a destination safe for a `Location` header, which is a ByteString: a character above U+00FF or a bare CR or LF throws.
|
|
39
|
+
* Each run outside printable ASCII is percent-encoded; printable ASCII, `%` included, is left alone so an already-encoded
|
|
40
|
+
* remainder is not encoded twice. A run that cannot be encoded (a lone surrogate) is dropped.
|
|
41
|
+
*/
|
|
42
|
+
export const encodeLocation = (destination: string): string =>
|
|
43
|
+
destination.replace(/[^!-~]+/gu, (run) => {
|
|
44
|
+
try {
|
|
45
|
+
return encodeURIComponent(run)
|
|
46
|
+
} catch {
|
|
47
|
+
return ''
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
/** Split a request target at the first `?`. `query` keeps its `?`. */
|
|
52
|
+
export const splitQuery = (target: string): { path: string; query: string } => {
|
|
53
|
+
const mark = target.indexOf('?')
|
|
54
|
+
if (mark === -1) return { path: target, query: '' }
|
|
55
|
+
return { path: target.slice(0, mark), query: target.slice(mark) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `:name` and `*` segments a pattern declares, in order. A `*` is listed as `'*'`. */
|
|
59
|
+
export const patternParams = (pattern: string): string[] => {
|
|
60
|
+
const found: string[] = []
|
|
61
|
+
for (const segment of pattern.split('/')) {
|
|
62
|
+
if (segment.startsWith(':') && segment.length > 1) found.push(segment.slice(1))
|
|
63
|
+
else if (segment === '*') found.push('*')
|
|
64
|
+
}
|
|
65
|
+
return found
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A same-site destination (one `/` then anything but another `/`) keeps exactly one leading separator. A remainder or a
|
|
70
|
+
* capture can start with `/` or `\`: a raw `//`, or a `%2F` or `%5C` that decoding turned into one. A browser reads a
|
|
71
|
+
* `Location` of `//host` or `/\host` as another site. Any other destination, an absolute one included, is left as stored.
|
|
72
|
+
* This tidies the built string only: a dot segment (`/.//host`) still resolves to `//host`. The guarantee is the proxy's
|
|
73
|
+
* check of the resolved `Location`.
|
|
74
|
+
*/
|
|
75
|
+
const keepOnSite = (stored: string, built: string): string => {
|
|
76
|
+
if (!stored.startsWith('/') || stored.startsWith('//')) return built
|
|
77
|
+
let start = 0
|
|
78
|
+
while (start < built.length && (built[start] === '/' || built[start] === '\\')) start += 1
|
|
79
|
+
return start > 1 ? `/${built.slice(start)}` : built
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Substitute a pattern rule's captures into its destination. */
|
|
83
|
+
export const applyCaptures = (destination: string, captures: Record<string, string>): string =>
|
|
84
|
+
destination
|
|
85
|
+
.split('/')
|
|
86
|
+
.map((segment) => {
|
|
87
|
+
if (segment === '*') return captures['*'] ?? ''
|
|
88
|
+
if (segment.startsWith(':') && segment.length > 1) return captures[segment.slice(1)] ?? segment
|
|
89
|
+
return segment
|
|
90
|
+
})
|
|
91
|
+
.join('/')
|
|
92
|
+
|
|
93
|
+
export type RedirectMatch = {
|
|
94
|
+
destination: string
|
|
95
|
+
status: RedirectStatus
|
|
96
|
+
/** The rule that won. */
|
|
97
|
+
rule: RedirectRule
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type Compiled = { exact: Map<string, RedirectRule>; prefix: RedirectRule[]; pattern: RedirectRule[] }
|
|
101
|
+
|
|
102
|
+
const compiledFor = new WeakMap<readonly RedirectRule[], Compiled>()
|
|
103
|
+
|
|
104
|
+
/** Sources are normalised again here: a rule stored before a change to `normalisePath` must still be reachable. */
|
|
105
|
+
const compile = (rules: readonly RedirectRule[]): Compiled => {
|
|
106
|
+
const exact = new Map<string, RedirectRule>()
|
|
107
|
+
const prefix: RedirectRule[] = []
|
|
108
|
+
const pattern: RedirectRule[] = []
|
|
109
|
+
for (const rule of rules) {
|
|
110
|
+
const normalised: RedirectRule = { ...rule, source: normalisePath(rule.source) }
|
|
111
|
+
if (normalised.matchType === 'exact') exact.set(normalised.source, normalised)
|
|
112
|
+
else if (normalised.matchType === 'prefix') prefix.push(normalised)
|
|
113
|
+
else pattern.push(normalised)
|
|
114
|
+
}
|
|
115
|
+
// Longest source first, then string order, so the result does not depend on row order.
|
|
116
|
+
prefix.sort((a, b) => b.source.length - a.source.length || a.source.localeCompare(b.source))
|
|
117
|
+
pattern.sort((a, b) => patternParams(a.source).length - patternParams(b.source).length || b.source.length - a.source.length || a.source.localeCompare(b.source))
|
|
118
|
+
return { exact, prefix, pattern }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Match one pattern against the normalised path, segment by segment, with no regular expression built from the pattern.
|
|
123
|
+
* Captures are taken from `emit`, the raw segments, since normalising decodes and lowercases and a capture is emitted.
|
|
124
|
+
*/
|
|
125
|
+
const matchPattern = (pattern: string, path: string, emit: string[]): Record<string, string> | null => {
|
|
126
|
+
const patternSegments = pattern.split('/')
|
|
127
|
+
const pathSegments = path.split('/')
|
|
128
|
+
const captures: Record<string, string> = {}
|
|
129
|
+
for (let index = 0; index < patternSegments.length; index += 1) {
|
|
130
|
+
const expected = patternSegments[index] ?? ''
|
|
131
|
+
if (expected === '*' && index === patternSegments.length - 1) {
|
|
132
|
+
captures['*'] = emit.slice(index).join('/')
|
|
133
|
+
return captures
|
|
134
|
+
}
|
|
135
|
+
const actual = pathSegments[index]
|
|
136
|
+
if (actual === undefined) return null
|
|
137
|
+
if (expected.startsWith(':') && expected.length > 1) {
|
|
138
|
+
if (actual === '') return null
|
|
139
|
+
captures[expected.slice(1)] = emit[index] ?? actual
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (expected === '*') {
|
|
143
|
+
if (actual === '') return null
|
|
144
|
+
captures['*'] = emit[index] ?? actual
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (expected !== actual) return null
|
|
148
|
+
}
|
|
149
|
+
return pathSegments.length === patternSegments.length ? captures : null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve a request target (a path with an optional query) against a rule set. Exact rules win, then prefix rules
|
|
154
|
+
* (longest first), then patterns (fewest parameters, then longest source). The rule set is compiled once per array.
|
|
155
|
+
*
|
|
156
|
+
* Matching reads the normalised path; the destination is built from the raw one, so captures and a prefix remainder
|
|
157
|
+
* keep their encoding and case. When decoding introduces a separator (`%2F`) the segment counts differ and the
|
|
158
|
+
* normalised segments are used.
|
|
159
|
+
*/
|
|
160
|
+
export const matchRedirect = (rules: readonly RedirectRule[], target: string): RedirectMatch | null => {
|
|
161
|
+
let compiled = compiledFor.get(rules)
|
|
162
|
+
if (!compiled) {
|
|
163
|
+
compiled = compile(rules)
|
|
164
|
+
compiledFor.set(rules, compiled)
|
|
165
|
+
}
|
|
166
|
+
const { path, query } = splitQuery(target)
|
|
167
|
+
const normalised = normalisePath(path)
|
|
168
|
+
const rawSegments = trimTrailingSlash(path).split('/')
|
|
169
|
+
const normalisedSegments = normalised.split('/')
|
|
170
|
+
const outputSegments = rawSegments.length === normalisedSegments.length ? rawSegments : normalisedSegments
|
|
171
|
+
|
|
172
|
+
const carry = (rule: RedirectRule, destination: string): RedirectMatch => {
|
|
173
|
+
if (rule.status === 410) return { destination: '', status: 410, rule }
|
|
174
|
+
const onSite = keepOnSite(rule.destination, destination)
|
|
175
|
+
// A destination with its own query keeps it and drops the request's: merging two query strings gives duplicate keys.
|
|
176
|
+
const withQuery = rule.preserveQuery && query && !onSite.includes('?') ? onSite + query : onSite
|
|
177
|
+
return { destination: encodeLocation(withQuery), status: rule.status, rule }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const exact = compiled.exact.get(normalised)
|
|
181
|
+
if (exact) return carry(exact, exact.destination)
|
|
182
|
+
|
|
183
|
+
for (const rule of compiled.prefix) {
|
|
184
|
+
// A prefix ends at a segment boundary: `/blog` does not match `/blogroll`.
|
|
185
|
+
if (normalised === rule.source || normalised.startsWith(`${rule.source}/`)) {
|
|
186
|
+
const consumed = rule.source.split('/').length
|
|
187
|
+
const rest = outputSegments.length > consumed ? `/${outputSegments.slice(consumed).join('/')}` : ''
|
|
188
|
+
const destination = rule.destination.endsWith('/') && rest.startsWith('/') ? rule.destination.slice(0, -1) + rest : rule.destination + rest
|
|
189
|
+
return carry(rule, destination)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const rule of compiled.pattern) {
|
|
194
|
+
const captures = matchPattern(rule.source, normalised, outputSegments)
|
|
195
|
+
if (captures) return carry(rule, applyCaptures(rule.destination, captures))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return null
|
|
199
|
+
}
|