@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,414 @@
|
|
|
1
|
+
import { canonicalLocale } from './languages/settings'
|
|
2
|
+
import { localePath } from './languages/routing'
|
|
3
|
+
import type { createLanguageRuntime } from './languages/runtime'
|
|
4
|
+
import { createManifest, type BlockSpec } from './defineBlocks'
|
|
5
|
+
import { PREVIEW_MAX_AGE_SECONDS, PREVIEW_TOKEN_COOKIE, PREVIEW_UNTIL_COOKIE } from './preview'
|
|
6
|
+
import { manifestFrom, type Site } from './defineSite'
|
|
7
|
+
import type { EditorBlock, ManifestCollection, PageDeclaration, SeedSection } from './contract'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The four routes every UnitNinety site mounts.
|
|
11
|
+
*
|
|
12
|
+
* Each is a factory returning a Next route handler, so a site's route file is one line
|
|
13
|
+
* and the logic is versioned here. These used to be copy-pasted per site, which meant
|
|
14
|
+
* a fix to one of them existed in exactly one repo and the next client silently got the
|
|
15
|
+
* old version.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here needs the platform's API key — these are the *inbound* half of the
|
|
18
|
+
* contract, authenticated with the tenant's `revalidateSecret`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Constant-time compare, so a wrong secret can't be found a byte at a time. */
|
|
22
|
+
const secretMatches = (provided: string, expected: string): boolean => {
|
|
23
|
+
if (provided.length !== expected.length) return false
|
|
24
|
+
let mismatch = 0
|
|
25
|
+
for (let index = 0; index < provided.length; index += 1) {
|
|
26
|
+
mismatch |= provided.charCodeAt(index) ^ expected.charCodeAt(index)
|
|
27
|
+
}
|
|
28
|
+
return mismatch === 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bearer = (request: Request): string => {
|
|
32
|
+
const header = request.headers.get('authorization') ?? ''
|
|
33
|
+
return header.startsWith('Bearer ') ? header.slice(7) : ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `GET /api/u90/manifest` — publish this site's content schema.
|
|
38
|
+
*
|
|
39
|
+
* export const GET = createManifestRoute({ blocks, pages })
|
|
40
|
+
*
|
|
41
|
+
* Authenticated, because the schema describes the client's editing surface and there is
|
|
42
|
+
* no reason for it to be public. The platform is the only caller.
|
|
43
|
+
*/
|
|
44
|
+
export const createManifestRoute = ({
|
|
45
|
+
blocks,
|
|
46
|
+
languageRuntime,
|
|
47
|
+
collections,
|
|
48
|
+
newPage,
|
|
49
|
+
pages,
|
|
50
|
+
routesAllPages,
|
|
51
|
+
secret = process.env.REVALIDATE_SECRET,
|
|
52
|
+
seed,
|
|
53
|
+
site,
|
|
54
|
+
}: {
|
|
55
|
+
languageRuntime?: ReturnType<typeof createLanguageRuntime>
|
|
56
|
+
blocks?: EditorBlock[]
|
|
57
|
+
/** Repeatable record types, from `defineCollections`. Their seeds ride along. */
|
|
58
|
+
collections?: ManifestCollection[]
|
|
59
|
+
/** The sections a client-added page starts with. See `SiteManifest.newPage`. */
|
|
60
|
+
newPage?: SeedSection[]
|
|
61
|
+
pages?: PageDeclaration[]
|
|
62
|
+
/** True when a catch-all serves every stored slug. See `SiteManifest.routesAllPages`. */
|
|
63
|
+
routesAllPages?: boolean
|
|
64
|
+
secret?: string
|
|
65
|
+
/**
|
|
66
|
+
* The spec passed to `defineBlocks`, so the manifest carries the site's committed
|
|
67
|
+
* copy as seed content. The platform stores it once, on a tenant's first pair —
|
|
68
|
+
* without it a fresh tenant's editor is a page of blanks while the site renders
|
|
69
|
+
* perfectly, because every component falls back to its committed copy.
|
|
70
|
+
*/
|
|
71
|
+
seed?: BlockSpec
|
|
72
|
+
/** A `defineSite` result. Supersedes `blocks`, `collections` and `seed`; pass one or the other. */
|
|
73
|
+
site?: Site<unknown>
|
|
74
|
+
}) => {
|
|
75
|
+
if (site) {
|
|
76
|
+
const legacyKey = blocks ? 'blocks' : collections ? 'collections' : seed ? 'seed' : undefined
|
|
77
|
+
if (legacyKey) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`createManifestRoute takes either \`site\` or \`${legacyKey}\`, not both. A site already carries its blocks, collections and seed.`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
} else if (!blocks) {
|
|
83
|
+
/*
|
|
84
|
+
* Neither form, which is the other half of the guard above and fails the same way:
|
|
85
|
+
* quietly. `blocks` is optional only so that `site` can replace it, so a route with
|
|
86
|
+
* neither serves `blocks: []` — a manifest the platform accepts and renders as an
|
|
87
|
+
* editor with nothing in it, which a client reads as their content having vanished.
|
|
88
|
+
* Both keys being absent is a mistake at the call site, so it is raised where the
|
|
89
|
+
* call is, at module load, rather than on the platform's first fetch.
|
|
90
|
+
*
|
|
91
|
+
* This also catches a legacy call that names `collections` or `seed` and forgets
|
|
92
|
+
* `blocks`, which used to serve an empty block list. That is the same empty editor, so
|
|
93
|
+
* the error is right — but a site that really does publish no blocks has to be able to
|
|
94
|
+
* say so, which is why the message names `blocks: []` rather than only the two keys.
|
|
95
|
+
*/
|
|
96
|
+
throw new Error(
|
|
97
|
+
"createManifestRoute needs either `site` (from defineSite) or `blocks` (from defineBlocks). With neither it publishes an empty schema, and the client's editor opens with no fields at all. If this site genuinely has no blocks, say so with `blocks: []`.",
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return async function GET(request: Request): Promise<Response> {
|
|
102
|
+
if (!secret) {
|
|
103
|
+
// Misconfiguration, not an auth failure — say so, because the platform surfaces
|
|
104
|
+
// this string to an operator during pairing.
|
|
105
|
+
return Response.json({ error: 'REVALIDATE_SECRET is not set on this site.' }, { status: 500 })
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (!secretMatches(bearer(request), secret)) {
|
|
109
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return Response.json(
|
|
113
|
+
{
|
|
114
|
+
...(site
|
|
115
|
+
? manifestFrom({ newPage, pages, routesAllPages, site })
|
|
116
|
+
: createManifest({
|
|
117
|
+
blocks: blocks ?? [],
|
|
118
|
+
collections,
|
|
119
|
+
newPage,
|
|
120
|
+
pages,
|
|
121
|
+
routesAllPages,
|
|
122
|
+
seed,
|
|
123
|
+
})),
|
|
124
|
+
...(languageRuntime?.languageVersion === 1 ? { languageVersion: 1 } : {}),
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
// The platform does its own tag-based caching; a cached response here would just
|
|
128
|
+
// make a deploy's new schema invisible for longer.
|
|
129
|
+
headers: { 'Cache-Control': 'no-store' },
|
|
130
|
+
},
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `POST /api/revalidate` — the platform telling this site something published.
|
|
137
|
+
*
|
|
138
|
+
* export const POST = createRevalidateRoute({ revalidateTag })
|
|
139
|
+
*
|
|
140
|
+
* `revalidateTag` is injected rather than imported, because it comes from
|
|
141
|
+
* `next/cache` in the consuming app and this package must not pin a Next version.
|
|
142
|
+
*
|
|
143
|
+
* Note the platform's POST is fire-and-forget with a short timeout: this route being
|
|
144
|
+
* down means a stale page, never a broken dashboard.
|
|
145
|
+
*/
|
|
146
|
+
export const createRevalidateRoute = ({
|
|
147
|
+
revalidateTag,
|
|
148
|
+
secret = process.env.REVALIDATE_SECRET,
|
|
149
|
+
}: {
|
|
150
|
+
/**
|
|
151
|
+
* Next's own `revalidateTag`. Both versions satisfy this: Next 16 requires a
|
|
152
|
+
* cache profile and types it as `string | CacheLifeConfig`, Next 15 takes the
|
|
153
|
+
* tag alone.
|
|
154
|
+
*
|
|
155
|
+
* `...rest: never[]` rather than `profile?: unknown` because the check runs the
|
|
156
|
+
* other way round — a function whose parameter is narrower than `unknown` is
|
|
157
|
+
* *not* assignable to one declaring `unknown`, so Next 16's signature was
|
|
158
|
+
* rejected outright. `never` accepts any parameter type, which is what
|
|
159
|
+
* "whatever your Next version wants" actually means in the type system.
|
|
160
|
+
*/
|
|
161
|
+
revalidateTag: (tag: string, ...rest: never[]) => unknown
|
|
162
|
+
secret?: string
|
|
163
|
+
}) =>
|
|
164
|
+
async function POST(request: Request): Promise<Response> {
|
|
165
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
166
|
+
collection?: string
|
|
167
|
+
secret?: string
|
|
168
|
+
slug?: null | string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Sent as a header *and* in the body, so it can be checked before parsing.
|
|
172
|
+
const provided = request.headers.get('x-revalidate-secret') ?? body.secret ?? ''
|
|
173
|
+
|
|
174
|
+
if (!secret || !secretMatches(provided, secret)) {
|
|
175
|
+
return Response.json({ error: 'Bad secret' }, { status: 401 })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const { collection, slug } = body
|
|
179
|
+
if (!collection) {
|
|
180
|
+
return Response.json({ error: 'No collection given' }, { status: 400 })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/*
|
|
184
|
+
* `{ expire: 0 }` so the purged entry is refetched on the next request rather than
|
|
185
|
+
* after a grace period — a publish the client just pressed should be visible now.
|
|
186
|
+
* Next 15 ignores the second argument; Next 16 requires it.
|
|
187
|
+
*
|
|
188
|
+
* One cast, because the profile's type is `CacheLifeConfig` in Next 16 and this
|
|
189
|
+
* package deliberately does not depend on Next to name it.
|
|
190
|
+
*/
|
|
191
|
+
const purge = revalidateTag as (tag: string, profile?: unknown) => unknown
|
|
192
|
+
|
|
193
|
+
purge(collection, { expire: 0 })
|
|
194
|
+
if (slug) purge(`${collection}:${slug}`, { expire: 0 })
|
|
195
|
+
|
|
196
|
+
return Response.json({ revalidated: true })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `GET /api/preview` — enter draft mode for one document.
|
|
201
|
+
*
|
|
202
|
+
* export const GET = createPreviewRoute({ cookies, draftMode, redirect })
|
|
203
|
+
*
|
|
204
|
+
* Next's own primitives are injected for the same reason as `revalidateTag`.
|
|
205
|
+
*
|
|
206
|
+
* The forwarded Payload token is stashed as a cookie so the page can fetch drafts *as
|
|
207
|
+
* the signed-in client* rather than with the site's own key. That matters: it means a
|
|
208
|
+
* preview shows what that person staged, and it expires with their session.
|
|
209
|
+
*/
|
|
210
|
+
export const createPreviewRoute = ({
|
|
211
|
+
cookies,
|
|
212
|
+
draftMode,
|
|
213
|
+
onAuthorised,
|
|
214
|
+
previewMaxAgeSeconds = PREVIEW_MAX_AGE_SECONDS,
|
|
215
|
+
redirect,
|
|
216
|
+
sameSite = process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
|
217
|
+
secret = process.env.REVALIDATE_SECRET,
|
|
218
|
+
}: {
|
|
219
|
+
cookies: () => Promise<{
|
|
220
|
+
set: (name: string, value: string, options?: Record<string, unknown>) => void
|
|
221
|
+
}>
|
|
222
|
+
draftMode: () => Promise<{ enable: () => void }>
|
|
223
|
+
/**
|
|
224
|
+
* Run after the secret is verified and before the redirect, with the cookie store.
|
|
225
|
+
*
|
|
226
|
+
* This exists for the private-preview gate every prospect site is behind. Without
|
|
227
|
+
* it, an editor opening Canvas is redirected to the site root, the gate rewrites
|
|
228
|
+
* that to the lock screen, and the dashboard reports the site as not responding to
|
|
229
|
+
* the editor — the schema still loads, so it looks like a bridge fault rather than
|
|
230
|
+
* a locked door. Knowing `REVALIDATE_SECRET` is a stronger proof than the gate's
|
|
231
|
+
* own code, so a site can mint its access cookie here.
|
|
232
|
+
*
|
|
233
|
+
* Called only on an authorised request. `secure` is passed through so the cookie
|
|
234
|
+
* matches the `sameSite` choice below — `none` requires `secure`.
|
|
235
|
+
*/
|
|
236
|
+
onAuthorised?: (
|
|
237
|
+
store: {
|
|
238
|
+
set: (name: string, value: string, options?: Record<string, unknown>) => void
|
|
239
|
+
},
|
|
240
|
+
options: { sameSite: 'lax' | 'none'; secure: boolean },
|
|
241
|
+
) => Promise<void> | void
|
|
242
|
+
/**
|
|
243
|
+
* How long this preview counts for. See `./preview` for why it must count for anything.
|
|
244
|
+
*
|
|
245
|
+
* Every entry restamps it, so an editor working through Canvas never meets the edge;
|
|
246
|
+
* the window is there for the browser that stops asking.
|
|
247
|
+
*/
|
|
248
|
+
previewMaxAgeSeconds?: number
|
|
249
|
+
redirect: (url: string) => never
|
|
250
|
+
/**
|
|
251
|
+
* `none` in production because the dashboard frames this site cross-origin, and a
|
|
252
|
+
* `lax` cookie is not sent in a third-party frame — the preview would silently show
|
|
253
|
+
* published content instead of the draft.
|
|
254
|
+
*/
|
|
255
|
+
sameSite?: 'lax' | 'none'
|
|
256
|
+
secret?: string
|
|
257
|
+
}) =>
|
|
258
|
+
async function GET(request: Request): Promise<Response> {
|
|
259
|
+
const { searchParams } = new URL(request.url)
|
|
260
|
+
|
|
261
|
+
if (!secret || !secretMatches(searchParams.get('secret') ?? '', secret)) {
|
|
262
|
+
return new Response('Bad secret', { status: 401 })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (searchParams.has('locale') && !canonicalLocale(searchParams.get('locale')))
|
|
266
|
+
return new Response('Invalid locale', { status: 400 })
|
|
267
|
+
if (
|
|
268
|
+
searchParams.has('languageRevision') &&
|
|
269
|
+
(!/^(0|[1-9]\d*)$/.test(searchParams.get('languageRevision') ?? '') ||
|
|
270
|
+
!Number.isSafeInteger(Number(searchParams.get('languageRevision'))))
|
|
271
|
+
)
|
|
272
|
+
return new Response('Invalid language revision', { status: 400 })
|
|
273
|
+
const draft = await draftMode()
|
|
274
|
+
draft.enable()
|
|
275
|
+
|
|
276
|
+
const store = await cookies()
|
|
277
|
+
const token = searchParams.get('token')
|
|
278
|
+
|
|
279
|
+
const shared = {
|
|
280
|
+
httpOnly: true,
|
|
281
|
+
maxAge: previewMaxAgeSeconds,
|
|
282
|
+
path: '/',
|
|
283
|
+
sameSite,
|
|
284
|
+
secure: sameSite === 'none',
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (token) {
|
|
288
|
+
/*
|
|
289
|
+
* `maxAge` rather than a session cookie. The doc above says the token "expires with
|
|
290
|
+
* their session", which is true of the JWT inside it and false of the cookie holding
|
|
291
|
+
* it: browsers restore sessions instead of ending them, so a session cookie is a
|
|
292
|
+
* permanent one with extra steps.
|
|
293
|
+
*/
|
|
294
|
+
store.set(PREVIEW_TOKEN_COOKIE, token, shared)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/*
|
|
298
|
+
* The stamp `resolvePreview` reads. Set on every entry, so the window tracks the last
|
|
299
|
+
* time somebody actually asked to preview rather than the first.
|
|
300
|
+
*/
|
|
301
|
+
store.set(PREVIEW_UNTIL_COOKIE, String(Date.now() + previewMaxAgeSeconds * 1000), shared)
|
|
302
|
+
|
|
303
|
+
await onAuthorised?.(store, { sameSite, secure: sameSite === 'none' })
|
|
304
|
+
|
|
305
|
+
/*
|
|
306
|
+
* An explicit route wins over the document's slug.
|
|
307
|
+
*
|
|
308
|
+
* The dashboard sends one for the screens that have no `pages` document behind them
|
|
309
|
+
* — a collection row's own page, `/projects/port-coogee`, is one route serving
|
|
310
|
+
* however many rows the client has. Without this there is no way to frame such a
|
|
311
|
+
* page in draft mode at all, so a client could never edit a project where it
|
|
312
|
+
* actually appears.
|
|
313
|
+
*
|
|
314
|
+
* Run through the same `safeReturnPath` as the exit route: it arrives in a query
|
|
315
|
+
* string, so it is not ours to trust even with the secret verified.
|
|
316
|
+
*/
|
|
317
|
+
const path = searchParams.get('path')
|
|
318
|
+
const locale = canonicalLocale(searchParams.get('locale'))
|
|
319
|
+
const previewPath = (logical: string) => {
|
|
320
|
+
const target = new URL(
|
|
321
|
+
locale ? localePath(logical, locale) : logical,
|
|
322
|
+
'https://preview.invalid',
|
|
323
|
+
)
|
|
324
|
+
const revision = searchParams.get('languageRevision')
|
|
325
|
+
if (locale && revision && /^\d+$/.test(revision))
|
|
326
|
+
target.searchParams.set('languageRevision', revision)
|
|
327
|
+
return target.pathname + target.search + target.hash
|
|
328
|
+
}
|
|
329
|
+
if (path) return redirect(previewPath(safeReturnPath(path)))
|
|
330
|
+
|
|
331
|
+
const slug = searchParams.get('slug') ?? ''
|
|
332
|
+
/*
|
|
333
|
+
* `return` even though `redirect` never returns: it is a destructured parameter, so
|
|
334
|
+
* TypeScript won't narrow the call to unreachable and would report the handler as
|
|
335
|
+
* missing a return. `never` is assignable to the declared type, so this is honest
|
|
336
|
+
* rather than a cast.
|
|
337
|
+
*/
|
|
338
|
+
return redirect(previewPath(slug && slug !== 'home' ? `/${slug}` : '/'))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Only same-origin paths, so `?to=` cannot be turned into an open redirect.
|
|
343
|
+
*
|
|
344
|
+
* `//evil.com` and `/\evil.com` are both browser-protocol-relative, so a leading slash
|
|
345
|
+
* is not on its own enough.
|
|
346
|
+
*/
|
|
347
|
+
const safeReturnPath = (value: null | string): string => {
|
|
348
|
+
if (!value || !value.startsWith('/')) return '/'
|
|
349
|
+
if (value.startsWith('//') || value.startsWith('/\\')) return '/'
|
|
350
|
+
return value
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* `GET /api/exit-preview` — leave draft mode.
|
|
355
|
+
*
|
|
356
|
+
* export const GET = createExitPreviewRoute({ cookies, draftMode, redirect })
|
|
357
|
+
*
|
|
358
|
+
* **Why this has to exist.** `createPreviewRoute` enables Next's draft mode and stashes
|
|
359
|
+
* the Payload token, and both are *session* cookies with no expiry. Nothing expires
|
|
360
|
+
* them and, until this route, nothing cleared them — so one click of Preview left that
|
|
361
|
+
* browser reading drafts on every page of the site until it was quit. The operator sees
|
|
362
|
+
* their unpublished edits on the live domain and reports, entirely reasonably, that the
|
|
363
|
+
* site changes before Publish is clicked. Visitors were never affected; the person
|
|
364
|
+
* checking was the only one who could see it.
|
|
365
|
+
*
|
|
366
|
+
* The pair with `createPreviewRoute` is the whole design, so the two live next to each
|
|
367
|
+
* other and the token is cleared with the same attributes it was set with.
|
|
368
|
+
*
|
|
369
|
+
* No secret, deliberately. Leaving draft mode is not a privileged act — the worst a
|
|
370
|
+
* stranger can do with this URL is take someone out of a mode they were not in.
|
|
371
|
+
*
|
|
372
|
+
* The site's own access cookie is left alone. A prospect site's private-preview gate is
|
|
373
|
+
* a different door, and slamming it on the way out would lock the operator off the site
|
|
374
|
+
* they were just editing.
|
|
375
|
+
*/
|
|
376
|
+
export const createExitPreviewRoute = ({
|
|
377
|
+
cookies,
|
|
378
|
+
draftMode,
|
|
379
|
+
redirect,
|
|
380
|
+
sameSite = process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
|
381
|
+
}: {
|
|
382
|
+
cookies: () => Promise<{
|
|
383
|
+
set: (name: string, value: string, options?: Record<string, unknown>) => void
|
|
384
|
+
}>
|
|
385
|
+
draftMode: () => Promise<{ disable: () => void }>
|
|
386
|
+
redirect: (url: string) => never
|
|
387
|
+
/** Matched to `createPreviewRoute`'s, since this clears what that one set. */
|
|
388
|
+
sameSite?: 'lax' | 'none'
|
|
389
|
+
}) =>
|
|
390
|
+
async function GET(request: Request): Promise<Response> {
|
|
391
|
+
const draft = await draftMode()
|
|
392
|
+
draft.disable()
|
|
393
|
+
|
|
394
|
+
/*
|
|
395
|
+
* Expired rather than deleted. A cookie is cleared only by a `Set-Cookie` whose
|
|
396
|
+
* path matches the one it was stored under, and this handler lives at
|
|
397
|
+
* `/api/exit-preview` — `delete(name)` would emit no path and the browser would
|
|
398
|
+
* scope it to `/api`, leaving the real `/` cookie in place and the fix looking
|
|
399
|
+
* like it silently did nothing.
|
|
400
|
+
*/
|
|
401
|
+
const store = await cookies()
|
|
402
|
+
const cleared = {
|
|
403
|
+
httpOnly: true,
|
|
404
|
+
maxAge: 0,
|
|
405
|
+
path: '/',
|
|
406
|
+
sameSite,
|
|
407
|
+
secure: sameSite === 'none',
|
|
408
|
+
}
|
|
409
|
+
store.set(PREVIEW_TOKEN_COOKIE, '', cleared)
|
|
410
|
+
// Cleared with the token, so leaving by the front door and lapsing agree on the state.
|
|
411
|
+
store.set(PREVIEW_UNTIL_COOKIE, '', cleared)
|
|
412
|
+
|
|
413
|
+
return redirect(safeReturnPath(new URL(request.url).searchParams.get('to')))
|
|
414
|
+
}
|