@payload-onpage/plugin 0.2.0 → 0.3.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/dist/access.d.ts +7 -4
- package/dist/access.d.ts.map +1 -1
- package/dist/access.js +23 -20
- package/dist/access.js.map +1 -1
- package/dist/admin/OnPageAdminControls.d.ts +3 -2
- package/dist/admin/OnPageAdminControls.d.ts.map +1 -1
- package/dist/admin/OnPageAdminControls.js +12 -8
- package/dist/admin/OnPageAdminControls.js.map +1 -1
- package/dist/endpoints.d.ts +22 -6
- package/dist/endpoints.d.ts.map +1 -1
- package/dist/endpoints.js +87 -80
- package/dist/endpoints.js.map +1 -1
- package/dist/hooks.d.ts +11 -5
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +2 -2
- package/dist/hooks.js.map +1 -1
- package/dist/http.d.ts +18 -4
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +30 -16
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -50
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +22 -11
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +11 -3
- package/dist/options.js.map +1 -1
- package/dist/paths.d.ts +2 -2
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +11 -24
- package/dist/paths.js.map +1 -1
- package/dist/schema.d.ts +6 -4
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +115 -62
- package/dist/schema.js.map +1 -1
- package/dist/translations.d.ts +12 -1
- package/dist/translations.d.ts.map +1 -1
- package/dist/translations.js +10 -9
- package/dist/translations.js.map +1 -1
- package/package.json +2 -2
- package/src/access.ts +26 -19
- package/src/admin/OnPageAdminControls.tsx +16 -16
- package/src/endpoints.ts +105 -98
- package/src/hooks.ts +14 -5
- package/src/http.ts +51 -15
- package/src/index.ts +77 -64
- package/src/options.ts +29 -17
- package/src/paths.ts +14 -27
- package/src/schema.ts +137 -79
- package/src/translations.ts +17 -11
package/src/endpoints.ts
CHANGED
|
@@ -1,91 +1,97 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The plugin's endpoints, under `${api}${routePrefix}` (default /api/onpage):
|
|
3
3
|
*
|
|
4
|
-
* GET /schema/:kind/:slug editable field schema for the signed-in editor
|
|
4
|
+
* GET /schema/:kind/:slug editable field schema for the signed-in editor (JSON)
|
|
5
5
|
* POST /enter start editing or previewing a document's page
|
|
6
6
|
* POST /mode switch the current page between editing and previewing
|
|
7
7
|
* POST /exit leave edit/preview mode
|
|
8
8
|
* POST /preview-link create a sealed, expiring read-only link (JSON)
|
|
9
9
|
* GET /preview?token= open a preview link
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* JSON endpoints throw Payload's errors, so responses, logging, CORS and
|
|
12
|
+
* translations match Payload's own. The form/new-tab endpoints answer in plain
|
|
13
|
+
* text or redirect. State-changing endpoints are same-origin POSTs (CSRF-safe),
|
|
14
|
+
* and every authorization decision goes through ./access (Payload's access rules).
|
|
13
15
|
*/
|
|
14
16
|
import {
|
|
17
|
+
isOnPageMode,
|
|
15
18
|
parseRefKey,
|
|
16
19
|
pathnameOf,
|
|
17
20
|
refKey,
|
|
21
|
+
ROUTES,
|
|
18
22
|
safePath,
|
|
19
23
|
samePath,
|
|
20
24
|
SEAL_PURPOSE,
|
|
21
25
|
seal,
|
|
26
|
+
type DocRef,
|
|
22
27
|
type DocumentSchema,
|
|
23
|
-
type
|
|
28
|
+
type PreviewGrant,
|
|
24
29
|
type SessionAdapter,
|
|
25
30
|
} from '@payload-onpage/core'
|
|
26
|
-
import
|
|
27
|
-
|
|
31
|
+
import {
|
|
32
|
+
addDataAndFileToRequest,
|
|
33
|
+
APIError,
|
|
34
|
+
Forbidden,
|
|
35
|
+
NotFound,
|
|
36
|
+
type Endpoint,
|
|
37
|
+
type PayloadRequest,
|
|
38
|
+
type TypedLocale,
|
|
39
|
+
} from 'payload'
|
|
40
|
+
import { getAutosaveInterval, hasAutosaveEnabled, hasDraftsEnabled, toWords } from 'payload/shared'
|
|
28
41
|
import { canEditDocument, canUseOnPage, documentPermissions } from './access.ts'
|
|
29
42
|
import {
|
|
43
|
+
apiURL,
|
|
44
|
+
entityConfig,
|
|
30
45
|
isSecure,
|
|
31
46
|
json,
|
|
32
|
-
originOf,
|
|
33
|
-
queryOf,
|
|
34
47
|
readRef,
|
|
35
48
|
redirectTo,
|
|
36
49
|
rejectCrossSite,
|
|
37
50
|
text,
|
|
51
|
+
withIdType,
|
|
38
52
|
} from './http.ts'
|
|
39
53
|
import type { Entry, OnPageOptions } from './options.ts'
|
|
40
54
|
import { resolvePath } from './paths.ts'
|
|
41
|
-
import { relationTargets, serializeFields } from './schema.ts'
|
|
55
|
+
import { label, relationTargets, serializeFields } from './schema.ts'
|
|
42
56
|
import { createPreviewToken, verifyPreviewToken } from './tokens.ts'
|
|
43
57
|
|
|
44
58
|
export interface EndpointContext {
|
|
45
|
-
options: OnPageOptions
|
|
46
|
-
prefix: string
|
|
47
|
-
|
|
48
|
-
entry: (kind: string, slug: string) => Entry | undefined
|
|
59
|
+
options: OnPageOptions & { session: SessionAdapter }
|
|
60
|
+
prefix: `/${string}`
|
|
61
|
+
entry: (kind: DocRef['kind'], slug: string) => Entry | undefined
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
const DAY = 24 * 3600
|
|
52
65
|
|
|
53
|
-
|
|
66
|
+
const secretOf = (req: PayloadRequest, options: OnPageOptions): string =>
|
|
54
67
|
options.secret ?? req.payload.config.secret
|
|
55
68
|
|
|
56
69
|
/** Name of the auth cookie an editor's mode is bound to. */
|
|
57
70
|
const authCookieOf = (req: PayloadRequest, options: OnPageOptions): string =>
|
|
58
71
|
options.authCookie ?? `${req.payload.config.cookiePrefix || 'payload'}-token`
|
|
59
72
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return options.session
|
|
66
|
-
}
|
|
73
|
+
/** A locale value from a body, if it's one Payload knows. */
|
|
74
|
+
const knownLocale = (req: PayloadRequest, value: unknown): TypedLocale | undefined =>
|
|
75
|
+
typeof value === 'string' && req.payload.config.localization
|
|
76
|
+
? req.payload.config.localization.localeCodes.find((c) => c === value)
|
|
77
|
+
: undefined
|
|
67
78
|
|
|
68
79
|
export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
69
|
-
const { options, prefix
|
|
80
|
+
const { options, prefix } = ctx
|
|
81
|
+
const { session } = options
|
|
70
82
|
|
|
71
83
|
const schema: Endpoint = {
|
|
72
|
-
path: `${prefix}
|
|
84
|
+
path: `${prefix}${ROUTES.schema}/:kind/:slug`,
|
|
73
85
|
method: 'get',
|
|
74
86
|
handler: async (req) => {
|
|
75
87
|
const kind = req.routeParams?.kind === 'global' ? 'global' : 'collection'
|
|
76
88
|
const slug = typeof req.routeParams?.slug === 'string' ? req.routeParams.slug : ''
|
|
77
89
|
const entry = ctx.entry(kind, slug)
|
|
78
|
-
const cfg
|
|
79
|
-
|
|
80
|
-
|
|
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)
|
|
90
|
+
const cfg = entityConfig(req, kind, slug)
|
|
91
|
+
if (!entry || !cfg) throw new NotFound(req.t)
|
|
92
|
+
if (!(await canUseOnPage(req, options))) throw new Forbidden(req.t)
|
|
84
93
|
|
|
85
|
-
const perms = await documentPermissions(
|
|
86
|
-
req,
|
|
87
|
-
kind === 'global' ? { kind, slug } : { kind, slug },
|
|
88
|
-
)
|
|
94
|
+
const perms = await documentPermissions(req, { kind, slug })
|
|
89
95
|
const pending: Promise<void>[] = []
|
|
90
96
|
const all = serializeFields(cfg.fields, {
|
|
91
97
|
i18n: req.i18n,
|
|
@@ -94,47 +100,43 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
94
100
|
defaults: { req, pending },
|
|
95
101
|
})
|
|
96
102
|
await Promise.all(pending)
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
: all
|
|
103
|
+
const only = entry.options.fields
|
|
104
|
+
const fields = only ? all.filter((f) => only.includes(f.name)) : all
|
|
100
105
|
|
|
106
|
+
const i18n = { i18n: req.i18n }
|
|
101
107
|
const relations: NonNullable<DocumentSchema['relations']> = {}
|
|
102
108
|
for (const target of relationTargets(fields)) {
|
|
103
|
-
const c = req
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const c = entityConfig(req, 'collection', target)
|
|
110
|
+
if (c)
|
|
111
|
+
relations[target] = {
|
|
112
|
+
titleField: c.admin?.useAsTitle ?? 'id',
|
|
113
|
+
label: label(c.labels?.singular, toWords(target), i18n),
|
|
114
|
+
}
|
|
109
115
|
}
|
|
116
|
+
const labels =
|
|
117
|
+
'labels' in cfg
|
|
118
|
+
? {
|
|
119
|
+
singular: label(cfg.labels?.singular, toWords(slug), i18n),
|
|
120
|
+
plural: label(cfg.labels?.plural, toWords(slug), i18n),
|
|
121
|
+
}
|
|
122
|
+
: {
|
|
123
|
+
singular: label(cfg.label, toWords(slug), i18n),
|
|
124
|
+
plural: label(cfg.label, toWords(slug), i18n),
|
|
125
|
+
}
|
|
126
|
+
const useAsTitle = 'labels' in cfg ? cfg.admin?.useAsTitle : undefined
|
|
110
127
|
const localization = req.payload.config.localization
|
|
111
|
-
const labelOf = (v: unknown) => (typeof v === 'string' ? v : slug)
|
|
112
128
|
const body: DocumentSchema = {
|
|
113
129
|
kind,
|
|
114
130
|
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
|
-
},
|
|
131
|
+
labels,
|
|
125
132
|
editable: fields.map((f) => f.name),
|
|
126
133
|
fields,
|
|
127
134
|
drafts: hasDraftsEnabled(cfg),
|
|
128
135
|
...(hasAutosaveEnabled(cfg) ? { autosaveInterval: getAutosaveInterval(cfg) } : {}),
|
|
129
|
-
...(
|
|
130
|
-
? { useAsTitle: (cfg as CollectionConfig).admin!.useAsTitle }
|
|
131
|
-
: {}),
|
|
136
|
+
...(useAsTitle ? { useAsTitle } : {}),
|
|
132
137
|
...(Object.keys(relations).length ? { relations } : {}),
|
|
133
138
|
...(localization
|
|
134
|
-
? {
|
|
135
|
-
defaultLocale: localization.defaultLocale,
|
|
136
|
-
locales: localization.locales.map((l) => (typeof l === 'string' ? l : l.code)),
|
|
137
|
-
}
|
|
139
|
+
? { defaultLocale: localization.defaultLocale, locales: localization.localeCodes }
|
|
138
140
|
: {}),
|
|
139
141
|
}
|
|
140
142
|
return json(body)
|
|
@@ -142,23 +144,23 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
142
144
|
}
|
|
143
145
|
|
|
144
146
|
const enter: Endpoint = {
|
|
145
|
-
path: `${prefix}
|
|
147
|
+
path: `${prefix}${ROUTES.enter}`,
|
|
146
148
|
method: 'post',
|
|
147
149
|
handler: async (req) => {
|
|
148
150
|
const refused = rejectCrossSite(req)
|
|
149
151
|
if (refused) return refused
|
|
150
|
-
const q =
|
|
151
|
-
const ref = readRef({ kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
|
|
152
|
+
const q = req.searchParams
|
|
153
|
+
const ref = readRef(req, { kind: q.get('kind'), slug: q.get('slug'), id: q.get('id') })
|
|
152
154
|
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
153
155
|
if (!ref || !entry) return text('Unknown document', 400)
|
|
154
156
|
if (!req.user) return text('Sign in to the admin first', 401)
|
|
155
157
|
if (!(await canEditDocument(req, ref, options)))
|
|
156
158
|
return text('You cannot edit this document', 403)
|
|
157
|
-
const locale =
|
|
159
|
+
const locale = req.locale && req.locale !== 'all' ? req.locale : undefined
|
|
158
160
|
const path = safePath(q.get('path')) ?? (await resolvePath(req, ref, entry, locale))
|
|
159
161
|
if (!path) return text('This document has no page to open', 404)
|
|
160
|
-
const mode
|
|
161
|
-
await
|
|
162
|
+
const mode = q.get('mode')
|
|
163
|
+
await session.enable(isOnPageMode(mode) ? mode : 'edit', {
|
|
162
164
|
secure: isSecure(req),
|
|
163
165
|
bindTo: authCookieOf(req, options),
|
|
164
166
|
})
|
|
@@ -167,14 +169,16 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
167
169
|
}
|
|
168
170
|
|
|
169
171
|
const mode: Endpoint = {
|
|
170
|
-
path: `${prefix}
|
|
172
|
+
path: `${prefix}${ROUTES.mode}`,
|
|
171
173
|
method: 'post',
|
|
172
174
|
handler: async (req) => {
|
|
173
175
|
const refused = rejectCrossSite(req)
|
|
174
176
|
if (refused) return refused
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
if (!req.user) return text('Sign in to the admin first', 401)
|
|
178
|
+
if (!(await canUseOnPage(req, options))) return text('You cannot use on-page editing', 403)
|
|
179
|
+
const q = req.searchParams
|
|
180
|
+
const next = q.get('mode')
|
|
181
|
+
await session.enable(isOnPageMode(next) ? next : 'edit', {
|
|
178
182
|
secure: isSecure(req),
|
|
179
183
|
bindTo: authCookieOf(req, options),
|
|
180
184
|
})
|
|
@@ -183,41 +187,41 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
const exit: Endpoint = {
|
|
186
|
-
path: `${prefix}
|
|
190
|
+
path: `${prefix}${ROUTES.exit}`,
|
|
187
191
|
method: 'post',
|
|
188
192
|
handler: async (req) => {
|
|
189
193
|
const refused = rejectCrossSite(req)
|
|
190
194
|
if (refused) return refused
|
|
191
|
-
await
|
|
192
|
-
return redirectTo(req,
|
|
195
|
+
await session.disable()
|
|
196
|
+
return redirectTo(req, req.searchParams.get('path'))
|
|
193
197
|
},
|
|
194
198
|
}
|
|
195
199
|
|
|
196
200
|
const previewLink: Endpoint = {
|
|
197
|
-
path: `${prefix}
|
|
201
|
+
path: `${prefix}${ROUTES.previewLink}`,
|
|
198
202
|
method: 'post',
|
|
199
203
|
handler: async (req) => {
|
|
200
204
|
const refused = rejectCrossSite(req)
|
|
201
205
|
if (refused) return refused
|
|
202
206
|
if (!req.headers.get('content-type')?.includes('application/json'))
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const
|
|
207
|
+
throw new APIError('Expected a JSON body', 415)
|
|
208
|
+
await addDataAndFileToRequest(req) // parses JSON, or throws a 400
|
|
209
|
+
const body: Record<string, unknown> = req.data ?? {}
|
|
210
|
+
const ref = readRef(req, body)
|
|
206
211
|
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
207
|
-
if (!ref || !entry)
|
|
208
|
-
if (!(await canEditDocument(req, ref, options)))
|
|
209
|
-
const locale =
|
|
212
|
+
if (!ref || !entry) throw new APIError('Unknown document', 400)
|
|
213
|
+
if (!(await canEditDocument(req, ref, options))) throw new Forbidden(req.t)
|
|
214
|
+
const locale = knownLocale(req, body.locale)
|
|
210
215
|
// The path is always derived on the server: a link opens this document's page and nothing else.
|
|
211
216
|
const path = await resolvePath(req, ref, entry, locale)
|
|
212
|
-
if (!path)
|
|
213
|
-
const ttl = options.previewLinkTTL ?? DAY
|
|
217
|
+
if (!path) throw new APIError('This document has no page', 404)
|
|
214
218
|
const { token, payload } = await createPreviewToken(
|
|
215
|
-
{ ref: refKey(ref), path, locale },
|
|
219
|
+
{ ref: refKey(ref), path, ...(locale ? { locale } : {}) },
|
|
216
220
|
secretOf(req, options),
|
|
217
|
-
|
|
221
|
+
options.previewLinkTTL ?? DAY,
|
|
218
222
|
)
|
|
219
223
|
return json({
|
|
220
|
-
url: `${
|
|
224
|
+
url: `${apiURL(req, `${prefix}${ROUTES.preview}`)}?token=${encodeURIComponent(token)}`,
|
|
221
225
|
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
|
222
226
|
id: payload.jti,
|
|
223
227
|
})
|
|
@@ -225,17 +229,18 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
225
229
|
}
|
|
226
230
|
|
|
227
231
|
const preview: Endpoint = {
|
|
228
|
-
path: `${prefix}
|
|
232
|
+
path: `${prefix}${ROUTES.preview}`,
|
|
229
233
|
method: 'get',
|
|
230
234
|
handler: async (req) => {
|
|
231
235
|
const invalid = () => text('This preview link is invalid or has expired.', 403)
|
|
232
236
|
const secret = secretOf(req, options)
|
|
233
|
-
const token = await verifyPreviewToken(
|
|
234
|
-
const
|
|
237
|
+
const token = await verifyPreviewToken(req.searchParams.get('token') ?? '', secret)
|
|
238
|
+
const parsed = token && parseRefKey(token.ref)
|
|
239
|
+
const ref = parsed && withIdType(req, parsed)
|
|
235
240
|
const entry = ref && ctx.entry(ref.kind, ref.slug)
|
|
236
241
|
if (!token || !ref || !entry) return invalid()
|
|
237
242
|
// Still the document's page? (It may have been deleted, or its slug changed.)
|
|
238
|
-
const current = await resolvePath(req, ref, entry, token.locale)
|
|
243
|
+
const current = await resolvePath(req, ref, entry, knownLocale(req, token.locale))
|
|
239
244
|
if (!current || !samePath(current, token.path)) return invalid()
|
|
240
245
|
if (
|
|
241
246
|
options.validatePreviewLink &&
|
|
@@ -249,17 +254,19 @@ export function createEndpoints(ctx: EndpointContext): Endpoint[] {
|
|
|
249
254
|
) {
|
|
250
255
|
return invalid()
|
|
251
256
|
}
|
|
252
|
-
|
|
253
|
-
await requireSession(options).enableLink({
|
|
257
|
+
await session.enableLink({
|
|
254
258
|
secure: isSecure(req),
|
|
255
259
|
path: pathnameOf(token.path),
|
|
256
|
-
expiresAt,
|
|
257
|
-
seal: (draftSecret) =>
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
260
|
+
expiresAt: new Date(token.exp * 1000),
|
|
261
|
+
seal: (draftSecret) => {
|
|
262
|
+
const grant: PreviewGrant = {
|
|
263
|
+
path: pathnameOf(token.path),
|
|
264
|
+
ref: token.ref,
|
|
265
|
+
exp: token.exp,
|
|
266
|
+
draft: draftSecret,
|
|
267
|
+
}
|
|
268
|
+
return seal(grant, secret, SEAL_PURPOSE.previewCookie)
|
|
269
|
+
},
|
|
263
270
|
})
|
|
264
271
|
return redirectTo(req, token.path, 307)
|
|
265
272
|
},
|
package/src/hooks.ts
CHANGED
|
@@ -5,23 +5,32 @@
|
|
|
5
5
|
* - beforeChange: stale-write guard (409) when the editor sends the version it last saw
|
|
6
6
|
*/
|
|
7
7
|
import {
|
|
8
|
+
CONTEXT_KEY,
|
|
8
9
|
encodeDocument,
|
|
9
10
|
HEADER,
|
|
10
11
|
refKey,
|
|
11
12
|
stegaCleanDeep,
|
|
13
|
+
type DocRef,
|
|
12
14
|
type EncodeFilter,
|
|
13
15
|
type SchemaField,
|
|
14
16
|
} from '@payload-onpage/core'
|
|
15
|
-
import { APIError, type PayloadRequest } from 'payload'
|
|
17
|
+
import { APIError, type PayloadRequest, type RequestContext } from 'payload'
|
|
18
|
+
|
|
19
|
+
declare module 'payload' {
|
|
20
|
+
interface RequestContext {
|
|
21
|
+
/** Set by `onpageContext()`: this read should carry auto-mapping markers. */
|
|
22
|
+
[CONTEXT_KEY]?: boolean
|
|
23
|
+
}
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
/** True when a read asked for auto-mapping markers (see `onpageContext()`). */
|
|
18
|
-
export const wantsMarkers = (context:
|
|
19
|
-
|
|
27
|
+
export const wantsMarkers = (context: RequestContext | undefined): boolean =>
|
|
28
|
+
context?.[CONTEXT_KEY] === true
|
|
20
29
|
|
|
21
30
|
export const stripMarkers = ({ data }: { data?: unknown }) => (data ? stegaCleanDeep(data) : data)
|
|
22
31
|
|
|
23
32
|
export function encodeOnRead(opts: {
|
|
24
|
-
kind: '
|
|
33
|
+
kind: DocRef['kind']
|
|
25
34
|
slug: string
|
|
26
35
|
fields: () => SchemaField[]
|
|
27
36
|
only?: string[]
|
|
@@ -34,7 +43,7 @@ export function encodeOnRead(opts: {
|
|
|
34
43
|
}: {
|
|
35
44
|
doc: Record<string, unknown>
|
|
36
45
|
req: PayloadRequest
|
|
37
|
-
context:
|
|
46
|
+
context: RequestContext
|
|
38
47
|
}) => {
|
|
39
48
|
if (!wantsMarkers(context) || req.locale === 'all' || !doc) return doc
|
|
40
49
|
const ref =
|
package/src/http.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
1
|
/** Request helpers shared by the endpoints. */
|
|
2
|
-
import {
|
|
3
|
-
import type { PayloadRequest } from 'payload'
|
|
2
|
+
import { safePath, type DocRef } from '@payload-onpage/core'
|
|
3
|
+
import type { PayloadRequest, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
|
|
4
|
+
import { formatAdminURL, stripTrailingSlash } from 'payload/shared'
|
|
4
5
|
|
|
5
6
|
/** The site origin: `serverURL` when configured, else the request's own origin. */
|
|
6
|
-
export
|
|
7
|
+
export const originOf = (req: PayloadRequest): string => {
|
|
7
8
|
const configured = req.payload.config.serverURL
|
|
8
|
-
|
|
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
|
-
}
|
|
9
|
+
return configured ? stripTrailingSlash(configured) : req.origin
|
|
16
10
|
}
|
|
17
11
|
|
|
18
12
|
export const isSecure = (req: PayloadRequest): boolean => originOf(req).startsWith('https:')
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Absolute URL of one of our API routes (e.g. `/onpage/preview`), built the way
|
|
16
|
+
* Payload builds its own: honours `routes.api`, Next's basePath and trailing slashes.
|
|
17
|
+
*/
|
|
18
|
+
export const apiURL = (req: PayloadRequest, path: `/${string}`): string =>
|
|
19
|
+
formatAdminURL({ apiRoute: req.payload.config.routes.api, path, serverURL: originOf(req) })
|
|
22
20
|
|
|
21
|
+
/** Plain-text reply for the form-POST / new-tab endpoints, where JSON would just be shown raw. */
|
|
23
22
|
export const text = (body: string, status: number): Response =>
|
|
24
23
|
new Response(body, {
|
|
25
24
|
status,
|
|
@@ -45,13 +44,50 @@ export function rejectCrossSite(req: PayloadRequest): Response | null {
|
|
|
45
44
|
return site === 'same-origin' || site === 'none' ? null : text('Cross-site request refused', 403)
|
|
46
45
|
}
|
|
47
46
|
|
|
47
|
+
/** A collection's or global's sanitized config. */
|
|
48
|
+
export function entityConfig(
|
|
49
|
+
req: PayloadRequest,
|
|
50
|
+
kind: 'collection',
|
|
51
|
+
slug: string,
|
|
52
|
+
): SanitizedCollectionConfig | undefined
|
|
53
|
+
export function entityConfig(
|
|
54
|
+
req: PayloadRequest,
|
|
55
|
+
kind: 'global',
|
|
56
|
+
slug: string,
|
|
57
|
+
): SanitizedGlobalConfig | undefined
|
|
58
|
+
export function entityConfig(
|
|
59
|
+
req: PayloadRequest,
|
|
60
|
+
kind: DocRef['kind'],
|
|
61
|
+
slug: string,
|
|
62
|
+
): SanitizedCollectionConfig | SanitizedGlobalConfig | undefined
|
|
63
|
+
export function entityConfig(req: PayloadRequest, kind: DocRef['kind'], slug: string) {
|
|
64
|
+
return kind === 'global'
|
|
65
|
+
? req.payload.globals.config.find((g) => g.slug === slug)
|
|
66
|
+
: req.payload.collections[slug]?.config
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
/** A document ref from `kind`, `slug`, `id` query/body values. */
|
|
49
|
-
export function readRef(
|
|
70
|
+
export function readRef(
|
|
71
|
+
req: PayloadRequest,
|
|
72
|
+
values: { kind?: unknown; slug?: unknown; id?: unknown },
|
|
73
|
+
): DocRef | null {
|
|
50
74
|
const slug = typeof values.slug === 'string' ? values.slug : ''
|
|
51
75
|
if (!slug) return null
|
|
52
76
|
if (values.kind === 'global') return { kind: 'global', slug }
|
|
53
77
|
const id = values.id
|
|
54
78
|
if (typeof id === 'number') return { kind: 'collection', slug, id }
|
|
55
79
|
if (typeof id !== 'string' || !id) return null
|
|
56
|
-
return
|
|
80
|
+
return withIdType(req, { kind: 'collection', slug, id })
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The ref with its id typed the way the collection stores it (number or text),
|
|
85
|
+
* so a text id that looks numeric ("007") is never coerced.
|
|
86
|
+
*/
|
|
87
|
+
export function withIdType(req: PayloadRequest, ref: DocRef): DocRef {
|
|
88
|
+
if (ref.kind === 'global') return ref
|
|
89
|
+
const type = req.payload.collections[ref.slug]?.customIDType ?? req.payload.db.defaultIDType
|
|
90
|
+
const raw = String(ref.id)
|
|
91
|
+
const n = Number(raw)
|
|
92
|
+
return { ...ref, id: type === 'number' && Number.isSafeInteger(n) && String(n) === raw ? n : raw }
|
|
57
93
|
}
|