@crombolo/api-delivery 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +33 -4
- package/src/coverage.ts +27 -0
- package/src/expansion.ts +81 -0
- package/src/handler.ts +487 -0
- package/src/index.ts +7 -0
- package/src/output.ts +363 -0
- package/src/protection.ts +51 -0
- package/src/query.ts +198 -0
- package/src/settings.ts +137 -0
- package/README.md +0 -3
package/src/handler.ts
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Umbraco's Delivery API v2 over the published cache: content and media
|
|
3
|
+
* items, paths and queries, with its access, culture and Start-Item rules.
|
|
4
|
+
* Every answer is read from memory, so it scales with the nodes that render.
|
|
5
|
+
* docs/delivery-api.md.
|
|
6
|
+
*/
|
|
7
|
+
import type { PublishedCache, PublishedContent, PublishedMedia } from '@crombolo/render'
|
|
8
|
+
import { isRightToLeft } from '@crombolo/render'
|
|
9
|
+
import {
|
|
10
|
+
ALL_FIELDS,
|
|
11
|
+
type ExpansionNode,
|
|
12
|
+
ExpansionSyntaxError,
|
|
13
|
+
parseExpansion,
|
|
14
|
+
} from './expansion.ts'
|
|
15
|
+
import {
|
|
16
|
+
type ApiRoute,
|
|
17
|
+
apiContentResponse,
|
|
18
|
+
apiMediaResponse,
|
|
19
|
+
type OutputContext,
|
|
20
|
+
} from './output.ts'
|
|
21
|
+
import { accessTo, type DeliveryMember, type ProtectionRule } from './protection.ts'
|
|
22
|
+
import {
|
|
23
|
+
compareBy,
|
|
24
|
+
parseContentFilters,
|
|
25
|
+
parseContentSelector,
|
|
26
|
+
parseContentSorts,
|
|
27
|
+
parseMediaQuery,
|
|
28
|
+
passesFilters,
|
|
29
|
+
QUERY_ERRORS,
|
|
30
|
+
type QueryError,
|
|
31
|
+
} from './query.ts'
|
|
32
|
+
import {
|
|
33
|
+
type DeliveryApiSettings,
|
|
34
|
+
isAllowedContentType,
|
|
35
|
+
memberAuthorizationEnabled,
|
|
36
|
+
} from './settings.ts'
|
|
37
|
+
|
|
38
|
+
export interface DeliveryApiPorts {
|
|
39
|
+
settings: DeliveryApiSettings
|
|
40
|
+
cache: PublishedCache
|
|
41
|
+
/** Where the API answers: `/umbraco/delivery/api`, as in Umbraco. */
|
|
42
|
+
prefix: string
|
|
43
|
+
/** Every protected node and who may see it; read once a request, and only when needed. */
|
|
44
|
+
protection(): Promise<ProtectionRule[]>
|
|
45
|
+
/** The member an access token names; undefined for none, or one not valid now. */
|
|
46
|
+
member(token: string): Promise<DeliveryMember | undefined>
|
|
47
|
+
/** The member sign-in endpoints under `v1/security/member/`, when built. */
|
|
48
|
+
security?(request: Request, action: string): Promise<Response | undefined>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface DeliveryApi {
|
|
52
|
+
handle(request: Request): Promise<Response | undefined>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
56
|
+
const ROOT_KEY = '00000000-0000-0000-0000-000000000000'
|
|
57
|
+
const CONTENT_VARY = 'Accept-Language, Accept-Segment, Preview, Start-Item'
|
|
58
|
+
|
|
59
|
+
/** 401, 403 and 404 carry no body in Umbraco's Delivery API. */
|
|
60
|
+
const bare = (status: number) => new Response(null, { status })
|
|
61
|
+
|
|
62
|
+
const problem = (error: QueryError) =>
|
|
63
|
+
Response.json(
|
|
64
|
+
{
|
|
65
|
+
type: 'Error',
|
|
66
|
+
title: QUERY_ERRORS[error].title,
|
|
67
|
+
status: 400,
|
|
68
|
+
detail: QUERY_ERRORS[error].detail,
|
|
69
|
+
},
|
|
70
|
+
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const invalid = (field: string, value: string) =>
|
|
74
|
+
Response.json(
|
|
75
|
+
{
|
|
76
|
+
type: 'https://tools.ietf.org/html/rfc9110#section-15.5.1',
|
|
77
|
+
title: 'One or more validation errors occurred.',
|
|
78
|
+
status: 400,
|
|
79
|
+
errors: { [field]: [`The value '${value}' is not valid.`] },
|
|
80
|
+
},
|
|
81
|
+
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/** The caller's key, compared exactly; a site without one has none to match. Pure. */
|
|
85
|
+
export const hasValidApiKey = (settings: DeliveryApiSettings, request: Request): boolean =>
|
|
86
|
+
settings.apiKey !== undefined && request.headers.get('api-key') === settings.apiKey
|
|
87
|
+
|
|
88
|
+
export const isPreview = (request: Request): boolean =>
|
|
89
|
+
request.headers.get('preview')?.toLowerCase() === 'true'
|
|
90
|
+
|
|
91
|
+
/** Umbraco's `ApiAccessService`. Pure. */
|
|
92
|
+
export function contentAccess(settings: DeliveryApiSettings, request: Request): boolean {
|
|
93
|
+
if (!settings.enabled) return false
|
|
94
|
+
if (isPreview(request)) return hasValidApiKey(settings, request)
|
|
95
|
+
return settings.publicAccess || hasValidApiKey(settings, request)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function mediaAccess(settings: DeliveryApiSettings, request: Request): boolean {
|
|
99
|
+
return (
|
|
100
|
+
settings.enabled &&
|
|
101
|
+
settings.media.enabled &&
|
|
102
|
+
((settings.publicAccess && settings.media.publicAccess) || hasValidApiKey(settings, request))
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `Accept-Language` only when it is one plain culture name; otherwise none. Pure. */
|
|
107
|
+
export function requestedCulture(header: string | null): string | undefined {
|
|
108
|
+
const value = header?.trim()
|
|
109
|
+
return value && /^[\w-]*$/.test(value) ? value : undefined
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The bearer token from the `Authorization` header only — never the query string. Pure. */
|
|
113
|
+
export function bearerToken(request: Request): string | undefined {
|
|
114
|
+
const match = /^Bearer\s+(.+)$/i.exec(request.headers.get('authorization') ?? '')
|
|
115
|
+
return match?.[1]?.trim() || undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const intParam = (url: URL, name: string, fallback: number): number | Response => {
|
|
119
|
+
const raw = url.searchParams.get(name)
|
|
120
|
+
if (raw === null || raw === '') return fallback
|
|
121
|
+
const value = Number(raw)
|
|
122
|
+
return Number.isInteger(value) && value >= 0 ? value : invalid(name, raw)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const expansionOf = (url: URL): { expand: ExpansionNode; include: ExpansionNode } | Response => {
|
|
126
|
+
try {
|
|
127
|
+
const fields = url.searchParams.get('fields')
|
|
128
|
+
return {
|
|
129
|
+
expand: parseExpansion(url.searchParams.get('expand') ?? ''),
|
|
130
|
+
include: fields ? parseExpansion(fields) : ALL_FIELDS,
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!(error instanceof ExpansionSyntaxError)) throw error
|
|
134
|
+
return Response.json(
|
|
135
|
+
{ type: 'Error', title: 'Invalid expansion', status: 400, detail: error.message },
|
|
136
|
+
{ status: 400, headers: { 'content-type': 'application/problem+json' } },
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function createDeliveryApi(ports: DeliveryApiPorts): DeliveryApi {
|
|
142
|
+
const { settings, cache } = ports
|
|
143
|
+
const prefix = ports.prefix.replace(/\/$/, '')
|
|
144
|
+
|
|
145
|
+
/** Everything a content request decides once: culture, member, rules, routes. */
|
|
146
|
+
async function contentRequest(request: Request) {
|
|
147
|
+
const asked = requestedCulture(request.headers.get('accept-language'))
|
|
148
|
+
const culture = asked ?? (await cache.defaultCulture())
|
|
149
|
+
const known = asked === undefined || (await cache.hasCulture(asked))
|
|
150
|
+
const nav = await cache.navigation(culture)
|
|
151
|
+
const hide = await cache.hidesTopLevel()
|
|
152
|
+
const roots = nav.root()
|
|
153
|
+
const rtl = isRightToLeft(culture)
|
|
154
|
+
|
|
155
|
+
let member: DeliveryMember | undefined
|
|
156
|
+
const token = bearerToken(request)
|
|
157
|
+
if (token && memberAuthorizationEnabled(settings)) member = await ports.member(token)
|
|
158
|
+
const loadedRules = new Map((await ports.protection()).map((rule) => [rule.nodeId, rule]))
|
|
159
|
+
|
|
160
|
+
const routeIn = async (inCulture: string | null) => {
|
|
161
|
+
const view = await cache.navigation(inCulture)
|
|
162
|
+
const first = view.root()[0]
|
|
163
|
+
const cultureRtl = isRightToLeft(inCulture)
|
|
164
|
+
return (content: PublishedContent): ApiRoute | undefined => {
|
|
165
|
+
const here = view.byKey(content.key)
|
|
166
|
+
if (!here) return undefined
|
|
167
|
+
const ancestors = view.ancestors(here)
|
|
168
|
+
const root = ancestors[ancestors.length - 1] ?? here
|
|
169
|
+
let path: string
|
|
170
|
+
if (ancestors.length === 0)
|
|
171
|
+
path = hide && first && here.id !== first.id ? `/${here.urlSegment}` : '/'
|
|
172
|
+
else {
|
|
173
|
+
const below = [...ancestors.slice(0, -1).reverse(), here].map((c) => c.urlSegment)
|
|
174
|
+
path = `/${(cultureRtl ? below.reverse() : below).join('/')}`
|
|
175
|
+
}
|
|
176
|
+
return { path, queryString: null, startItem: { id: root.key, path: root.urlSegment } }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const route = await routeIn(culture)
|
|
180
|
+
|
|
181
|
+
const ctx: OutputContext = {
|
|
182
|
+
route,
|
|
183
|
+
content: (key) => nav.byKey(key),
|
|
184
|
+
allowedType: (alias) => isAllowedContentType(settings, alias),
|
|
185
|
+
access: (content) => accessTo(content.path, loadedRules, member),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Below a node by URL segments, exactly as stored. */
|
|
189
|
+
const walk = (
|
|
190
|
+
from: readonly PublishedContent[],
|
|
191
|
+
segments: readonly string[],
|
|
192
|
+
): PublishedContent | undefined => {
|
|
193
|
+
let level = from
|
|
194
|
+
let found: PublishedContent | undefined
|
|
195
|
+
for (const segment of segments) {
|
|
196
|
+
found = level.find((c) => c.urlSegment === segment)
|
|
197
|
+
if (!found) return undefined
|
|
198
|
+
level = nav.children(found)
|
|
199
|
+
}
|
|
200
|
+
return found
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** `Start-Item`: a root's key, or a root's segment in any case. */
|
|
204
|
+
const startItemHeader = request.headers.get('start-item')?.replace(/^\/+|\/+$/g, '')
|
|
205
|
+
const startItem = startItemHeader
|
|
206
|
+
? roots.find((r) =>
|
|
207
|
+
GUID.test(startItemHeader)
|
|
208
|
+
? r.key === startItemHeader.toLowerCase()
|
|
209
|
+
: r.urlSegment.toLowerCase() === startItemHeader.toLowerCase(),
|
|
210
|
+
)
|
|
211
|
+
: undefined
|
|
212
|
+
const badStartItem = Boolean(startItemHeader) && !startItem
|
|
213
|
+
|
|
214
|
+
const byPath = (raw: string): PublishedContent | undefined => {
|
|
215
|
+
const decoded = raw.includes('%2F') || raw.includes('%2f') ? decodeURIComponent(raw) : raw
|
|
216
|
+
let segments = decoded.split('/').filter(Boolean)
|
|
217
|
+
if (rtl) segments = [...segments].reverse()
|
|
218
|
+
if (startItem)
|
|
219
|
+
return segments.length === 0 ? startItem : walk(nav.children(startItem), segments)
|
|
220
|
+
if (segments.length === 0) return roots[0]
|
|
221
|
+
if (!hide) return walk(roots, segments)
|
|
222
|
+
if (segments.length === 1) {
|
|
223
|
+
const root = roots.find((r) => r.urlSegment === segments[0])
|
|
224
|
+
if (root) return root
|
|
225
|
+
}
|
|
226
|
+
for (const root of roots) {
|
|
227
|
+
const found = walk(nav.children(root), segments)
|
|
228
|
+
if (found) return found
|
|
229
|
+
}
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const servable = (content: PublishedContent | undefined) =>
|
|
234
|
+
content &&
|
|
235
|
+
known &&
|
|
236
|
+
isAllowedContentType(settings, content.contentType.alias) &&
|
|
237
|
+
route(content)
|
|
238
|
+
? content
|
|
239
|
+
: undefined
|
|
240
|
+
|
|
241
|
+
const respond = async (content: PublishedContent, url: URL) => {
|
|
242
|
+
const expansion = expansionOf(url)
|
|
243
|
+
if (expansion instanceof Response) return expansion
|
|
244
|
+
const routes = new Map<string, (c: PublishedContent) => ApiRoute | undefined>()
|
|
245
|
+
for (const c of content.cultures) routes.set(c, await routeIn(c))
|
|
246
|
+
return apiContentResponse(
|
|
247
|
+
content,
|
|
248
|
+
ctx,
|
|
249
|
+
(c) => routes.get(c)?.(content),
|
|
250
|
+
expansion.expand,
|
|
251
|
+
expansion.include,
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
culture,
|
|
257
|
+
known,
|
|
258
|
+
nav,
|
|
259
|
+
roots,
|
|
260
|
+
member,
|
|
261
|
+
ctx,
|
|
262
|
+
byPath,
|
|
263
|
+
startItem,
|
|
264
|
+
badStartItem,
|
|
265
|
+
servable,
|
|
266
|
+
respond,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
type ContentRequest = Awaited<ReturnType<typeof contentRequest>>
|
|
271
|
+
|
|
272
|
+
const json = (body: unknown) => Response.json(body, { headers: { vary: CONTENT_VARY } })
|
|
273
|
+
|
|
274
|
+
async function contentItem(r: ContentRequest, content: PublishedContent | undefined, url: URL) {
|
|
275
|
+
const found = r.servable(content)
|
|
276
|
+
if (!found) return bare(404)
|
|
277
|
+
const access = r.ctx.access(found)
|
|
278
|
+
if (access !== 'allowed') return bare(access === 'forbidden' ? 403 : 401)
|
|
279
|
+
const body = await r.respond(found, url)
|
|
280
|
+
if (body instanceof Response) return body
|
|
281
|
+
return body ? json(body) : bare(404)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function contentItems(r: ContentRequest, url: URL) {
|
|
285
|
+
const ids = [...new Set(url.searchParams.getAll('id').map((id) => id.toLowerCase()))]
|
|
286
|
+
for (const id of ids) if (!GUID.test(id)) return invalid('id', id)
|
|
287
|
+
const found = ids.flatMap((id) => {
|
|
288
|
+
const content = r.servable(r.nav.byKey(id))
|
|
289
|
+
return content ? [content] : []
|
|
290
|
+
})
|
|
291
|
+
for (const content of found) {
|
|
292
|
+
const access = r.ctx.access(content)
|
|
293
|
+
if (access !== 'allowed') return bare(access === 'forbidden' ? 403 : 401)
|
|
294
|
+
}
|
|
295
|
+
const out = []
|
|
296
|
+
for (const content of found) {
|
|
297
|
+
const body = await r.respond(content, url)
|
|
298
|
+
if (body instanceof Response) return body
|
|
299
|
+
if (body) out.push(body)
|
|
300
|
+
}
|
|
301
|
+
return json(out)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function contentQuery(r: ContentRequest, url: URL) {
|
|
305
|
+
const selector = parseContentSelector(url.searchParams.get('fetch'))
|
|
306
|
+
if (typeof selector === 'string') return problem(selector)
|
|
307
|
+
const filters = parseContentFilters(url.searchParams.getAll('filter'))
|
|
308
|
+
if (typeof filters === 'string') return problem(filters)
|
|
309
|
+
const sorts = parseContentSorts(url.searchParams.getAll('sort'))
|
|
310
|
+
if (typeof sorts === 'string') return problem(sorts)
|
|
311
|
+
const skip = intParam(url, 'skip', 0)
|
|
312
|
+
if (skip instanceof Response) return skip
|
|
313
|
+
const take = intParam(url, 'take', 10)
|
|
314
|
+
if (take instanceof Response) return take
|
|
315
|
+
|
|
316
|
+
const all = r.known ? await cache.contentIn(r.culture) : []
|
|
317
|
+
const resolveTarget = (target: string): PublishedContent | 'roots' | undefined => {
|
|
318
|
+
if (!target) return undefined
|
|
319
|
+
if (target === ROOT_KEY) return 'roots'
|
|
320
|
+
if (GUID.test(target)) return r.nav.byKey(target)
|
|
321
|
+
return r.byPath(target)
|
|
322
|
+
}
|
|
323
|
+
let candidates: PublishedContent[]
|
|
324
|
+
if (selector.kind === 'all')
|
|
325
|
+
candidates = r.startItem
|
|
326
|
+
? all.filter(
|
|
327
|
+
(c) => c.id !== r.startItem?.id && c.path.split(',').includes(String(r.startItem?.id)),
|
|
328
|
+
)
|
|
329
|
+
: all
|
|
330
|
+
else {
|
|
331
|
+
const target = resolveTarget(selector.target)
|
|
332
|
+
if (!target) candidates = []
|
|
333
|
+
else if (selector.kind === 'children')
|
|
334
|
+
candidates = target === 'roots' ? r.roots : r.nav.children(target)
|
|
335
|
+
else if (target === 'roots') candidates = selector.kind === 'descendants' ? all : []
|
|
336
|
+
else if (selector.kind === 'ancestors') candidates = [...r.nav.ancestors(target)].reverse()
|
|
337
|
+
else
|
|
338
|
+
candidates = all.filter(
|
|
339
|
+
(c) => c.id !== target.id && c.path.split(',').includes(String(target.id)),
|
|
340
|
+
)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// A members-only page is never listed for a caller who cannot open it,
|
|
344
|
+
// whether or not member sign-in is on (Umbraco lists them when it is off).
|
|
345
|
+
const visible = candidates.filter(
|
|
346
|
+
(c) =>
|
|
347
|
+
isAllowedContentType(settings, c.contentType.alias) &&
|
|
348
|
+
r.ctx.access(c) === 'allowed' &&
|
|
349
|
+
passesFilters(
|
|
350
|
+
{
|
|
351
|
+
contentType: c.contentType.alias,
|
|
352
|
+
name: c.name,
|
|
353
|
+
createDate: c.createDate,
|
|
354
|
+
updateDate: c.updateDate,
|
|
355
|
+
},
|
|
356
|
+
filters,
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
const ordered =
|
|
360
|
+
sorts.length > 0
|
|
361
|
+
? [...visible].sort(
|
|
362
|
+
compareBy(sorts) as (a: PublishedContent, b: PublishedContent) => number,
|
|
363
|
+
)
|
|
364
|
+
: visible
|
|
365
|
+
const items = []
|
|
366
|
+
for (const content of ordered.slice(skip, skip + take)) {
|
|
367
|
+
const body = await r.respond(content, url)
|
|
368
|
+
if (body instanceof Response) return body
|
|
369
|
+
if (body) items.push(body)
|
|
370
|
+
}
|
|
371
|
+
return json({ total: ordered.length, items })
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Media's tree, by parent, and each item's path of lowercased names. */
|
|
375
|
+
async function mediaTree() {
|
|
376
|
+
const media = await cache.allMedia()
|
|
377
|
+
const byId = new Map(media.map((m) => [m.id, m]))
|
|
378
|
+
const childrenOf = new Map<number, PublishedMedia[]>()
|
|
379
|
+
for (const m of media) childrenOf.set(m.parentId, [...(childrenOf.get(m.parentId) ?? []), m])
|
|
380
|
+
for (const list of childrenOf.values()) list.sort((a, b) => a.sortOrder - b.sortOrder)
|
|
381
|
+
const pathOf = (m: PublishedMedia): string => {
|
|
382
|
+
const names: string[] = []
|
|
383
|
+
for (let at: PublishedMedia | undefined = m; at; at = byId.get(at.parentId))
|
|
384
|
+
names.unshift(at.name.toLowerCase())
|
|
385
|
+
return `/${names.join('/')}/`
|
|
386
|
+
}
|
|
387
|
+
const byPath = (raw: string): PublishedMedia | undefined => {
|
|
388
|
+
let level = childrenOf.get(-1) ?? []
|
|
389
|
+
let found: PublishedMedia | undefined
|
|
390
|
+
for (const segment of decodeURIComponent(raw).split('/').filter(Boolean)) {
|
|
391
|
+
found = level.find((m) => m.name.toLowerCase() === segment.toLowerCase())
|
|
392
|
+
if (!found) return undefined
|
|
393
|
+
level = childrenOf.get(found.id) ?? []
|
|
394
|
+
}
|
|
395
|
+
return found
|
|
396
|
+
}
|
|
397
|
+
const byKey = new Map(media.map((m) => [m.key.toLowerCase(), m]))
|
|
398
|
+
return { media, childrenOf, pathOf, byPath, byKey }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function handleMedia(request: Request, url: URL, rest: string) {
|
|
402
|
+
if (!mediaAccess(settings, request)) return bare(401)
|
|
403
|
+
const tree = await mediaTree()
|
|
404
|
+
const answer = (m: PublishedMedia) => apiMediaResponse(m, tree.pathOf(m))
|
|
405
|
+
if (rest === 'items') {
|
|
406
|
+
const ids = [...new Set(url.searchParams.getAll('id').map((id) => id.toLowerCase()))]
|
|
407
|
+
for (const id of ids) if (!GUID.test(id)) return invalid('id', id)
|
|
408
|
+
return Response.json(
|
|
409
|
+
ids.flatMap((id) =>
|
|
410
|
+
tree.byKey.has(id) ? [answer(tree.byKey.get(id) as PublishedMedia)] : [],
|
|
411
|
+
),
|
|
412
|
+
)
|
|
413
|
+
}
|
|
414
|
+
if (rest.startsWith('item/')) {
|
|
415
|
+
const target = rest.slice('item/'.length)
|
|
416
|
+
const found = GUID.test(target) ? tree.byKey.get(target.toLowerCase()) : tree.byPath(target)
|
|
417
|
+
return found ? Response.json(answer(found)) : bare(404)
|
|
418
|
+
}
|
|
419
|
+
if (rest !== '') return undefined
|
|
420
|
+
const query = parseMediaQuery({
|
|
421
|
+
fetch: url.searchParams.get('fetch'),
|
|
422
|
+
filters: url.searchParams.getAll('filter'),
|
|
423
|
+
sorts: url.searchParams.getAll('sort'),
|
|
424
|
+
})
|
|
425
|
+
if (typeof query === 'string') return problem(query)
|
|
426
|
+
const skip = intParam(url, 'skip', 0)
|
|
427
|
+
if (skip instanceof Response) return skip
|
|
428
|
+
const take = intParam(url, 'take', 10)
|
|
429
|
+
if (take instanceof Response) return take
|
|
430
|
+
let parent: PublishedMedia | 'root' | undefined
|
|
431
|
+
if (query.parent === '') parent = undefined
|
|
432
|
+
else if (/^\/+$/.test(query.parent)) parent = 'root'
|
|
433
|
+
else if (GUID.test(query.parent)) parent = tree.byKey.get(query.parent.toLowerCase())
|
|
434
|
+
else parent = tree.byPath(query.parent)
|
|
435
|
+
const children = parent ? (tree.childrenOf.get(parent === 'root' ? -1 : parent.id) ?? []) : []
|
|
436
|
+
const filtered = children.filter((m) =>
|
|
437
|
+
query.filters.every((f) =>
|
|
438
|
+
f.kind === 'mediaType'
|
|
439
|
+
? m.mediaType.alias === f.value
|
|
440
|
+
: m.name.toLowerCase().includes(f.value.toLowerCase()),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
const ordered = query.sort
|
|
444
|
+
? [...filtered].sort(
|
|
445
|
+
compareBy([query.sort]) as unknown as (a: PublishedMedia, b: PublishedMedia) => number,
|
|
446
|
+
)
|
|
447
|
+
: filtered
|
|
448
|
+
return Response.json({
|
|
449
|
+
total: ordered.length,
|
|
450
|
+
items: ordered.slice(skip, skip + take).map(answer),
|
|
451
|
+
})
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
async handle(request) {
|
|
456
|
+
const url = new URL(request.url)
|
|
457
|
+
if (!url.pathname.startsWith(`${prefix}/`)) return undefined
|
|
458
|
+
const rest = url.pathname.slice(prefix.length + 1)
|
|
459
|
+
|
|
460
|
+
const security = /^v1\/security\/member\/(.+)$/.exec(rest)
|
|
461
|
+
if (security) return ports.security?.(request, security[1] as string)
|
|
462
|
+
|
|
463
|
+
const match = /^v2\/(content|media)(?:\/(.*))?$/.exec(rest)
|
|
464
|
+
if (!match) return undefined
|
|
465
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') return bare(405)
|
|
466
|
+
const area = match[1]
|
|
467
|
+
const tail = (match[2] ?? '').replace(/\/$/, '')
|
|
468
|
+
|
|
469
|
+
if (area === 'media')
|
|
470
|
+
return (
|
|
471
|
+
(await handleMedia(request, url, tail === '' && match[2] === undefined ? '' : tail)) ??
|
|
472
|
+
bare(404)
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if (!contentAccess(settings, request)) return bare(401)
|
|
476
|
+
const r = await contentRequest(request)
|
|
477
|
+
if (r.badStartItem) return Response.json('The Start-Item could not be found', { status: 404 })
|
|
478
|
+
if (tail === '' && match[2] === undefined) return contentQuery(r, url)
|
|
479
|
+
if (tail === 'items') return contentItems(r, url)
|
|
480
|
+
if (match[2]?.startsWith('item')) {
|
|
481
|
+
const target = (match[2] as string).replace(/^item\/?/, '')
|
|
482
|
+
return contentItem(r, GUID.test(target) ? r.nav.byKey(target) : r.byPath(target), url)
|
|
483
|
+
}
|
|
484
|
+
return bare(404)
|
|
485
|
+
},
|
|
486
|
+
}
|
|
487
|
+
}
|