@payload-onpage/plugin 0.0.0-stage → 0.2.0
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/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/access.d.ts +20 -0
- package/dist/access.d.ts.map +1 -0
- package/dist/access.js +51 -0
- package/dist/access.js.map +1 -0
- package/dist/admin/OnPageAdminControls.d.ts +9 -0
- package/dist/admin/OnPageAdminControls.d.ts.map +1 -0
- package/dist/admin/OnPageAdminControls.js +59 -0
- package/dist/admin/OnPageAdminControls.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/endpoints.d.ts +11 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +223 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/hooks.d.ts +38 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +42 -0
- package/dist/hooks.js.map +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +53 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +59 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/schema.d.ts +34 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +209 -0
- package/dist/schema.js.map +1 -0
- package/dist/tokens.d.ts +18 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +24 -0
- package/dist/tokens.js.map +1 -0
- package/dist/translations.d.ts +45 -0
- package/dist/translations.d.ts.map +1 -0
- package/dist/translations.js +44 -0
- package/dist/translations.js.map +1 -0
- package/package.json +65 -4
- package/src/access.ts +65 -0
- package/src/admin/OnPageAdminControls.tsx +99 -0
- package/src/admin/index.ts +2 -0
- package/src/endpoints.ts +269 -0
- package/src/hooks.ts +79 -0
- package/src/http.ts +57 -0
- package/src/index.ts +167 -0
- package/src/options.ts +65 -0
- package/src/paths.ts +59 -0
- package/src/schema.ts +251 -0
- package/src/tokens.ts +46 -0
- package/src/translations.ts +45 -0
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plugin's endpoints, under `${api}${routePrefix}` (default /api/onpage):
|
|
3
|
+
*
|
|
4
|
+
* GET /schema/:kind/:slug editable field schema for the signed-in editor
|
|
5
|
+
* POST /enter start editing or previewing a document's page
|
|
6
|
+
* POST /mode switch the current page between editing and previewing
|
|
7
|
+
* POST /exit leave edit/preview mode
|
|
8
|
+
* POST /preview-link create a sealed, expiring read-only link (JSON)
|
|
9
|
+
* GET /preview?token= open a preview link
|
|
10
|
+
*
|
|
11
|
+
* State-changing endpoints are same-origin POSTs (CSRF-safe). Every
|
|
12
|
+
* authorization decision goes through ./access (Payload's own access rules).
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
parseRefKey,
|
|
16
|
+
pathnameOf,
|
|
17
|
+
refKey,
|
|
18
|
+
safePath,
|
|
19
|
+
samePath,
|
|
20
|
+
SEAL_PURPOSE,
|
|
21
|
+
seal,
|
|
22
|
+
type DocumentSchema,
|
|
23
|
+
type OnPageMode,
|
|
24
|
+
type SessionAdapter,
|
|
25
|
+
} from '@payload-onpage/core'
|
|
26
|
+
import type { CollectionConfig, Endpoint, GlobalConfig, PayloadRequest } from 'payload'
|
|
27
|
+
import { hasAutosaveEnabled, hasDraftsEnabled, getAutosaveInterval } from 'payload/shared'
|
|
28
|
+
import { canEditDocument, canUseOnPage, documentPermissions } from './access.ts'
|
|
29
|
+
import {
|
|
30
|
+
isSecure,
|
|
31
|
+
json,
|
|
32
|
+
originOf,
|
|
33
|
+
queryOf,
|
|
34
|
+
readRef,
|
|
35
|
+
redirectTo,
|
|
36
|
+
rejectCrossSite,
|
|
37
|
+
text,
|
|
38
|
+
} from './http.ts'
|
|
39
|
+
import type { Entry, OnPageOptions } from './options.ts'
|
|
40
|
+
import { resolvePath } from './paths.ts'
|
|
41
|
+
import { relationTargets, serializeFields } from './schema.ts'
|
|
42
|
+
import { createPreviewToken, verifyPreviewToken } from './tokens.ts'
|
|
43
|
+
|
|
44
|
+
export interface EndpointContext {
|
|
45
|
+
options: OnPageOptions
|
|
46
|
+
prefix: string
|
|
47
|
+
apiRoute: string
|
|
48
|
+
entry: (kind: string, slug: string) => Entry | undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DAY = 24 * 3600
|
|
52
|
+
|
|
53
|
+
export const secretOf = (req: PayloadRequest, options: OnPageOptions): string =>
|
|
54
|
+
options.secret ?? req.payload.config.secret
|
|
55
|
+
|
|
56
|
+
/** Name of the auth cookie an editor's mode is bound to. */
|
|
57
|
+
const authCookieOf = (req: PayloadRequest, options: OnPageOptions): string =>
|
|
58
|
+
options.authCookie ?? `${req.payload.config.cookiePrefix || 'payload'}-token`
|
|
59
|
+
|
|
60
|
+
function requireSession(options: OnPageOptions): SessionAdapter {
|
|
61
|
+
if (!options.session)
|
|
62
|
+
throw new Error(
|
|
63
|
+
'payload-onpage: pass `session` (e.g. nextSession()) to use the enter, mode, exit and preview endpoints',
|
|
64
|
+
)
|
|
65
|
+
return options.session
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
69
|
+
const { options, prefix, apiRoute } = ctx
|
|
70
|
+
|
|
71
|
+
const schema: Endpoint = {
|
|
72
|
+
path: `${prefix}/schema/:kind/:slug`,
|
|
73
|
+
method: 'get',
|
|
74
|
+
handler: async (req) => {
|
|
75
|
+
const kind = req.routeParams?.kind === 'global' ? 'global' : 'collection'
|
|
76
|
+
const slug = typeof req.routeParams?.slug === 'string' ? req.routeParams.slug : ''
|
|
77
|
+
const entry = ctx.entry(kind, slug)
|
|
78
|
+
const cfg: CollectionConfig | GlobalConfig | undefined =
|
|
79
|
+
kind === 'global'
|
|
80
|
+
? req.payload.globals.config.find((g) => g.slug === slug)
|
|
81
|
+
: req.payload.collections[slug]?.config
|
|
82
|
+
if (!entry || !cfg) return json({ error: 'Not enabled for on-page editing' }, 404)
|
|
83
|
+
if (!(await canUseOnPage(req, options))) return json({ error: 'Forbidden' }, 403)
|
|
84
|
+
|
|
85
|
+
const perms = await documentPermissions(
|
|
86
|
+
req,
|
|
87
|
+
kind === 'global' ? { kind, slug } : { kind, slug },
|
|
88
|
+
)
|
|
89
|
+
const pending: Promise<void>[] = []
|
|
90
|
+
const all = serializeFields(cfg.fields, {
|
|
91
|
+
i18n: req.i18n,
|
|
92
|
+
blocks: req.payload.config.blocks,
|
|
93
|
+
permissions: perms?.fields,
|
|
94
|
+
defaults: { req, pending },
|
|
95
|
+
})
|
|
96
|
+
await Promise.all(pending)
|
|
97
|
+
const fields = entry.options.fields
|
|
98
|
+
? all.filter((f) => entry.options.fields!.includes(f.name))
|
|
99
|
+
: all
|
|
100
|
+
|
|
101
|
+
const relations: NonNullable<DocumentSchema['relations']> = {}
|
|
102
|
+
for (const target of relationTargets(fields)) {
|
|
103
|
+
const c = req.payload.collections[target]?.config
|
|
104
|
+
if (!c) continue
|
|
105
|
+
relations[target] = {
|
|
106
|
+
titleField: c.admin?.useAsTitle ?? 'id',
|
|
107
|
+
label: typeof c.labels?.singular === 'string' ? c.labels.singular : target,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const localization = req.payload.config.localization
|
|
111
|
+
const labelOf = (v: unknown) => (typeof v === 'string' ? v : slug)
|
|
112
|
+
const body: DocumentSchema = {
|
|
113
|
+
kind,
|
|
114
|
+
slug,
|
|
115
|
+
labels:
|
|
116
|
+
kind === 'global'
|
|
117
|
+
? {
|
|
118
|
+
singular: labelOf((cfg as GlobalConfig).label),
|
|
119
|
+
plural: labelOf((cfg as GlobalConfig).label),
|
|
120
|
+
}
|
|
121
|
+
: {
|
|
122
|
+
singular: labelOf((cfg as CollectionConfig).labels?.singular),
|
|
123
|
+
plural: labelOf((cfg as CollectionConfig).labels?.plural),
|
|
124
|
+
},
|
|
125
|
+
editable: fields.map((f) => f.name),
|
|
126
|
+
fields,
|
|
127
|
+
drafts: hasDraftsEnabled(cfg),
|
|
128
|
+
...(hasAutosaveEnabled(cfg) ? { autosaveInterval: getAutosaveInterval(cfg) } : {}),
|
|
129
|
+
...(kind === 'collection' && (cfg as CollectionConfig).admin?.useAsTitle
|
|
130
|
+
? { useAsTitle: (cfg as CollectionConfig).admin!.useAsTitle }
|
|
131
|
+
: {}),
|
|
132
|
+
...(Object.keys(relations).length ? { relations } : {}),
|
|
133
|
+
...(localization
|
|
134
|
+
? {
|
|
135
|
+
defaultLocale: localization.defaultLocale,
|
|
136
|
+
locales: localization.locales.map((l) => (typeof l === 'string' ? l : l.code)),
|
|
137
|
+
}
|
|
138
|
+
: {}),
|
|
139
|
+
}
|
|
140
|
+
return json(body)
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const enter: Endpoint = {
|
|
145
|
+
path: `${prefix}/enter`,
|
|
146
|
+
method: 'post',
|
|
147
|
+
handler: async (req) => {
|
|
148
|
+
const refused = rejectCrossSite(req)
|
|
149
|
+
if (refused) return refused
|
|
150
|
+
const q = queryOf(req)
|
|
151
|
+
const ref = readRef({ kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
|
|
152
|
+
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
153
|
+
if (!ref || !entry) return text('Unknown document', 400)
|
|
154
|
+
if (!req.user) return text('Sign in to the admin first', 401)
|
|
155
|
+
if (!(await canEditDocument(req, ref, options)))
|
|
156
|
+
return text('You cannot edit this document', 403)
|
|
157
|
+
const locale = q.get('locale') ?? undefined
|
|
158
|
+
const path = safePath(q.get('path')) ?? (await resolvePath(req, ref, entry, locale))
|
|
159
|
+
if (!path) return text('This document has no page to open', 404)
|
|
160
|
+
const mode: OnPageMode = q.get('mode') === 'view' ? 'view' : 'edit'
|
|
161
|
+
await requireSession(options).enable(mode, {
|
|
162
|
+
secure: isSecure(req),
|
|
163
|
+
bindTo: authCookieOf(req, options),
|
|
164
|
+
})
|
|
165
|
+
return redirectTo(req, path)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mode: Endpoint = {
|
|
170
|
+
path: `${prefix}/mode`,
|
|
171
|
+
method: 'post',
|
|
172
|
+
handler: async (req) => {
|
|
173
|
+
const refused = rejectCrossSite(req)
|
|
174
|
+
if (refused) return refused
|
|
175
|
+
if (!(await canUseOnPage(req, options))) return text('Sign in as an editor first', 401)
|
|
176
|
+
const q = queryOf(req)
|
|
177
|
+
await requireSession(options).enable(q.get('mode') === 'view' ? 'view' : 'edit', {
|
|
178
|
+
secure: isSecure(req),
|
|
179
|
+
bindTo: authCookieOf(req, options),
|
|
180
|
+
})
|
|
181
|
+
return redirectTo(req, q.get('path'))
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const exit: Endpoint = {
|
|
186
|
+
path: `${prefix}/exit`,
|
|
187
|
+
method: 'post',
|
|
188
|
+
handler: async (req) => {
|
|
189
|
+
const refused = rejectCrossSite(req)
|
|
190
|
+
if (refused) return refused
|
|
191
|
+
await requireSession(options).disable()
|
|
192
|
+
return redirectTo(req, queryOf(req).get('path'))
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const previewLink: Endpoint = {
|
|
197
|
+
path: `${prefix}/preview-link`,
|
|
198
|
+
method: 'post',
|
|
199
|
+
handler: async (req) => {
|
|
200
|
+
const refused = rejectCrossSite(req)
|
|
201
|
+
if (refused) return refused
|
|
202
|
+
if (!req.headers.get('content-type')?.includes('application/json'))
|
|
203
|
+
return json({ error: 'Expected JSON' }, 415)
|
|
204
|
+
const body = ((await req.json?.().catch(() => null)) ?? {}) as Record<string, unknown>
|
|
205
|
+
const ref = readRef(body)
|
|
206
|
+
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
207
|
+
if (!ref || !entry) return json({ error: 'Unknown document' }, 400)
|
|
208
|
+
if (!(await canEditDocument(req, ref, options))) return json({ error: 'Forbidden' }, 403)
|
|
209
|
+
const locale = typeof body.locale === 'string' ? body.locale : undefined
|
|
210
|
+
// The path is always derived on the server: a link opens this document's page and nothing else.
|
|
211
|
+
const path = await resolvePath(req, ref, entry, locale)
|
|
212
|
+
if (!path) return json({ error: 'This document has no page' }, 404)
|
|
213
|
+
const ttl = options.previewLinkTTL ?? DAY
|
|
214
|
+
const { token, payload } = await createPreviewToken(
|
|
215
|
+
{ ref: refKey(ref), path, locale },
|
|
216
|
+
secretOf(req, options),
|
|
217
|
+
ttl,
|
|
218
|
+
)
|
|
219
|
+
return json({
|
|
220
|
+
url: `${originOf(req)}${apiRoute}${prefix}/preview?token=${encodeURIComponent(token)}`,
|
|
221
|
+
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
222
|
+
id: payload.jti,
|
|
223
|
+
})
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const preview: Endpoint = {
|
|
228
|
+
path: `${prefix}/preview`,
|
|
229
|
+
method: 'get',
|
|
230
|
+
handler: async (req) => {
|
|
231
|
+
const invalid = () => text('This preview link is invalid or has expired.', 403)
|
|
232
|
+
const secret = secretOf(req, options)
|
|
233
|
+
const token = await verifyPreviewToken(queryOf(req).get('token') ?? '', secret)
|
|
234
|
+
const ref = token && parseRefKey(token.ref)
|
|
235
|
+
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
236
|
+
if (!token || !ref || !entry) return invalid()
|
|
237
|
+
// Still the document's page? (It may have been deleted, or its slug changed.)
|
|
238
|
+
const current = await resolvePath(req, ref, entry, token.locale)
|
|
239
|
+
if (!current || !samePath(current, token.path)) return invalid()
|
|
240
|
+
if (
|
|
241
|
+
options.validatePreviewLink &&
|
|
242
|
+
!(await options.validatePreviewLink({
|
|
243
|
+
req,
|
|
244
|
+
ref,
|
|
245
|
+
path: token.path,
|
|
246
|
+
issuedAt: token.iat,
|
|
247
|
+
id: token.jti,
|
|
248
|
+
}))
|
|
249
|
+
) {
|
|
250
|
+
return invalid()
|
|
251
|
+
}
|
|
252
|
+
const expiresAt = new Date(token.exp * 1000)
|
|
253
|
+
await requireSession(options).enableLink({
|
|
254
|
+
secure: isSecure(req),
|
|
255
|
+
path: pathnameOf(token.path),
|
|
256
|
+
expiresAt,
|
|
257
|
+
seal: (draftSecret) =>
|
|
258
|
+
seal(
|
|
259
|
+
{ path: pathnameOf(token.path), ref: token.ref, exp: token.exp, draft: draftSecret },
|
|
260
|
+
secret,
|
|
261
|
+
SEAL_PURPOSE.previewCookie,
|
|
262
|
+
),
|
|
263
|
+
})
|
|
264
|
+
return redirectTo(req, token.path, 307)
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return [schema, enter, mode, exit, previewLink, preview]
|
|
269
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection/global hooks added by the plugin:
|
|
3
|
+
* - afterRead: auto-mapping markers, only on reads that ask (`context.onpage`)
|
|
4
|
+
* - beforeValidate/beforeChange: strip markers from every write, so they can't be stored
|
|
5
|
+
* - beforeChange: stale-write guard (409) when the editor sends the version it last saw
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
encodeDocument,
|
|
9
|
+
HEADER,
|
|
10
|
+
refKey,
|
|
11
|
+
stegaCleanDeep,
|
|
12
|
+
type EncodeFilter,
|
|
13
|
+
type SchemaField,
|
|
14
|
+
} from '@payload-onpage/core'
|
|
15
|
+
import { APIError, type PayloadRequest } from 'payload'
|
|
16
|
+
|
|
17
|
+
/** True when a read asked for auto-mapping markers (see `onpageContext()`). */
|
|
18
|
+
export const wantsMarkers = (context: unknown): boolean =>
|
|
19
|
+
!!(context && typeof context === 'object' && (context as Record<string, unknown>).onpage === true)
|
|
20
|
+
|
|
21
|
+
export const stripMarkers = ({ data }: { data?: unknown }) => (data ? stegaCleanDeep(data) : data)
|
|
22
|
+
|
|
23
|
+
export function encodeOnRead(opts: {
|
|
24
|
+
kind: 'collection' | 'global'
|
|
25
|
+
slug: string
|
|
26
|
+
fields: () => SchemaField[]
|
|
27
|
+
only?: string[]
|
|
28
|
+
filter?: EncodeFilter
|
|
29
|
+
}) {
|
|
30
|
+
return ({
|
|
31
|
+
doc,
|
|
32
|
+
req,
|
|
33
|
+
context,
|
|
34
|
+
}: {
|
|
35
|
+
doc: Record<string, unknown>
|
|
36
|
+
req: PayloadRequest
|
|
37
|
+
context: unknown
|
|
38
|
+
}) => {
|
|
39
|
+
if (!wantsMarkers(context) || req.locale === 'all' || !doc) return doc
|
|
40
|
+
const ref =
|
|
41
|
+
opts.kind === 'global'
|
|
42
|
+
? refKey({ kind: 'global', slug: opts.slug })
|
|
43
|
+
: doc.id != null
|
|
44
|
+
? refKey({ kind: 'collection', slug: opts.slug, id: doc.id as string | number })
|
|
45
|
+
: null
|
|
46
|
+
return ref
|
|
47
|
+
? encodeDocument(doc, opts.fields(), ref, { only: opts.only, filter: opts.filter })
|
|
48
|
+
: doc
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Optimistic concurrency: the editor sends `x-onpage-if-unmodified-since` with
|
|
54
|
+
* the `updatedAt` it last saw; if the document changed since, the write fails
|
|
55
|
+
* with 409 and the editor offers to reload. Writes without the header (admin,
|
|
56
|
+
* API, other tools) are untouched.
|
|
57
|
+
*/
|
|
58
|
+
export const guardStaleWrite = <T>({
|
|
59
|
+
data,
|
|
60
|
+
req,
|
|
61
|
+
originalDoc,
|
|
62
|
+
}: {
|
|
63
|
+
data: T
|
|
64
|
+
req: PayloadRequest
|
|
65
|
+
originalDoc?: { updatedAt?: unknown }
|
|
66
|
+
}): T => {
|
|
67
|
+
const seen = req.headers?.get(HEADER.ifUnmodifiedSince)
|
|
68
|
+
const current = originalDoc?.updatedAt
|
|
69
|
+
if (!seen || typeof current !== 'string') return data
|
|
70
|
+
if (new Date(seen).getTime() !== new Date(current).getTime()) {
|
|
71
|
+
throw new APIError(
|
|
72
|
+
'This document was changed by someone else since you loaded it.',
|
|
73
|
+
409,
|
|
74
|
+
undefined,
|
|
75
|
+
true,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return data
|
|
79
|
+
}
|
package/src/http.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Request helpers shared by the endpoints. */
|
|
2
|
+
import { parseRefKey, safePath, type DocRef } from '@payload-onpage/core'
|
|
3
|
+
import type { PayloadRequest } from 'payload'
|
|
4
|
+
|
|
5
|
+
/** The site origin: `serverURL` when configured, else the request's own origin. */
|
|
6
|
+
export function originOf(req: PayloadRequest): string {
|
|
7
|
+
const configured = req.payload.config.serverURL
|
|
8
|
+
if (configured) return configured.replace(/\/$/, '')
|
|
9
|
+
try {
|
|
10
|
+
return new URL(req.url ?? '').origin
|
|
11
|
+
} catch {
|
|
12
|
+
const host = req.headers.get('x-forwarded-host') ?? req.headers.get('host') ?? 'localhost'
|
|
13
|
+
const proto = req.headers.get('x-forwarded-proto') ?? 'http'
|
|
14
|
+
return `${proto}://${host}`
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const isSecure = (req: PayloadRequest): boolean => originOf(req).startsWith('https:')
|
|
19
|
+
|
|
20
|
+
export const queryOf = (req: PayloadRequest): URLSearchParams =>
|
|
21
|
+
new URL(req.url ?? '/', 'http://localhost').searchParams
|
|
22
|
+
|
|
23
|
+
export const text = (body: string, status: number): Response =>
|
|
24
|
+
new Response(body, {
|
|
25
|
+
status,
|
|
26
|
+
headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export const json = (body: unknown, status = 200): Response =>
|
|
30
|
+
Response.json(body, { status, headers: { 'cache-control': 'private, no-store' } })
|
|
31
|
+
|
|
32
|
+
/** Redirect to a same-origin path (anything unsafe becomes "/"). 303 turns a POST into a GET. */
|
|
33
|
+
export const redirectTo = (req: PayloadRequest, path: unknown, status: 303 | 307 = 303): Response =>
|
|
34
|
+
Response.redirect(new URL(safePath(path) ?? '/', originOf(req)), status)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* CSRF guard for state-changing endpoints: a POST from this site (Origin, or
|
|
38
|
+
* Sec-Fetch-Site when Origin is absent). Returns an error response or null.
|
|
39
|
+
*/
|
|
40
|
+
export function rejectCrossSite(req: PayloadRequest): Response | null {
|
|
41
|
+
if (req.method?.toUpperCase() !== 'POST') return text('Method not allowed', 405)
|
|
42
|
+
const origin = req.headers.get('origin')
|
|
43
|
+
if (origin) return origin === originOf(req) ? null : text('Cross-site request refused', 403)
|
|
44
|
+
const site = req.headers.get('sec-fetch-site')
|
|
45
|
+
return site === 'same-origin' || site === 'none' ? null : text('Cross-site request refused', 403)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A document ref from `kind`, `slug`, `id` query/body values. */
|
|
49
|
+
export function readRef(values: { kind?: unknown; slug?: unknown; id?: unknown }): DocRef | null {
|
|
50
|
+
const slug = typeof values.slug === 'string' ? values.slug : ''
|
|
51
|
+
if (!slug) return null
|
|
52
|
+
if (values.kind === 'global') return { kind: 'global', slug }
|
|
53
|
+
const id = values.id
|
|
54
|
+
if (typeof id === 'number') return { kind: 'collection', slug, id }
|
|
55
|
+
if (typeof id !== 'string' || !id) return null
|
|
56
|
+
return parseRefKey(`c:${slug}:${id}`)
|
|
57
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @payload-onpage/plugin — the Payload side of payload-onpage.
|
|
3
|
+
*
|
|
4
|
+
* plugins: [onpage({ collections: ['pages'], globals: ['header'], session: nextSession() })]
|
|
5
|
+
*
|
|
6
|
+
* Adds the endpoints in ./endpoints, auto-mapping on reads that ask for it,
|
|
7
|
+
* marker stripping and a stale-write guard on every write, and "Edit on page" /
|
|
8
|
+
* "Copy preview link" controls in the admin. Saves from the editor go through
|
|
9
|
+
* Payload's REST API, so access control, hooks, validation, drafts and versions
|
|
10
|
+
* apply unchanged.
|
|
11
|
+
*/
|
|
12
|
+
import { DEFAULTS, type SchemaField } from '@payload-onpage/core'
|
|
13
|
+
import type { CollectionConfig, Config, GlobalConfig, Plugin } from 'payload'
|
|
14
|
+
import { createEndpoints } from './endpoints.ts'
|
|
15
|
+
import { encodeOnRead, guardStaleWrite, stripMarkers } from './hooks.ts'
|
|
16
|
+
import { normalizeEntries, type Entry, type OnPageOptions } from './options.ts'
|
|
17
|
+
import { serializeFields } from './schema.ts'
|
|
18
|
+
import { translations } from './translations.ts'
|
|
19
|
+
|
|
20
|
+
const APPLIED = 'payload-onpage'
|
|
21
|
+
|
|
22
|
+
type Translations = Record<string, Record<string, unknown>>
|
|
23
|
+
/** Ours first; the site's own translations (any language, any key) win. */
|
|
24
|
+
function mergeTranslations(
|
|
25
|
+
ours: Translations,
|
|
26
|
+
theirs: unknown,
|
|
27
|
+
): NonNullable<Config['i18n']>['translations'] {
|
|
28
|
+
const out: Translations = { ...ours }
|
|
29
|
+
for (const [lang, table] of Object.entries((theirs ?? {}) as Translations)) {
|
|
30
|
+
const mine = out[lang] ?? {}
|
|
31
|
+
out[lang] = {
|
|
32
|
+
...mine,
|
|
33
|
+
...table,
|
|
34
|
+
onpage: { ...(mine.onpage as object), ...(table.onpage ?? {}) },
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function onpage(options: OnPageOptions): Plugin {
|
|
41
|
+
return (incoming: Config): Config => {
|
|
42
|
+
if (options.enabled === false || incoming.custom?.[APPLIED]) return incoming
|
|
43
|
+
const prefix = options.routePrefix ?? DEFAULTS.routePrefix
|
|
44
|
+
const apiRoute = incoming.routes?.api ?? DEFAULTS.apiRoute
|
|
45
|
+
const entries = [
|
|
46
|
+
...normalizeEntries(options.collections, 'collection'),
|
|
47
|
+
...normalizeEntries(options.globals, 'global'),
|
|
48
|
+
]
|
|
49
|
+
const byKey = new Map(entries.map((e) => [`${e.kind}:${e.slug}`, e]))
|
|
50
|
+
|
|
51
|
+
const withHooks = <T extends CollectionConfig | GlobalConfig>(cfg: T, entry: Entry): T => {
|
|
52
|
+
let fields: SchemaField[] | undefined
|
|
53
|
+
// Encoding only needs names, types and editors: serialize once, lazily, without request context.
|
|
54
|
+
const lazyFields = () => (fields ??= serializeFields(cfg.fields, { blocks: incoming.blocks }))
|
|
55
|
+
const hooks = { ...(cfg.hooks ?? {}) } as Record<string, unknown[]>
|
|
56
|
+
hooks.beforeValidate = [...(hooks.beforeValidate ?? []), stripMarkers]
|
|
57
|
+
hooks.beforeChange = [...(hooks.beforeChange ?? []), stripMarkers, guardStaleWrite]
|
|
58
|
+
hooks.afterRead = [
|
|
59
|
+
...(hooks.afterRead ?? []),
|
|
60
|
+
encodeOnRead({
|
|
61
|
+
kind: entry.kind,
|
|
62
|
+
slug: cfg.slug,
|
|
63
|
+
fields: lazyFields,
|
|
64
|
+
only: entry.options.fields,
|
|
65
|
+
filter: options.encode,
|
|
66
|
+
}),
|
|
67
|
+
]
|
|
68
|
+
return { ...cfg, hooks }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const control = (kind: Entry['kind'], slug: string) => ({
|
|
72
|
+
path: '@payload-onpage/plugin/admin#OnPageAdminControls',
|
|
73
|
+
clientProps: { kind, slug, prefix },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const withCollectionAdmin = (c: CollectionConfig): CollectionConfig => {
|
|
77
|
+
const edit = c.admin?.components?.edit ?? {}
|
|
78
|
+
return {
|
|
79
|
+
...c,
|
|
80
|
+
admin: {
|
|
81
|
+
...c.admin,
|
|
82
|
+
components: {
|
|
83
|
+
...c.admin?.components,
|
|
84
|
+
edit: {
|
|
85
|
+
...edit,
|
|
86
|
+
beforeDocumentControls: [
|
|
87
|
+
...(edit.beforeDocumentControls ?? []),
|
|
88
|
+
control('collection', c.slug),
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const withGlobalAdmin = (g: GlobalConfig): GlobalConfig => {
|
|
96
|
+
const elements = g.admin?.components?.elements ?? {}
|
|
97
|
+
return {
|
|
98
|
+
...g,
|
|
99
|
+
admin: {
|
|
100
|
+
...g.admin,
|
|
101
|
+
components: {
|
|
102
|
+
...g.admin?.components,
|
|
103
|
+
elements: {
|
|
104
|
+
...elements,
|
|
105
|
+
beforeDocumentControls: [
|
|
106
|
+
...(elements.beforeDocumentControls ?? []),
|
|
107
|
+
control('global', g.slug),
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const missing = entries.filter((e) =>
|
|
116
|
+
e.kind === 'global'
|
|
117
|
+
? !incoming.globals?.some((g) => g.slug === e.slug)
|
|
118
|
+
: !incoming.collections?.some((c) => c.slug === e.slug),
|
|
119
|
+
)
|
|
120
|
+
if (missing.length) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`payload-onpage: unknown ${missing.map((e) => `${e.kind} "${e.slug}"`).join(', ')}`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
...incoming,
|
|
128
|
+
custom: { ...incoming.custom, [APPLIED]: true },
|
|
129
|
+
i18n: {
|
|
130
|
+
...incoming.i18n,
|
|
131
|
+
translations: mergeTranslations(translations, incoming.i18n?.translations),
|
|
132
|
+
},
|
|
133
|
+
collections: (incoming.collections ?? []).map((c) => {
|
|
134
|
+
const entry = byKey.get(`collection:${c.slug}`)
|
|
135
|
+
return entry ? withCollectionAdmin(withHooks(c, entry)) : c
|
|
136
|
+
}),
|
|
137
|
+
globals: (incoming.globals ?? []).map((g) => {
|
|
138
|
+
const entry = byKey.get(`global:${g.slug}`)
|
|
139
|
+
return entry ? withGlobalAdmin(withHooks(g, entry)) : g
|
|
140
|
+
}),
|
|
141
|
+
endpoints: [
|
|
142
|
+
...(incoming.endpoints ?? []),
|
|
143
|
+
...createEndpoints({
|
|
144
|
+
options,
|
|
145
|
+
prefix,
|
|
146
|
+
apiRoute,
|
|
147
|
+
entry: (kind, slug) => byKey.get(`${kind}:${slug}`),
|
|
148
|
+
}),
|
|
149
|
+
],
|
|
150
|
+
onInit: async (payload) => {
|
|
151
|
+
if (process.env.NODE_ENV === 'production' && !payload.config.serverURL) {
|
|
152
|
+
payload.logger.warn(
|
|
153
|
+
'payload-onpage: set `serverURL` in production so redirects, preview links and Secure cookies use the public origin.',
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
await incoming.onInit?.(payload)
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export type { OnPageDocOptions, OnPageOptions } from './options.ts'
|
|
163
|
+
export { canEditDocument, canUseOnPage } from './access.ts'
|
|
164
|
+
export { createPreviewToken, verifyPreviewToken, type PreviewTokenPayload } from './tokens.ts'
|
|
165
|
+
export { serializeFields, type SerializeContext } from './schema.ts'
|
|
166
|
+
export { wantsMarkers } from './hooks.ts'
|
|
167
|
+
export { translations } from './translations.ts'
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { DocRef, EncodeFilter, SessionAdapter } from '@payload-onpage/core'
|
|
2
|
+
import type { PayloadRequest } from 'payload'
|
|
3
|
+
|
|
4
|
+
export interface OnPageDocOptions {
|
|
5
|
+
/** Top-level fields editable on the page. Default: every top-level field the user may update. */
|
|
6
|
+
fields?: string[]
|
|
7
|
+
/**
|
|
8
|
+
* The site path that shows this document. Default for collections: the
|
|
9
|
+
* collection's `admin.preview` URL, else `/${doc.slug}`. Globals: "/".
|
|
10
|
+
*/
|
|
11
|
+
url?: (args: {
|
|
12
|
+
doc: Record<string, unknown>
|
|
13
|
+
locale?: string
|
|
14
|
+
req: PayloadRequest
|
|
15
|
+
}) => string | null | Promise<string | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface OnPageOptions {
|
|
19
|
+
collections?: string[] | Record<string, OnPageDocOptions | true>
|
|
20
|
+
globals?: string[] | Record<string, OnPageDocOptions | true>
|
|
21
|
+
/**
|
|
22
|
+
* Who may use on-page editing (`ref` undefined) and edit a given document.
|
|
23
|
+
* Default: users of the admin user collection, with the document's own
|
|
24
|
+
* `update` access (evaluated by Payload, including row-level rules).
|
|
25
|
+
*/
|
|
26
|
+
access?: (args: { req: PayloadRequest; ref?: DocRef }) => boolean | Promise<boolean>
|
|
27
|
+
/** Framework adapter that switches edit/preview mode (e.g. `nextSession()`). Required for the enter/exit/preview endpoints. */
|
|
28
|
+
session?: SessionAdapter
|
|
29
|
+
/** Narrow which strings get auto-mapping markers. */
|
|
30
|
+
encode?: EncodeFilter
|
|
31
|
+
/** Lifetime of shareable preview links, in seconds. Default 1 day. */
|
|
32
|
+
previewLinkTTL?: number
|
|
33
|
+
/** Extra check when a preview link is opened (e.g. a revocation list). Return false to refuse it. */
|
|
34
|
+
validatePreviewLink?: (args: {
|
|
35
|
+
req: PayloadRequest
|
|
36
|
+
ref: DocRef
|
|
37
|
+
path: string
|
|
38
|
+
issuedAt: number
|
|
39
|
+
id: string
|
|
40
|
+
}) => boolean | Promise<boolean>
|
|
41
|
+
/**
|
|
42
|
+
* Secret for preview links and the preview cookie. Default: Payload's `secret`
|
|
43
|
+
* (`PAYLOAD_SECRET`). The Next.js proxy must be given the same value.
|
|
44
|
+
*/
|
|
45
|
+
secret?: string
|
|
46
|
+
/** Auth cookie an editor's session is bound to. Default `${cookiePrefix}-token` ("payload-token"). */
|
|
47
|
+
authCookie?: string
|
|
48
|
+
/** Endpoint prefix under the API route. Default "/onpage". */
|
|
49
|
+
routePrefix?: string
|
|
50
|
+
enabled?: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface Entry {
|
|
54
|
+
kind: 'collection' | 'global'
|
|
55
|
+
slug: string
|
|
56
|
+
options: OnPageDocOptions
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const normalizeEntries = (
|
|
60
|
+
input: OnPageOptions['collections'],
|
|
61
|
+
kind: Entry['kind'],
|
|
62
|
+
): Entry[] =>
|
|
63
|
+
Array.isArray(input)
|
|
64
|
+
? input.map((slug) => ({ kind, slug, options: {} }))
|
|
65
|
+
: Object.entries(input ?? {}).map(([slug, o]) => ({ kind, slug, options: o === true ? {} : o }))
|