@crombolo/api-management 0.0.0-stage → 0.10.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/NOTICE +69 -0
- package/package.json +32 -4
- package/src/authorization.ts +453 -0
- package/src/handlers/content-type.ts +784 -0
- package/src/handlers/data-type.ts +267 -0
- package/src/handlers/dictionary.ts +245 -0
- package/src/handlers/document-blueprint.ts +193 -0
- package/src/handlers/document.ts +961 -0
- package/src/handlers/editor-services.ts +59 -0
- package/src/handlers/element.ts +492 -0
- package/src/handlers/file-systems.ts +207 -0
- package/src/handlers/import.ts +46 -0
- package/src/handlers/localization.ts +116 -0
- package/src/handlers/log-viewer.ts +125 -0
- package/src/handlers/manifest.ts +15 -0
- package/src/handlers/media.ts +453 -0
- package/src/handlers/member-group.ts +87 -0
- package/src/handlers/member.ts +189 -0
- package/src/handlers/models-builder.ts +29 -0
- package/src/handlers/package.ts +193 -0
- package/src/handlers/public-access.ts +107 -0
- package/src/handlers/published-cache.ts +27 -0
- package/src/handlers/redirects.ts +78 -0
- package/src/handlers/references.ts +44 -0
- package/src/handlers/server.ts +25 -0
- package/src/handlers/settings-stubs.ts +76 -0
- package/src/handlers/start-node-trees.ts +84 -0
- package/src/handlers/template-query.ts +191 -0
- package/src/handlers/template.ts +150 -0
- package/src/handlers/temporary-file.ts +62 -0
- package/src/handlers/user.ts +604 -0
- package/src/index.ts +17 -0
- package/src/ports-content.ts +564 -0
- package/src/ports-files.ts +51 -0
- package/src/ports-logs.ts +38 -0
- package/src/ports-members.ts +111 -0
- package/src/ports-models-builder.ts +26 -0
- package/src/ports-packages.ts +59 -0
- package/src/ports-users.ts +230 -0
- package/src/ports.ts +225 -0
- package/src/router.ts +216 -0
- package/src/routes.ts +99 -0
- package/src/server-events.ts +71 -0
- package/README.md +0 -3
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Members section: the collection, the editor, and the lookups member
|
|
3
|
+
* pickers use.
|
|
4
|
+
*
|
|
5
|
+
* Members are not a tree — the contract gives them no `tree/member` operations —
|
|
6
|
+
* so the collection is `filter/member`, which is Umbraco's own member filter with
|
|
7
|
+
* the same columns and the same default order (username, ascending).
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
type DocumentValue,
|
|
11
|
+
invalidSkipTake,
|
|
12
|
+
notFound,
|
|
13
|
+
paged,
|
|
14
|
+
problemDetails,
|
|
15
|
+
problemResponse,
|
|
16
|
+
} from '@crombolo/core'
|
|
17
|
+
import type { PortFailure } from '../ports-content.ts'
|
|
18
|
+
import type { MemberPort, SaveMember } from '../ports-members.ts'
|
|
19
|
+
import type { ManagementApiRouter, Principal, RequestContext } from '../router.ts'
|
|
20
|
+
import { created, paging } from './content-type.ts'
|
|
21
|
+
import { readBody, validationProblem } from './document.ts'
|
|
22
|
+
|
|
23
|
+
const V1 = '/umbraco/management/api/v1'
|
|
24
|
+
|
|
25
|
+
const refId = (value: unknown): string | null => {
|
|
26
|
+
if (!value || typeof value !== 'object') return null
|
|
27
|
+
const id = (value as Record<string, unknown>).id
|
|
28
|
+
return typeof id === 'string' && id ? id : null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const strings = (value: unknown): string[] | undefined =>
|
|
32
|
+
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined
|
|
33
|
+
|
|
34
|
+
function readValues(body: Record<string, unknown>): DocumentValue[] {
|
|
35
|
+
if (!Array.isArray(body.values)) return []
|
|
36
|
+
return (body.values as Array<Record<string, unknown>>).map((value) => ({
|
|
37
|
+
alias: String(value.alias ?? ''),
|
|
38
|
+
culture: (value.culture as string | null) ?? null,
|
|
39
|
+
segment: (value.segment as string | null) ?? null,
|
|
40
|
+
value: value.value,
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readVariants(body: Record<string, unknown>) {
|
|
45
|
+
if (!Array.isArray(body.variants)) return []
|
|
46
|
+
return (body.variants as Array<Record<string, unknown>>).map((variant) => ({
|
|
47
|
+
culture: (variant.culture as string | null) ?? null,
|
|
48
|
+
segment: (variant.segment as string | null) ?? null,
|
|
49
|
+
name: String(variant.name ?? ''),
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A member save request. Create carries `password` and `memberType`; update
|
|
55
|
+
* carries `newPassword` with `oldPassword` and neither of the other two, since
|
|
56
|
+
* a member's type never changes.
|
|
57
|
+
*/
|
|
58
|
+
export function toSaveMember(key: string, body: Record<string, unknown>): SaveMember {
|
|
59
|
+
const newPassword = typeof body.newPassword === 'string' ? body.newPassword : null
|
|
60
|
+
return {
|
|
61
|
+
key,
|
|
62
|
+
memberTypeKey: refId(body.memberType) ?? '',
|
|
63
|
+
email: typeof body.email === 'string' ? body.email.trim() : '',
|
|
64
|
+
username: typeof body.username === 'string' ? body.username.trim() : '',
|
|
65
|
+
password: typeof body.password === 'string' ? body.password : newPassword,
|
|
66
|
+
oldPassword: typeof body.oldPassword === 'string' ? body.oldPassword : null,
|
|
67
|
+
isApproved: body.isApproved !== false,
|
|
68
|
+
isLockedOut: body.isLockedOut === true,
|
|
69
|
+
isTwoFactorEnabled: body.isTwoFactorEnabled === true,
|
|
70
|
+
groupKeys: strings(body.groups),
|
|
71
|
+
values: readValues(body),
|
|
72
|
+
variants: readVariants(body),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const failed = (title: string, result: PortFailure) =>
|
|
77
|
+
problemResponse(problemDetails({ title, status: result.status ?? 400, detail: result.reason }))
|
|
78
|
+
|
|
79
|
+
const missing = () => problemResponse(notFound('The member could not be found'))
|
|
80
|
+
|
|
81
|
+
/** Required fields the contract declares but does not constrain. */
|
|
82
|
+
function missingFields(input: SaveMember, forCreate: boolean): string | undefined {
|
|
83
|
+
if (!input.email) return 'An e-mail address is required'
|
|
84
|
+
if (!input.username) return 'A username is required'
|
|
85
|
+
if (input.variants.length === 0 || !input.variants[0]?.name) return 'A name is required'
|
|
86
|
+
if (forCreate && !input.memberTypeKey) return 'A member type is required'
|
|
87
|
+
if (forCreate && !input.password) return 'A password is required'
|
|
88
|
+
return undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function registerMemberHandlers(router: ManagementApiRouter, port: MemberPort): void {
|
|
92
|
+
const viewer = (ctx: RequestContext) => ctx.principal as Principal
|
|
93
|
+
const done = (ctx: RequestContext, message?: string) => {
|
|
94
|
+
if (message) ctx.notifications.push({ message, category: 'Member', type: 'Success' })
|
|
95
|
+
return new Response(null, { status: 200 })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const checked = async (input: SaveMember, forCreate: boolean) => {
|
|
99
|
+
const problem = missingFields(input, forCreate)
|
|
100
|
+
if (problem) return problemResponse(problemDetails({ title: problem, status: 400 }))
|
|
101
|
+
const errors = await port.validate(input)
|
|
102
|
+
if (errors.length > 0) return problemResponse(validationProblem(errors, input.values))
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
router.handle('GetMemberById', async (ctx) => {
|
|
107
|
+
const member = await port.byKey(ctx.params.id as string, viewer(ctx))
|
|
108
|
+
return member ? Response.json(member) : missing()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
router.handle('PostMemberValidate', async (ctx) => {
|
|
112
|
+
const input = toSaveMember(crypto.randomUUID(), await readBody(ctx))
|
|
113
|
+
return (await checked(input, true)) ?? done(ctx)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
router.handle('PutMemberByIdValidate', async (ctx) => {
|
|
117
|
+
const key = ctx.params.id as string
|
|
118
|
+
if (!(await port.byKey(key, viewer(ctx)))) return missing()
|
|
119
|
+
return (await checked(toSaveMember(key, await readBody(ctx)), false)) ?? done(ctx)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
router.handle('PostMember', async (ctx) => {
|
|
123
|
+
const body = await readBody(ctx)
|
|
124
|
+
const key = typeof body.id === 'string' && body.id ? body.id : crypto.randomUUID()
|
|
125
|
+
const input = toSaveMember(key, body)
|
|
126
|
+
const invalid = await checked(input, true)
|
|
127
|
+
if (invalid) return invalid
|
|
128
|
+
const result = await port.create(input, viewer(ctx))
|
|
129
|
+
if (!result.ok) return failed('Could not create the member', result)
|
|
130
|
+
return created(`${ctx.url.origin}${V1}/member/${result.key}`, result.key)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
router.handle('PutMemberById', async (ctx) => {
|
|
134
|
+
const key = ctx.params.id as string
|
|
135
|
+
if (!(await port.byKey(key, viewer(ctx)))) return missing()
|
|
136
|
+
const input = toSaveMember(key, await readBody(ctx))
|
|
137
|
+
const invalid = await checked(input, false)
|
|
138
|
+
if (invalid) return invalid
|
|
139
|
+
const result = await port.update(key, input, viewer(ctx))
|
|
140
|
+
return result.ok ? done(ctx) : failed('Could not save the member', result)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
router.handle('DeleteMemberById', async (ctx) =>
|
|
144
|
+
(await port.remove(ctx.params.id as string)) ? done(ctx) : missing(),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
router.handle('GetFilterMember', async (ctx) => {
|
|
148
|
+
const page = paging(ctx)
|
|
149
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
150
|
+
const query = ctx.url.searchParams
|
|
151
|
+
const flag = (name: string) => {
|
|
152
|
+
const value = query.get(name)
|
|
153
|
+
return value === null ? undefined : value === 'true'
|
|
154
|
+
}
|
|
155
|
+
const result = await port.filter(
|
|
156
|
+
{
|
|
157
|
+
...page,
|
|
158
|
+
memberTypeKey: query.get('memberTypeId'),
|
|
159
|
+
memberGroupName: query.get('memberGroupName'),
|
|
160
|
+
isApproved: flag('isApproved'),
|
|
161
|
+
isLockedOut: flag('isLockedOut'),
|
|
162
|
+
filter: query.get('filter') ?? undefined,
|
|
163
|
+
orderBy: query.get('orderBy') ?? undefined,
|
|
164
|
+
orderDirection: query.get('orderDirection') === 'Descending' ? 'Descending' : 'Ascending',
|
|
165
|
+
},
|
|
166
|
+
viewer(ctx),
|
|
167
|
+
)
|
|
168
|
+
return Response.json(paged(result.items, result.total))
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
router.handle('GetItemMember', async (ctx) =>
|
|
172
|
+
Response.json(await port.items(ctx.url.searchParams.getAll('id'))),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
// A member has no ancestors: the list is always empty, but the picker asks.
|
|
176
|
+
router.handle('GetItemMemberAncestors', (ctx) =>
|
|
177
|
+
Response.json(ctx.url.searchParams.getAll('id').map((id) => ({ id, ancestors: [] }))),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
router.handle('GetItemMemberSearch', async (ctx) => {
|
|
181
|
+
const page = paging(ctx)
|
|
182
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
183
|
+
const result = await port.search(ctx.url.searchParams.get('query') ?? '', {
|
|
184
|
+
...page,
|
|
185
|
+
allowedMemberTypeKeys: ctx.url.searchParams.getAll('allowedMemberTypes'),
|
|
186
|
+
})
|
|
187
|
+
return Response.json(paged(result.items, result.total))
|
|
188
|
+
})
|
|
189
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models Builder: the dashboard's state, whether the models are stale, and the
|
|
3
|
+
* Generate button. A build that fails still answers 200, as Umbraco's does —
|
|
4
|
+
* the client treats any other status as a failed request and never re-reads the
|
|
5
|
+
* dashboard, which is the only place the error is shown.
|
|
6
|
+
*/
|
|
7
|
+
import type { ModelsBuilderPort } from '../ports-models-builder.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
|
|
10
|
+
export function registerModelsBuilderHandlers(
|
|
11
|
+
router: ManagementApiRouter,
|
|
12
|
+
models: ModelsBuilderPort,
|
|
13
|
+
): void {
|
|
14
|
+
router.handle('GetModelsBuilderDashboard', async () => Response.json(await models.info()))
|
|
15
|
+
|
|
16
|
+
router.handle('GetModelsBuilderStatus', async () =>
|
|
17
|
+
Response.json({ status: await models.status() }),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
router.handle('PostModelsBuilderBuild', async (ctx) => {
|
|
21
|
+
const result = await models.build()
|
|
22
|
+
ctx.notifications.push(
|
|
23
|
+
result.ok
|
|
24
|
+
? { message: 'Models generated', category: 'Models Builder', type: 'Success' }
|
|
25
|
+
: { message: 'Models could not be generated', category: 'Models Builder', type: 'Error' },
|
|
26
|
+
)
|
|
27
|
+
return new Response(null, { status: 200 })
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Packages section: created-package definitions, their download, and the
|
|
3
|
+
* two migration operations that answer honestly rather than pretend.
|
|
4
|
+
*
|
|
5
|
+
* `packagePath` is required by the contract and is a real file path in Umbraco,
|
|
6
|
+
* which stores a built zip on disk. Nothing is stored here — the zip is built
|
|
7
|
+
* when it is downloaded — so it reports the name the download will carry,
|
|
8
|
+
* which is the only part of it the client ever shows.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
invalidSkipTake,
|
|
12
|
+
notFound,
|
|
13
|
+
paged,
|
|
14
|
+
parseSkipTake,
|
|
15
|
+
problemDetails,
|
|
16
|
+
problemResponse,
|
|
17
|
+
} from '@crombolo/core'
|
|
18
|
+
import type {
|
|
19
|
+
PackageDefinition,
|
|
20
|
+
PackageDefinitionInput,
|
|
21
|
+
PackagePort,
|
|
22
|
+
PackageWriteResult,
|
|
23
|
+
} from '../ports-packages.ts'
|
|
24
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
25
|
+
import { created } from './content-type.ts'
|
|
26
|
+
|
|
27
|
+
const packageNotFound = () => problemResponse(notFound('The package could not be found'))
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A download filename, as `npm pack` names one: the package name with its scope
|
|
31
|
+
* folded in, then the version. No separators, no surprises.
|
|
32
|
+
*/
|
|
33
|
+
export function packageFileName(name: string, version = '1.0.0'): string {
|
|
34
|
+
const slug = name
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/^@/, '')
|
|
38
|
+
.replace(/[^a-z0-9.]+/g, '-')
|
|
39
|
+
.replace(/^-+|-+$/g, '')
|
|
40
|
+
return `${slug || 'package'}-${version}.tgz`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const toResponse = (definition: PackageDefinition) => ({
|
|
44
|
+
id: definition.id,
|
|
45
|
+
name: definition.name,
|
|
46
|
+
packagePath: packageFileName(definition.name),
|
|
47
|
+
contentNodeId: definition.contentNodeId,
|
|
48
|
+
contentLoadChildNodes: definition.contentLoadChildNodes,
|
|
49
|
+
mediaIds: definition.mediaIds,
|
|
50
|
+
mediaLoadChildNodes: definition.mediaLoadChildNodes,
|
|
51
|
+
elementIds: definition.elementIds,
|
|
52
|
+
documentTypes: definition.contentTypes,
|
|
53
|
+
mediaTypes: definition.mediaTypes,
|
|
54
|
+
dataTypes: definition.dataTypes,
|
|
55
|
+
templates: definition.templates,
|
|
56
|
+
partialViews: definition.partialViews,
|
|
57
|
+
stylesheets: definition.stylesheets,
|
|
58
|
+
scripts: definition.scripts,
|
|
59
|
+
languages: definition.languages,
|
|
60
|
+
dictionaryItems: definition.dictionaryItems,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const strings = (value: unknown): string[] =>
|
|
64
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
|
65
|
+
|
|
66
|
+
/** The id of a reference, which the client sends as `{ id }` or as a bare string. */
|
|
67
|
+
const refId = (value: unknown): string | null => {
|
|
68
|
+
if (typeof value === 'string') return value.length > 0 ? value : null
|
|
69
|
+
if (value && typeof value === 'object' && 'id' in value) {
|
|
70
|
+
const id = (value as { id: unknown }).id
|
|
71
|
+
return typeof id === 'string' && id.length > 0 ? id : null
|
|
72
|
+
}
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readInput(body: Record<string, unknown>): PackageDefinitionInput {
|
|
77
|
+
return {
|
|
78
|
+
name: String(body.name ?? '').trim(),
|
|
79
|
+
contentNodeId: refId(body.contentNodeId),
|
|
80
|
+
contentLoadChildNodes: body.contentLoadChildNodes === true,
|
|
81
|
+
mediaIds: strings(body.mediaIds),
|
|
82
|
+
mediaLoadChildNodes: body.mediaLoadChildNodes === true,
|
|
83
|
+
elementIds:
|
|
84
|
+
body.elementIds === null || body.elementIds === undefined ? null : strings(body.elementIds),
|
|
85
|
+
contentTypes: strings(body.documentTypes),
|
|
86
|
+
mediaTypes: strings(body.mediaTypes),
|
|
87
|
+
dataTypes: strings(body.dataTypes),
|
|
88
|
+
templates: strings(body.templates),
|
|
89
|
+
partialViews: strings(body.partialViews),
|
|
90
|
+
stylesheets: strings(body.stylesheets),
|
|
91
|
+
scripts: strings(body.scripts),
|
|
92
|
+
languages: strings(body.languages),
|
|
93
|
+
dictionaryItems: strings(body.dictionaryItems),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeFailure(result: PackageWriteResult): Response | undefined {
|
|
98
|
+
if (result.ok) return undefined
|
|
99
|
+
switch (result.status) {
|
|
100
|
+
case 'NotFound':
|
|
101
|
+
return packageNotFound()
|
|
102
|
+
case 'InvalidName':
|
|
103
|
+
return problemResponse(
|
|
104
|
+
problemDetails({
|
|
105
|
+
title: 'A package name is required',
|
|
106
|
+
status: 400,
|
|
107
|
+
operationStatus: 'InvalidName',
|
|
108
|
+
}),
|
|
109
|
+
)
|
|
110
|
+
case 'DuplicateName':
|
|
111
|
+
return problemResponse(
|
|
112
|
+
problemDetails({
|
|
113
|
+
title: 'Duplicate package name detected',
|
|
114
|
+
detail: 'Another package exists with the same name. Package names must be unique.',
|
|
115
|
+
status: 400,
|
|
116
|
+
operationStatus: 'DuplicateName',
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const body = async (ctx: RequestContext): Promise<Record<string, unknown>> =>
|
|
123
|
+
((await ctx.request.json().catch(() => ({}))) ?? {}) as Record<string, unknown>
|
|
124
|
+
|
|
125
|
+
export function registerPackageHandlers(router: ManagementApiRouter, port: PackagePort): void {
|
|
126
|
+
router.handle('GetPackageConfiguration', () =>
|
|
127
|
+
Response.json({ marketplaceUrl: port.marketplaceUrl() }),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
router.handle('GetPackageCreated', async (ctx) => {
|
|
131
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
132
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
133
|
+
const result = await port.list(page.value)
|
|
134
|
+
return Response.json(paged(result.items.map(toResponse), result.total))
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
router.handle('PostPackageCreated', async (ctx) => {
|
|
138
|
+
const input = await body(ctx)
|
|
139
|
+
const result = await port.create(readInput(input), refId(input.id) ?? undefined)
|
|
140
|
+
const failed = writeFailure(result)
|
|
141
|
+
if (failed) return failed
|
|
142
|
+
const id = result.ok ? result.id : ''
|
|
143
|
+
return created(`${ctx.url.origin}/umbraco/management/api/v1/package/created/${id}`, id)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
router.handle('GetPackageCreatedById', async (ctx) => {
|
|
147
|
+
const definition = await port.byId(ctx.params.id as string)
|
|
148
|
+
if (!definition) return packageNotFound()
|
|
149
|
+
return Response.json(toResponse(definition))
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
router.handle('PutPackageCreatedById', async (ctx) => {
|
|
153
|
+
const result = await port.update(ctx.params.id as string, readInput(await body(ctx)))
|
|
154
|
+
return writeFailure(result) ?? new Response(null, { status: 200 })
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
router.handle('DeletePackageCreatedById', async (ctx) => {
|
|
158
|
+
const result = await port.remove(ctx.params.id as string)
|
|
159
|
+
if (result === 'notFound') return packageNotFound()
|
|
160
|
+
return new Response(null, { status: 200 })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
router.handle('GetPackageCreatedByIdDownload', async (ctx) => {
|
|
164
|
+
const built = await port.build(ctx.params.id as string)
|
|
165
|
+
if (!built) return packageNotFound()
|
|
166
|
+
return new Response(built.bytes, {
|
|
167
|
+
headers: {
|
|
168
|
+
'content-type': built.contentType,
|
|
169
|
+
'content-disposition': `attachment; filename="${built.fileName}"`,
|
|
170
|
+
},
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Umbraco's package migrations are C# migrations shipped inside a package.
|
|
176
|
+
* Nothing carries them here — extensions are client-side and schema changes
|
|
177
|
+
* travel as TOML through a deploy — so there is never a package with a
|
|
178
|
+
* pending migration, and saying so is the honest answer rather than a stub.
|
|
179
|
+
*/
|
|
180
|
+
router.handle('GetPackageMigrationStatus', (ctx) => {
|
|
181
|
+
if (!parseSkipTake(ctx.url.searchParams).ok) return problemResponse(invalidSkipTake())
|
|
182
|
+
return Response.json(paged([], 0))
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
router.handle('PostPackageByNameRunMigration', (ctx) =>
|
|
186
|
+
problemResponse(
|
|
187
|
+
notFound(
|
|
188
|
+
'The package could not be found',
|
|
189
|
+
`No package named '${ctx.params.name}' has migrations to run. Package migrations do not exist in this CMS; schema changes travel as files through a deploy.`,
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public access: which documents are protected, and by what.
|
|
3
|
+
*
|
|
4
|
+
* A rule names member groups **or** individual members, never both — Umbraco
|
|
5
|
+
* calls the mixture ambiguous and refuses it, and so do we, because the two are
|
|
6
|
+
* different intentions and the editor offers them as a choice.
|
|
7
|
+
*/
|
|
8
|
+
import { notFound, problemDetails, problemResponse } from '@crombolo/core'
|
|
9
|
+
import type { PublicAccessPort, PublicAccessStatus } from '../ports-members.ts'
|
|
10
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
11
|
+
import { readBody } from './document.ts'
|
|
12
|
+
|
|
13
|
+
const refId = (value: unknown): string | null => {
|
|
14
|
+
if (!value || typeof value !== 'object') return null
|
|
15
|
+
const id = (value as Record<string, unknown>).id
|
|
16
|
+
return typeof id === 'string' && id ? id : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const names = (value: unknown): string[] =>
|
|
20
|
+
Array.isArray(value)
|
|
21
|
+
? value.filter((v): v is string => typeof v === 'string' && v.trim() !== '')
|
|
22
|
+
: []
|
|
23
|
+
|
|
24
|
+
/** Umbraco's `PublicAccessOperationStatus`, as the problem details the client reads. */
|
|
25
|
+
function statusResponse(status: PublicAccessStatus, ok: () => Response): Response {
|
|
26
|
+
switch (status) {
|
|
27
|
+
case 'ok':
|
|
28
|
+
return ok()
|
|
29
|
+
case 'content-not-found':
|
|
30
|
+
return problemResponse(notFound('The document could not be found'))
|
|
31
|
+
case 'login-node-not-found':
|
|
32
|
+
return problemResponse(notFound('The login page could not be found'))
|
|
33
|
+
case 'error-node-not-found':
|
|
34
|
+
return problemResponse(notFound('The error page could not be found'))
|
|
35
|
+
default:
|
|
36
|
+
return problemResponse(
|
|
37
|
+
problemDetails({
|
|
38
|
+
title: 'Entry not found',
|
|
39
|
+
detail: 'The specified entry was not found.',
|
|
40
|
+
status: 404,
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerPublicAccessHandlers(
|
|
47
|
+
router: ManagementApiRouter,
|
|
48
|
+
port: PublicAccessPort,
|
|
49
|
+
): void {
|
|
50
|
+
router.handle('GetDocumentByIdPublicAccess', async (ctx) => {
|
|
51
|
+
const result = await port.entry(
|
|
52
|
+
ctx.params.id as string,
|
|
53
|
+
ctx.url.searchParams.get('includeAncestors') === 'true',
|
|
54
|
+
)
|
|
55
|
+
if (result.status !== 'ok' || !result.entry)
|
|
56
|
+
return statusResponse(result.status, () => new Response(null, { status: 404 }))
|
|
57
|
+
return Response.json(result.entry)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const write = async (ctx: RequestContext, ok: () => Response) => {
|
|
61
|
+
const body = await readBody(ctx)
|
|
62
|
+
const login = refId(body.loginDocument)
|
|
63
|
+
const error = refId(body.errorDocument)
|
|
64
|
+
if (!login) return problemResponse(notFound('The login page could not be found'))
|
|
65
|
+
if (!error) return problemResponse(notFound('The error page could not be found'))
|
|
66
|
+
const memberGroupNames = names(body.memberGroupNames)
|
|
67
|
+
const memberUserNames = names(body.memberUserNames)
|
|
68
|
+
if (memberGroupNames.length === 0 && memberUserNames.length === 0)
|
|
69
|
+
return problemResponse(
|
|
70
|
+
problemDetails({
|
|
71
|
+
title: 'No allowed entities given',
|
|
72
|
+
detail: 'Both MemberGroups and Members were empty, thus no entities can be allowed.',
|
|
73
|
+
status: 400,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
if (memberGroupNames.length > 0 && memberUserNames.length > 0)
|
|
77
|
+
return problemResponse(
|
|
78
|
+
problemDetails({
|
|
79
|
+
title: 'Ambiguous Rule',
|
|
80
|
+
detail:
|
|
81
|
+
'The specified rule is ambiguous, because both member groups and member names were given.',
|
|
82
|
+
status: 400,
|
|
83
|
+
}),
|
|
84
|
+
)
|
|
85
|
+
const status = await port.save(ctx.params.id as string, {
|
|
86
|
+
loginDocumentKey: login,
|
|
87
|
+
errorDocumentKey: error,
|
|
88
|
+
memberGroupNames,
|
|
89
|
+
memberUserNames,
|
|
90
|
+
})
|
|
91
|
+
return statusResponse(status, ok)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
router.handle('PostDocumentByIdPublicAccess', (ctx) =>
|
|
95
|
+
write(ctx, () => new Response(null, { status: 201 })),
|
|
96
|
+
)
|
|
97
|
+
router.handle('PutDocumentByIdPublicAccess', (ctx) =>
|
|
98
|
+
write(ctx, () => new Response(null, { status: 200 })),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
router.handle('DeleteDocumentByIdPublicAccess', async (ctx) =>
|
|
102
|
+
statusResponse(
|
|
103
|
+
await port.remove(ctx.params.id as string),
|
|
104
|
+
() => new Response(null, { status: 200 }),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Published Status dashboard's three buttons. Reload and rebuild answer 200
|
|
3
|
+
* with no body, and the client polls the rebuild status until it reports it has
|
|
4
|
+
* finished — which, since a rebuild here completes before the response, it
|
|
5
|
+
* already has by the first poll.
|
|
6
|
+
*/
|
|
7
|
+
import type { PublishedCachePort } from '../ports.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
|
|
10
|
+
export function registerPublishedCacheHandlers(
|
|
11
|
+
router: ManagementApiRouter,
|
|
12
|
+
cache: PublishedCachePort,
|
|
13
|
+
): void {
|
|
14
|
+
router.handle('PostPublishedCacheReload', () => {
|
|
15
|
+
cache.reload()
|
|
16
|
+
return new Response(null, { status: 200 })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
router.handle('PostPublishedCacheRebuild', async () => {
|
|
20
|
+
await cache.rebuild()
|
|
21
|
+
return new Response(null, { status: 200 })
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
router.handle('GetPublishedCacheRebuildStatus', () =>
|
|
25
|
+
Response.json({ isRebuilding: cache.isRebuilding() }),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Redirect URL Management dashboard, in the Content section, and the
|
|
3
|
+
* redirects listed on a page's Info tab.
|
|
4
|
+
*
|
|
5
|
+
* Two quirks of the contract are worth naming. `{id}` means the *document* on the
|
|
6
|
+
* GET and the *redirect* on the DELETE. And `document` is a required field on
|
|
7
|
+
* every item although the client's own mapper throws it away — a redirect the
|
|
8
|
+
* site configured towards an external URL names no document, so those report the
|
|
9
|
+
* empty key rather than inventing one.
|
|
10
|
+
*
|
|
11
|
+
* `POST status` is a no-op, as it is in Umbraco since v17: tracking is a
|
|
12
|
+
* configuration setting, not something an editor toggles.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
invalidSkipTake,
|
|
16
|
+
paged,
|
|
17
|
+
parseSkipTake,
|
|
18
|
+
problemDetails,
|
|
19
|
+
problemResponse,
|
|
20
|
+
} from '@crombolo/core'
|
|
21
|
+
import type { RedirectListing, RedirectPort } from '../ports-content.ts'
|
|
22
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
23
|
+
|
|
24
|
+
const NO_DOCUMENT = '00000000-0000-0000-0000-000000000000'
|
|
25
|
+
|
|
26
|
+
const toResponse = (item: RedirectListing) => ({
|
|
27
|
+
id: item.key,
|
|
28
|
+
originalUrl: item.originalUrl,
|
|
29
|
+
destinationUrl: item.destinationUrl,
|
|
30
|
+
created: item.created.toISOString(),
|
|
31
|
+
document: { id: item.documentKey ?? NO_DOCUMENT },
|
|
32
|
+
culture: item.culture,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export function registerRedirectHandlers(router: ManagementApiRouter, port: RedirectPort): void {
|
|
36
|
+
router.handle('GetRedirectManagement', async (ctx) => {
|
|
37
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
38
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
39
|
+
const filter = ctx.url.searchParams.get('filter') ?? undefined
|
|
40
|
+
const result = await port.list(filter, page.value)
|
|
41
|
+
return Response.json(paged(result.items.map(toResponse), result.total))
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
router.handle('GetRedirectManagementById', async (ctx) => {
|
|
45
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
46
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
47
|
+
const result = await port.byDocument(ctx.params.id as string, page.value)
|
|
48
|
+
return Response.json(paged(result.items.map(toResponse), result.total))
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
router.handle('DeleteRedirectManagementById', async (ctx) => {
|
|
52
|
+
const result = await port.remove(ctx.params.id as string)
|
|
53
|
+
if (result === 'notFound')
|
|
54
|
+
return problemResponse(
|
|
55
|
+
problemDetails({ title: 'The redirect could not be found', status: 404 }),
|
|
56
|
+
)
|
|
57
|
+
if (result === 'configured')
|
|
58
|
+
return problemResponse(
|
|
59
|
+
problemDetails({
|
|
60
|
+
title: 'The redirect is defined in configuration',
|
|
61
|
+
detail: "Remove it from the site's `redirects` configuration instead.",
|
|
62
|
+
status: 409,
|
|
63
|
+
}),
|
|
64
|
+
)
|
|
65
|
+
return new Response(null, { status: 200 })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
router.handle('GetRedirectManagementStatus', (ctx) =>
|
|
69
|
+
Response.json({
|
|
70
|
+
status: port.isTracking() ? 'Enabled' : 'Disabled',
|
|
71
|
+
userIsAdmin: ctx.principal?.isAdmin ?? false,
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Umbraco deprecated this in v17 and made it a no-op; reporting success without
|
|
76
|
+
// changing anything is what its own client now expects.
|
|
77
|
+
router.handle('PostRedirectManagementStatus', () => new Response(null, { status: 200 }))
|
|
78
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "What refers to this?" — the Info tab's list, and the checks the client makes
|
|
3
|
+
* before a delete. These answered an empty page until now, which told an editor
|
|
4
|
+
* that nothing referenced a document they were about to delete.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, paged, parseSkipTake, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { ReferencePort } from '../ports-content.ts'
|
|
8
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
9
|
+
|
|
10
|
+
type Area = 'Document' | 'Media' | 'Member' | 'Element'
|
|
11
|
+
|
|
12
|
+
export function registerReferenceHandlers(
|
|
13
|
+
router: ManagementApiRouter,
|
|
14
|
+
area: Area,
|
|
15
|
+
port: ReferencePort,
|
|
16
|
+
): void {
|
|
17
|
+
// Umbraco pages these 20 at a time, not the usual 100.
|
|
18
|
+
const paging = (ctx: RequestContext) => parseSkipTake(ctx.url.searchParams, 20)
|
|
19
|
+
const has = (id: string) => router.operations.some((o) => o.operationId === id)
|
|
20
|
+
const handle = (id: string, handler: Parameters<ManagementApiRouter['handle']>[1]) => {
|
|
21
|
+
if (has(id)) router.handle(id, handler)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
handle(`Get${area}ByIdReferencedBy`, async (ctx) => {
|
|
25
|
+
const page = paging(ctx)
|
|
26
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
27
|
+
const result = await port.referencedBy(ctx.params.id as string, page.value)
|
|
28
|
+
return Response.json(paged(result.items, result.total))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
handle(`Get${area}ByIdReferencedDescendants`, async (ctx) => {
|
|
32
|
+
const page = paging(ctx)
|
|
33
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
34
|
+
const result = await port.referencedDescendants(ctx.params.id as string, page.value)
|
|
35
|
+
return Response.json(paged(result.items, result.total))
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
handle(`Get${area}AreReferenced`, async (ctx) => {
|
|
39
|
+
const page = paging(ctx)
|
|
40
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
41
|
+
const result = await port.areReferenced(ctx.url.searchParams.getAll('id'), page.value)
|
|
42
|
+
return Response.json(paged(result.items, result.total))
|
|
43
|
+
})
|
|
44
|
+
}
|