@crombolo/api-management 0.0.0-stage → 0.9.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +32 -4
  4. package/src/authorization.ts +453 -0
  5. package/src/handlers/content-type.ts +784 -0
  6. package/src/handlers/data-type.ts +267 -0
  7. package/src/handlers/dictionary.ts +245 -0
  8. package/src/handlers/document-blueprint.ts +193 -0
  9. package/src/handlers/document.ts +961 -0
  10. package/src/handlers/editor-services.ts +59 -0
  11. package/src/handlers/element.ts +492 -0
  12. package/src/handlers/file-systems.ts +207 -0
  13. package/src/handlers/import.ts +46 -0
  14. package/src/handlers/localization.ts +116 -0
  15. package/src/handlers/log-viewer.ts +125 -0
  16. package/src/handlers/manifest.ts +15 -0
  17. package/src/handlers/media.ts +453 -0
  18. package/src/handlers/member-group.ts +87 -0
  19. package/src/handlers/member.ts +189 -0
  20. package/src/handlers/models-builder.ts +29 -0
  21. package/src/handlers/package.ts +189 -0
  22. package/src/handlers/public-access.ts +107 -0
  23. package/src/handlers/published-cache.ts +27 -0
  24. package/src/handlers/redirects.ts +78 -0
  25. package/src/handlers/references.ts +44 -0
  26. package/src/handlers/server.ts +25 -0
  27. package/src/handlers/settings-stubs.ts +76 -0
  28. package/src/handlers/start-node-trees.ts +84 -0
  29. package/src/handlers/template-query.ts +191 -0
  30. package/src/handlers/template.ts +150 -0
  31. package/src/handlers/temporary-file.ts +62 -0
  32. package/src/handlers/user.ts +604 -0
  33. package/src/index.ts +17 -0
  34. package/src/ports-content.ts +564 -0
  35. package/src/ports-files.ts +51 -0
  36. package/src/ports-logs.ts +38 -0
  37. package/src/ports-members.ts +111 -0
  38. package/src/ports-models-builder.ts +26 -0
  39. package/src/ports-packages.ts +58 -0
  40. package/src/ports-users.ts +230 -0
  41. package/src/ports.ts +225 -0
  42. package/src/router.ts +216 -0
  43. package/src/routes.ts +99 -0
  44. package/src/server-events.ts +71 -0
  45. package/README.md +0 -3
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The two endpoints the backoffice probes before anything else. If either fails
3
+ * the client hard-redirects to its error page, so they must be anonymous and
4
+ * must not depend on the database being reachable. Plus the upload settings
5
+ * the editor reads before it offers a file picker.
6
+ */
7
+ import { DEFAULT_UPLOAD_SETTINGS } from '@crombolo/core'
8
+ import type { ServerPort } from '../ports.ts'
9
+ import type { ManagementApiRouter } from '../router.ts'
10
+
11
+ export function registerServerHandlers(router: ManagementApiRouter, server: ServerPort): void {
12
+ router.handle('GetServerStatus', () => Response.json(server.status()))
13
+ router.handle('GetServerConfiguration', () => Response.json(server.configuration()))
14
+ router.handle('GetServerInformation', () => Response.json(server.information()))
15
+
16
+ router.handle('GetTemporaryFileConfiguration', () => {
17
+ const settings = server.uploadSettings?.() ?? DEFAULT_UPLOAD_SETTINGS
18
+ return Response.json({
19
+ imageFileTypes: settings.imageFileTypes,
20
+ disallowedUploadedFilesExtensions: settings.disallowedExtensions,
21
+ allowedUploadedFileExtensions: settings.allowedExtensions,
22
+ maxFileSize: settings.maxFileSize,
23
+ })
24
+ })
25
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Trees and lists the backoffice renders for areas not yet built — in the
3
+ * Settings, Media and Library sections. Each answers an honest empty result in
4
+ * the shape its operation declares, so every section loads, expands and
5
+ * deep-links without an error toast; every one is replaced by a real handler
6
+ * when its work package lands (docs/07-roadmap.md, Phase 6). Counted
7
+ * separately by `coverage:api`.
8
+ */
9
+ import { invalidSkipTake, notFound, problemResponse } from '@crombolo/core'
10
+ import type { ManagementApiRouter } from '../router.ts'
11
+ import { paging } from './content-type.ts'
12
+
13
+ /** Trees with root, children, ancestors and siblings. */
14
+ const STUB_TREES = [] as const
15
+ /** Recycle bins for areas that have nothing to trash yet. */
16
+ const STUB_BINS = [] as const
17
+
18
+ /** Paged lists: `{ total, items }`, honouring skip/take validation. */
19
+ const PAGED = [
20
+ ...STUB_TREES.flatMap((t) => [`GetTree${t}Root`, `GetTree${t}Children`]),
21
+ ...STUB_BINS.flatMap((b) => [
22
+ `GetRecycleBin${b}Root`,
23
+ `GetRecycleBin${b}Children`,
24
+ `GetRecycleBin${b}ReferencedBy`,
25
+ ]),
26
+ 'GetRecycleBinDocumentReferencedBy', // needs relations, WP-6.9
27
+ 'GetRecycleBinMediaReferencedBy',
28
+ 'GetRecycleBinElementReferencedBy',
29
+ // The recycle bin and the trash and delete dialogs; needs relations, WP-6.9. Until
30
+ // then they cannot warn that other content links to what is being removed.
31
+ 'GetTreeStaticFileRoot',
32
+ 'GetTreeStaticFileChildren',
33
+ 'GetRelationType',
34
+ 'GetWebhook',
35
+ ]
36
+ /** Ancestor lookups: a bare array. */
37
+ const ANCESTORS = [...STUB_TREES.map((t) => `GetTree${t}Ancestors`), 'GetTreeStaticFileAncestors']
38
+ /** Sibling windows: `{ totalBefore, totalAfter, items }`. */
39
+ const SIBLINGS = [
40
+ ...STUB_TREES.map((t) => `GetTree${t}Siblings`),
41
+ ...STUB_BINS.map((b) => `GetRecycleBin${b}Siblings`),
42
+ ]
43
+ /** Lookups of one thing in an empty area: there is nothing to find. */
44
+ const NOT_FOUND: string[] = []
45
+
46
+ /** Fixed bodies. Umbraco's news dashboard relays umbraco.com; there is no feed to relay here. */
47
+ const FIXED: Record<string, unknown> = {
48
+ GetNewsDashboard: { items: [] },
49
+ }
50
+
51
+ export const EMPTY_SETTINGS_OPERATIONS: readonly string[] = [
52
+ ...PAGED,
53
+ ...ANCESTORS,
54
+ ...SIBLINGS,
55
+ ...NOT_FOUND,
56
+ ...Object.keys(FIXED),
57
+ ]
58
+
59
+ export function registerSettingsStubHandlers(router: ManagementApiRouter): void {
60
+ for (const operation of PAGED) {
61
+ router.handle(operation, (ctx) => {
62
+ if (!paging(ctx)) return problemResponse(invalidSkipTake())
63
+ return Response.json({ total: 0, items: [] })
64
+ })
65
+ }
66
+ for (const operation of ANCESTORS) router.handle(operation, () => Response.json([]))
67
+ for (const operation of SIBLINGS) {
68
+ router.handle(operation, () => Response.json({ totalBefore: 0, totalAfter: 0, items: [] }))
69
+ }
70
+ for (const operation of NOT_FOUND) {
71
+ router.handle(operation, () => problemResponse(notFound('The item could not be found')))
72
+ }
73
+ for (const [operation, body] of Object.entries(FIXED)) {
74
+ router.handle(operation, () => Response.json(body))
75
+ }
76
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Trees as a user with start nodes sees them, as Umbraco's
3
+ * `UserStartNodeTreeControllerBase` serves them: at the root, their start
4
+ * nodes and the top-level ancestors of deeper ones; below a place they cannot
5
+ * reach, only the way down to a start node. Nodes on that way are shown with
6
+ * `noAccess`. With root access nothing is filtered.
7
+ */
8
+ import { type DocumentTreeItem, hasPathAccess, type Page, type StartNodes } from '@crombolo/core'
9
+ import type { SkipTake } from '../ports-content.ts'
10
+
11
+ export interface TreeSource {
12
+ treeRoot(paging: SkipTake): Promise<Page<DocumentTreeItem>>
13
+ treeChildren(parentKey: string, paging: SkipTake): Promise<Page<DocumentTreeItem>>
14
+ items(keys: readonly string[]): Promise<DocumentTreeItem[]>
15
+ }
16
+
17
+ export interface VisibleItem {
18
+ item: DocumentTreeItem
19
+ noAccess: boolean
20
+ }
21
+
22
+ const EVERYTHING: SkipTake = { skip: 0, take: 1_000_000 }
23
+ const chainOf = (item: DocumentTreeItem) => [...item.ancestorKeys, item.key]
24
+
25
+ async function startChains(start: StartNodes, source: TreeSource): Promise<string[][]> {
26
+ return (await source.items(start.keys)).filter((i) => !i.isTrashed).map(chainOf)
27
+ }
28
+
29
+ /** Only what lies on the way to a start node; skip and take no longer apply. */
30
+ async function onTheWay(
31
+ start: StartNodes,
32
+ source: TreeSource,
33
+ items: readonly DocumentTreeItem[],
34
+ ): Promise<Page<VisibleItem>> {
35
+ const chains = await startChains(start, source)
36
+ const visible = items
37
+ .filter((item) => chains.some((chain) => chain.includes(item.key)))
38
+ .map((item) => ({ item, noAccess: !start.keys.includes(item.key) }))
39
+ return { total: visible.length, items: visible }
40
+ }
41
+
42
+ const all = (page: Page<DocumentTreeItem>): Page<VisibleItem> => ({
43
+ total: page.total,
44
+ items: page.items.map((item) => ({ item, noAccess: false })),
45
+ })
46
+
47
+ export async function visibleRoot(
48
+ start: StartNodes,
49
+ source: TreeSource,
50
+ paging: SkipTake,
51
+ ): Promise<Page<VisibleItem>> {
52
+ if (start.root) return all(await source.treeRoot(paging))
53
+ return onTheWay(start, source, (await source.treeRoot(EVERYTHING)).items)
54
+ }
55
+
56
+ export async function visibleChildren(
57
+ start: StartNodes,
58
+ source: TreeSource,
59
+ parentKey: string,
60
+ paging: SkipTake,
61
+ ): Promise<Page<VisibleItem>> {
62
+ if (start.root) return all(await source.treeChildren(parentKey, paging))
63
+ const [parent] = await source.items([parentKey])
64
+ if (parent && hasPathAccess(start, chainOf(parent), parent.isTrashed))
65
+ return all(await source.treeChildren(parentKey, paging))
66
+ return onTheWay(start, source, (await source.treeChildren(parentKey, EVERYTHING)).items)
67
+ }
68
+
69
+ /** A siblings window, cut down to the way to a start node when the parent is out of reach. */
70
+ export async function visibleSiblings(
71
+ start: StartNodes,
72
+ source: TreeSource,
73
+ window: { items: DocumentTreeItem[]; totalBefore: number; totalAfter: number },
74
+ ): Promise<{ items: VisibleItem[]; totalBefore: number; totalAfter: number }> {
75
+ const whole = { ...window, items: window.items.map((item) => ({ item, noAccess: false })) }
76
+ if (start.root) return whole
77
+ const parentKey = window.items[0]?.parentKey
78
+ if (parentKey) {
79
+ const [parent] = await source.items([parentKey])
80
+ if (parent && hasPathAccess(start, chainOf(parent), parent.isTrashed)) return whole
81
+ }
82
+ const visible = await onTheWay(start, source, window.items)
83
+ return { items: visible.items, totalBefore: 0, totalAfter: 0 }
84
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The template editor's query builder.
3
+ *
4
+ * It writes the snippet a component uses to pick content, so what it emits is
5
+ * **TypeScript against the render model** — `nav.children(...)` and the
6
+ * accessors a `.tsx` already has — rather than the Razor and LINQ Umbraco's own
7
+ * builder writes. The wire contract is still Umbraco's, so the field names here
8
+ * are theirs and only the expression inside is ours.
9
+ *
10
+ * `execute` runs the query it describes, so the count and the sample are the
11
+ * answer the snippet would give rather than a guess about it.
12
+ */
13
+ import type { ResponseOf } from '@crombolo/contracts'
14
+ import type {
15
+ TemplateQueryFilter,
16
+ TemplateQueryPort,
17
+ TemplateQueryRequest,
18
+ TemplateQuerySort,
19
+ } from '../ports-content.ts'
20
+ import type { ManagementApiRouter } from '../router.ts'
21
+
22
+ /** What the builder can filter and sort on: the model's own fields. */
23
+ const PROPERTIES = [
24
+ { alias: 'Id', type: 'Integer' },
25
+ { alias: 'Name', type: 'String' },
26
+ { alias: 'CreateDate', type: 'DateTime' },
27
+ { alias: 'UpdateDate', type: 'DateTime' },
28
+ ] as const
29
+
30
+ const OPERATORS = [
31
+ { operator: 'Equals', applicableTypes: ['String', 'DateTime', 'Integer'] },
32
+ { operator: 'NotEquals', applicableTypes: ['String', 'DateTime', 'Integer'] },
33
+ { operator: 'Contains', applicableTypes: ['String'] },
34
+ { operator: 'NotContains', applicableTypes: ['String'] },
35
+ { operator: 'LessThan', applicableTypes: ['DateTime', 'Integer'] },
36
+ { operator: 'LessThanEqualTo', applicableTypes: ['DateTime', 'Integer'] },
37
+ { operator: 'GreaterThan', applicableTypes: ['DateTime', 'Integer'] },
38
+ { operator: 'GreaterThanEqualTo', applicableTypes: ['DateTime', 'Integer'] },
39
+ ] as const
40
+
41
+ const TYPE_OF = new Map<string, string>(PROPERTIES.map((p) => [p.alias, p.type]))
42
+
43
+ /** The accessor on a `PublishedContent` that each property names. */
44
+ const ACCESSOR: Record<string, string> = {
45
+ Id: 'id',
46
+ Name: 'name',
47
+ CreateDate: 'createDate',
48
+ UpdateDate: 'updateDate',
49
+ }
50
+
51
+ const quote = (value: string) => `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`
52
+
53
+ /** A filter as a TypeScript predicate over `item`. */
54
+ function predicate(filter: TemplateQueryFilter): string | undefined {
55
+ const accessor = ACCESSOR[filter.propertyAlias]
56
+ const type = TYPE_OF.get(filter.propertyAlias)
57
+ if (!accessor || !type) return undefined
58
+ const left = `item.${accessor}`
59
+ const value = filter.constraintValue
60
+
61
+ if (type === 'String') {
62
+ const right = quote(value)
63
+ switch (filter.operator) {
64
+ case 'Equals':
65
+ return `${left} === ${right}`
66
+ case 'NotEquals':
67
+ return `${left} !== ${right}`
68
+ case 'Contains':
69
+ return `${left}.includes(${right})`
70
+ case 'NotContains':
71
+ return `!${left}.includes(${right})`
72
+ default:
73
+ return undefined
74
+ }
75
+ }
76
+
77
+ // A date compares as a number, which is also what `Integer` already is.
78
+ const left_ = type === 'DateTime' ? `${left}.getTime()` : left
79
+ const right_ =
80
+ type === 'DateTime' ? `new Date(${quote(value)}).getTime()` : String(Number(value) || 0)
81
+ switch (filter.operator) {
82
+ case 'Equals':
83
+ return `${left_} === ${right_}`
84
+ case 'NotEquals':
85
+ return `${left_} !== ${right_}`
86
+ case 'LessThan':
87
+ return `${left_} < ${right_}`
88
+ case 'LessThanEqualTo':
89
+ return `${left_} <= ${right_}`
90
+ case 'GreaterThan':
91
+ return `${left_} > ${right_}`
92
+ case 'GreaterThanEqualTo':
93
+ return `${left_} >= ${right_}`
94
+ default:
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ /** A sort as the comparator `Array.prototype.sort` takes. */
100
+ function comparator(sort: TemplateQuerySort): string | undefined {
101
+ const accessor = ACCESSOR[sort.propertyAlias]
102
+ const type = TYPE_OF.get(sort.propertyAlias)
103
+ if (!accessor || !type) return undefined
104
+ const [first, second] = sort.direction?.toLowerCase() === 'descending' ? ['b', 'a'] : ['a', 'b']
105
+ if (type === 'String') return `${first}.${accessor}.localeCompare(${second}.${accessor})`
106
+ if (type === 'DateTime') return `${first}.${accessor}.getTime() - ${second}.${accessor}.getTime()`
107
+ return `${first}.${accessor} - ${second}.${accessor}`
108
+ }
109
+
110
+ /**
111
+ * The snippet itself.
112
+ *
113
+ * `?? model` rather than a guard on the lookup: a key that no longer resolves
114
+ * falls back to the page being rendered, so the component still compiles and
115
+ * still renders something.
116
+ */
117
+ export function queryExpression(request: TemplateQueryRequest): string {
118
+ const from = request.rootKey
119
+ ? `nav.children(nav.byKey(${quote(request.rootKey)}) ?? model)`
120
+ : 'nav.children(model)'
121
+ const lines = [`const selection = ${from}`]
122
+ if (request.documentTypeAlias)
123
+ lines.push(
124
+ ` .filter((item) => item.contentType.alias === ${quote(request.documentTypeAlias)})`,
125
+ )
126
+ for (const filter of request.filters) {
127
+ const expression = predicate(filter)
128
+ if (expression) lines.push(` .filter((item) => ${expression})`)
129
+ }
130
+ const sort = request.sort ? comparator(request.sort) : undefined
131
+ if (sort) lines.push(` .sort((a, b) => ${sort})`)
132
+ lines.push(` .slice(0, ${request.take})`)
133
+ return lines.join('\n')
134
+ }
135
+
136
+ export function registerTemplateQueryHandlers(
137
+ router: ManagementApiRouter,
138
+ port: TemplateQueryPort,
139
+ ): void {
140
+ router.handle('GetTemplateQuerySettings', async () => {
141
+ const documentTypeAliases = await port.documentTypeAliases()
142
+ return Response.json({
143
+ documentTypeAliases,
144
+ properties: PROPERTIES.map((property) => ({ ...property })),
145
+ operators: OPERATORS.map((operator) => ({
146
+ operator: operator.operator,
147
+ applicableTypes: [...operator.applicableTypes],
148
+ })),
149
+ } as ResponseOf<'GetTemplateQuerySettings'>)
150
+ })
151
+
152
+ router.handle('PostTemplateQueryExecute', async (ctx) => {
153
+ const body = (await ctx.request.json()) as Record<string, unknown>
154
+ const root = body.rootDocument as { id?: unknown } | null | undefined
155
+ const request: TemplateQueryRequest = {
156
+ rootKey: typeof root?.id === 'string' ? root.id : null,
157
+ documentTypeAlias:
158
+ typeof body.documentTypeAlias === 'string' && body.documentTypeAlias
159
+ ? body.documentTypeAlias
160
+ : null,
161
+ filters: (Array.isArray(body.filters) ? body.filters : [])
162
+ .map((filter) => filter as Record<string, unknown>)
163
+ .filter((filter) => typeof filter.propertyAlias === 'string' && filter.propertyAlias)
164
+ .map((filter) => ({
165
+ propertyAlias: String(filter.propertyAlias),
166
+ constraintValue: String(filter.constraintValue ?? ''),
167
+ operator: String(filter.operator ?? 'Equals'),
168
+ })),
169
+ sort:
170
+ body.sort && typeof (body.sort as Record<string, unknown>).propertyAlias === 'string'
171
+ ? {
172
+ propertyAlias: String((body.sort as Record<string, unknown>).propertyAlias),
173
+ direction: ((body.sort as Record<string, unknown>).direction as string) ?? null,
174
+ }
175
+ : null,
176
+ take: Number(body.take) > 0 ? Number(body.take) : 10,
177
+ }
178
+
179
+ const started = Bun.nanoseconds()
180
+ const result = await port.execute(request)
181
+ // Milliseconds, as the contract's `int64` and the client's label expect.
182
+ const executionTime = Math.round((Bun.nanoseconds() - started) / 1_000_000)
183
+
184
+ return Response.json({
185
+ queryExpression: queryExpression(request),
186
+ sampleResults: result.items.map((item) => ({ icon: item.icon, name: item.name })),
187
+ resultCount: result.total,
188
+ executionTime,
189
+ } as ResponseOf<'PostTemplateQueryExecute'>)
190
+ })
191
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Templates. The record holds the alias; the view file on disk holds the markup,
3
+ * and the editor reads and writes it through `content`.
4
+ */
5
+ import type { ResponseOf } from '@crombolo/contracts'
6
+ import type { ComponentModel } from '@crombolo/core'
7
+ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
8
+ import type { TemplatePort } from '../ports-content.ts'
9
+ import type { ManagementApiRouter } from '../router.ts'
10
+ import { created, paging, toTreeItemResponse } from './content-type.ts'
11
+
12
+ function toResponse(model: ComponentModel): ResponseOf<'GetTemplateById'> {
13
+ return {
14
+ id: model.key,
15
+ name: model.name,
16
+ alias: model.alias,
17
+ content: model.content,
18
+ // The contract keeps the older name beside the one the client reads
19
+ layoutTemplate: model.masterKey ? { id: model.masterKey } : null,
20
+ masterTemplate: model.masterKey ? { id: model.masterKey } : null,
21
+ } as ResponseOf<'GetTemplateById'>
22
+ }
23
+
24
+ export function registerTemplateHandlers(router: ManagementApiRouter, port: TemplatePort): void {
25
+ router.handle('GetTemplateById', async ({ params }) => {
26
+ const model = await port.byKey(params.id as string)
27
+ if (!model) return problemResponse(notFound('The template could not be found'))
28
+ return Response.json(toResponse(model))
29
+ })
30
+
31
+ router.handle('PostTemplate', async (ctx) => {
32
+ const body = (await ctx.request.json()) as Record<string, unknown>
33
+ const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
34
+ const name = String(body.name ?? '')
35
+ const alias = String(body.alias ?? name)
36
+ await port.save(
37
+ {
38
+ key,
39
+ name,
40
+ alias,
41
+ content: typeof body.content === 'string' ? body.content : port.scaffold(name, alias),
42
+ },
43
+ ctx.principal as NonNullable<typeof ctx.principal>,
44
+ )
45
+ ctx.notifications.push({ message: 'Template created', category: 'Template', type: 'Success' })
46
+ return created(`${ctx.url.origin}/umbraco/management/api/v1/template/${key}`, key)
47
+ })
48
+
49
+ router.handle('PutTemplateById', async (ctx) => {
50
+ const key = ctx.params.id as string
51
+ const existing = await port.byKey(key)
52
+ if (!existing) return problemResponse(notFound('The template could not be found'))
53
+ const body = (await ctx.request.json()) as Record<string, unknown>
54
+ await port.save(
55
+ {
56
+ key,
57
+ name: String(body.name ?? existing.name),
58
+ alias: String(body.alias ?? existing.alias),
59
+ content: typeof body.content === 'string' ? body.content : existing.content,
60
+ },
61
+ ctx.principal as NonNullable<typeof ctx.principal>,
62
+ )
63
+ ctx.notifications.push({ message: 'Template saved', category: 'Template', type: 'Success' })
64
+ return new Response(null, { status: 200 })
65
+ })
66
+
67
+ router.handle('DeleteTemplateById', async (ctx) => {
68
+ const removed = await port.remove(ctx.params.id as string)
69
+ if (!removed) return problemResponse(notFound('The template could not be found'))
70
+ return new Response(null, { status: 200 })
71
+ })
72
+
73
+ router.handle('GetTreeTemplateRoot', async (ctx) => {
74
+ const page = paging(ctx)
75
+ if (!page) return problemResponse(invalidSkipTake())
76
+ const result = await port.root(page)
77
+ return Response.json({ total: result.total, items: result.items.map(toTreeItemResponse) })
78
+ })
79
+
80
+ router.handle('GetTreeTemplateChildren', async (ctx) => {
81
+ const page = paging(ctx)
82
+ if (!page) return problemResponse(invalidSkipTake())
83
+ const parentId = ctx.url.searchParams.get('parentId')
84
+ if (!parentId)
85
+ return problemResponse(problemDetails({ title: 'parentId is required', status: 400 }))
86
+ const result = await port.children(parentId, page)
87
+ return Response.json({ total: result.total, items: result.items.map(toTreeItemResponse) })
88
+ })
89
+
90
+ router.handle('GetTreeTemplateAncestors', async (ctx) => {
91
+ const descendantId = ctx.url.searchParams.get('descendantId')
92
+ // Umbraco binds a missing id to an empty GUID and answers with no ancestors.
93
+ if (!descendantId) return Response.json([])
94
+ return Response.json((await port.ancestors(descendantId)).map(toTreeItemResponse))
95
+ })
96
+
97
+ /** A template's ancestors are its layout chain, outermost first. */
98
+ router.handle('GetItemTemplateAncestors', async (ctx) => {
99
+ const result = []
100
+ for (const id of ctx.url.searchParams.getAll('id')) {
101
+ const ancestors = []
102
+ for (const item of await port.ancestors(id)) {
103
+ const model = await port.byKey(item.key)
104
+ ancestors.push({ id: item.key, name: item.name, alias: model?.alias ?? '', flags: [] })
105
+ }
106
+ result.push({ id, ancestors })
107
+ }
108
+ return Response.json(result)
109
+ })
110
+
111
+ router.handle('GetTreeTemplateSiblings', async (ctx) => {
112
+ const target = ctx.url.searchParams.get('target')
113
+ if (!target)
114
+ return problemResponse(problemDetails({ title: 'target is required', status: 400 }))
115
+ const window = await port.siblings(
116
+ target,
117
+ Number(ctx.url.searchParams.get('before') ?? 0),
118
+ Number(ctx.url.searchParams.get('after') ?? 0),
119
+ )
120
+ if (!window) return problemResponse(notFound('The template could not be found'))
121
+ return Response.json({ ...window, items: window.items.map(toTreeItemResponse) })
122
+ })
123
+
124
+ router.handle('GetItemTemplateSearch', async (ctx) => {
125
+ const page = paging(ctx)
126
+ if (!page) return problemResponse(invalidSkipTake())
127
+ const result = await port.search(ctx.url.searchParams.get('query') ?? '', page)
128
+ return Response.json({
129
+ total: result.total,
130
+ items: result.items.map((t) => ({ id: t.key, name: t.name, alias: t.alias, flags: [] })),
131
+ })
132
+ })
133
+
134
+ router.handle('GetItemTemplate', async (ctx) => {
135
+ const keys = ctx.url.searchParams.getAll('id')
136
+ const items = await port.items(keys)
137
+ // The template editor writes the master template's alias into the view
138
+ const aliases = await Promise.all(
139
+ items.map(async (item) => (await port.byKey(item.key))?.alias),
140
+ )
141
+ return Response.json(
142
+ items.map((item, i) => ({
143
+ id: item.key,
144
+ name: item.name,
145
+ alias: aliases[i] ?? null,
146
+ flags: [],
147
+ })),
148
+ )
149
+ })
150
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Temporary files: what an upload editor or the media dropzone posts before the
3
+ * value that uses it is saved.
4
+ */
5
+ import { notFound, problemDetails, problemResponse } from '@crombolo/core'
6
+ import type { TemporaryFilePort } from '../ports.ts'
7
+ import type { ManagementApiRouter } from '../router.ts'
8
+ import { created } from './content-type.ts'
9
+
10
+ const toResponse = (file: { id: string; fileName: string; availableUntil: Date }) => ({
11
+ id: file.id,
12
+ fileName: file.fileName,
13
+ availableUntil: file.availableUntil.toISOString(),
14
+ })
15
+
16
+ export function registerTemporaryFileHandlers(
17
+ router: ManagementApiRouter,
18
+ port: TemporaryFilePort,
19
+ ): void {
20
+ router.handle('PostTemporaryFile', async (ctx) => {
21
+ let form: FormData
22
+ try {
23
+ form = await ctx.request.formData()
24
+ } catch {
25
+ return problemResponse(
26
+ problemDetails({ title: 'The upload must be multipart/form-data', status: 400 }),
27
+ )
28
+ }
29
+ const file = form.get('File')
30
+ const id = String(form.get('Id') ?? '') || crypto.randomUUID()
31
+ if (!(file instanceof File))
32
+ return problemResponse(problemDetails({ title: 'No file was uploaded', status: 400 }))
33
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))
34
+ return problemResponse(problemDetails({ title: 'The id must be a uuid', status: 400 }))
35
+ const result = await port.save(id, file)
36
+ if (!result.ok)
37
+ return problemResponse(
38
+ problemDetails({
39
+ title: 'File upload failed',
40
+ detail: result.reason,
41
+ status: 400,
42
+ operationStatus: 'FileExtensionNotAllowed',
43
+ }),
44
+ )
45
+ return created(
46
+ `${ctx.url.origin}/umbraco/management/api/v1/temporary-file/${result.file.id}`,
47
+ result.file.id,
48
+ )
49
+ })
50
+
51
+ router.handle('GetTemporaryFileById', async ({ params }) => {
52
+ const file = await port.get(params.id as string)
53
+ if (!file) return problemResponse(notFound('The temporary file could not be found'))
54
+ return Response.json(toResponse(file))
55
+ })
56
+
57
+ router.handle('DeleteTemporaryFileById', async ({ params }) => {
58
+ const removed = await port.remove(params.id as string)
59
+ if (!removed) return problemResponse(notFound('The temporary file could not be found'))
60
+ return new Response(null, { status: 200 })
61
+ })
62
+ }