@crombolo/api-management 0.10.0 → 0.12.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/package.json +3 -3
- package/src/audit.ts +430 -0
- package/src/divergences.ts +22 -0
- package/src/handlers/audit-log.ts +131 -0
- package/src/handlers/data-type.ts +25 -0
- package/src/handlers/diagnostics.ts +89 -0
- package/src/handlers/document-blueprint.ts +1 -3
- package/src/handlers/document.ts +75 -49
- package/src/handlers/dynamic-root.ts +63 -0
- package/src/handlers/element.ts +1 -2
- package/src/handlers/media.ts +13 -9
- package/src/handlers/reference-data.ts +35 -0
- package/src/handlers/relations.ts +60 -0
- package/src/handlers/search-index.ts +96 -0
- package/src/handlers/settings-stubs.ts +1 -10
- package/src/handlers/start-node-trees.ts +26 -0
- package/src/handlers/static-files.ts +98 -0
- package/src/handlers/template.ts +2 -0
- package/src/handlers/webhook.ts +146 -0
- package/src/index.ts +5 -0
- package/src/ports-content.ts +29 -0
- package/src/ports-diagnostics.ts +17 -0
- package/src/ports-webhooks.ts +32 -0
- package/src/ports.ts +68 -0
- package/src/router.ts +130 -1
- package/src/routes.ts +47 -1
- package/src/webhook-events.ts +74 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health checks, telemetry, profiling and the server's troubleshooting and
|
|
3
|
+
* upgrade-check answers. Telemetry and profiling are fixed: nothing is
|
|
4
|
+
* collected or sent, and there is no profiler. docs/diagnostics.md.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { DiagnosticsPort } from '../ports-diagnostics.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
import { paging } from './content-type.ts'
|
|
10
|
+
import { readBody } from './document.ts'
|
|
11
|
+
|
|
12
|
+
const TELEMETRY_LEVEL = 'Minimal'
|
|
13
|
+
|
|
14
|
+
const badRequest = (title: string, detail: string) => problemDetails({ title, detail, status: 400 })
|
|
15
|
+
|
|
16
|
+
export function registerTelemetryAndProfilingHandlers(router: ManagementApiRouter): void {
|
|
17
|
+
router.handle('GetTelemetry', (ctx) => {
|
|
18
|
+
const page = paging(ctx)
|
|
19
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
20
|
+
const items = [{ telemetryLevel: TELEMETRY_LEVEL }]
|
|
21
|
+
return Response.json({
|
|
22
|
+
total: items.length,
|
|
23
|
+
items: items.slice(page.skip, page.skip + page.take),
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
router.handle('GetTelemetryLevel', () => Response.json({ telemetryLevel: TELEMETRY_LEVEL }))
|
|
27
|
+
router.handle('PostTelemetryLevel', async (ctx) => {
|
|
28
|
+
const { telemetryLevel } = await readBody(ctx)
|
|
29
|
+
if (telemetryLevel === TELEMETRY_LEVEL) return new Response(null, { status: 200 })
|
|
30
|
+
return problemResponse(
|
|
31
|
+
badRequest(
|
|
32
|
+
'Telemetry cannot be raised',
|
|
33
|
+
'This site collects no telemetry and sends nothing anywhere, so the level stays at Minimal.',
|
|
34
|
+
),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
router.handle('GetProfilingStatus', () => Response.json({ enabled: false }))
|
|
38
|
+
router.handle('PutProfilingStatus', async (ctx) => {
|
|
39
|
+
const { enabled } = await readBody(ctx)
|
|
40
|
+
if (enabled === false) return new Response(null, { status: 200 })
|
|
41
|
+
return problemResponse(
|
|
42
|
+
badRequest('Profiling is not available', 'This site has no profiler to switch on.'),
|
|
43
|
+
)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function registerDiagnosticsHandlers(
|
|
48
|
+
router: ManagementApiRouter,
|
|
49
|
+
diagnostics: DiagnosticsPort,
|
|
50
|
+
): void {
|
|
51
|
+
const groupNamed = (name: string) =>
|
|
52
|
+
diagnostics.groups().find((group) => group.name.toLowerCase() === name.toLowerCase())
|
|
53
|
+
const noGroup = () => problemResponse(notFound('The health check group could not be found'))
|
|
54
|
+
|
|
55
|
+
router.handle('GetHealthCheckGroup', (ctx) => {
|
|
56
|
+
const page = paging(ctx)
|
|
57
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
58
|
+
const groups = diagnostics.groups()
|
|
59
|
+
return Response.json({
|
|
60
|
+
total: groups.length,
|
|
61
|
+
items: groups.slice(page.skip, page.skip + page.take).map(({ name }) => ({ name })),
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
router.handle('GetHealthCheckGroupByName', (ctx) => {
|
|
65
|
+
const group = groupNamed(String(ctx.params.name))
|
|
66
|
+
return group ? Response.json(group) : noGroup()
|
|
67
|
+
})
|
|
68
|
+
router.handle('PostHealthCheckGroupByNameCheck', async (ctx) => {
|
|
69
|
+
const group = groupNamed(String(ctx.params.name))
|
|
70
|
+
const checks = group && (await diagnostics.run(group.name))
|
|
71
|
+
return checks ? Response.json({ checks }) : noGroup()
|
|
72
|
+
})
|
|
73
|
+
// No check here offers an action: each one reports, and the fix is a change
|
|
74
|
+
// to the site's configuration rather than something a button can do.
|
|
75
|
+
router.handle('PostHealthCheckExecuteAction', () =>
|
|
76
|
+
problemResponse(
|
|
77
|
+
badRequest(
|
|
78
|
+
'There is nothing to run',
|
|
79
|
+
'These health checks report only; none offers an action.',
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
router.handle('GetServerTroubleshooting', async () =>
|
|
84
|
+
Response.json(await diagnostics.troubleshooting()),
|
|
85
|
+
)
|
|
86
|
+
router.handle('GetServerUpgradeCheck', async () =>
|
|
87
|
+
Response.json(await diagnostics.upgradeCheck()),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
@@ -7,7 +7,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
|
|
|
7
7
|
import type { BlueprintPort, PortFailure } from '../ports-content.ts'
|
|
8
8
|
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
9
9
|
import { created, paging, registerFolderHandlers } from './content-type.ts'
|
|
10
|
-
import {
|
|
10
|
+
import { readBody, toDocumentResponse, toSaveDocument } from './document.ts'
|
|
11
11
|
|
|
12
12
|
const API = '/umbraco/management/api/v1'
|
|
13
13
|
|
|
@@ -126,8 +126,6 @@ export function registerDocumentBlueprintHandlers(
|
|
|
126
126
|
return new Response(null, { status: 200 })
|
|
127
127
|
})
|
|
128
128
|
|
|
129
|
-
router.handle('GetDocumentBlueprintByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
130
|
-
|
|
131
129
|
router.handle('GetDocumentTypeByIdBlueprint', async (ctx) => {
|
|
132
130
|
const page = paging(ctx)
|
|
133
131
|
if (!page) return problemResponse(invalidSkipTake())
|
package/src/handlers/document.ts
CHANGED
|
@@ -7,11 +7,14 @@ import type {
|
|
|
7
7
|
DocumentTreeItem,
|
|
8
8
|
DocumentValidationError,
|
|
9
9
|
DocumentValue,
|
|
10
|
-
|
|
10
|
+
PatchOperation,
|
|
11
11
|
} from '@crombolo/core'
|
|
12
12
|
import {
|
|
13
|
+
applyJsonPatch,
|
|
14
|
+
DocumentPermissions,
|
|
13
15
|
invalidSkipTake,
|
|
14
16
|
notFound,
|
|
17
|
+
permissionsForPath,
|
|
15
18
|
problemDetails,
|
|
16
19
|
problemResponse,
|
|
17
20
|
ROOT_ACCESS,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
type TreeSource,
|
|
25
28
|
visibleChildren,
|
|
26
29
|
visibleRoot,
|
|
30
|
+
visibleSearch,
|
|
27
31
|
visibleSiblings,
|
|
28
32
|
} from './start-node-trees.ts'
|
|
29
33
|
|
|
@@ -385,11 +389,28 @@ export function registerDocumentHandlers(
|
|
|
385
389
|
const page = paging(ctx)
|
|
386
390
|
if (!page) return problemResponse(invalidSkipTake())
|
|
387
391
|
const query = ctx.url.searchParams.get('query') ?? ''
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
392
|
+
const groups = ctx.principal?.groups ?? []
|
|
393
|
+
// Browse is checked per item only when some group could withhold it somewhere.
|
|
394
|
+
const browsesEverything =
|
|
395
|
+
!ctx.principal ||
|
|
396
|
+
groups.some(
|
|
397
|
+
(group) =>
|
|
398
|
+
group.defaults.includes(DocumentPermissions.Read) &&
|
|
399
|
+
[...group.granular.values()].every((verbs) => verbs.includes(DocumentPermissions.Read)),
|
|
400
|
+
)
|
|
401
|
+
const result = await visibleSearch(
|
|
402
|
+
documentStart(ctx),
|
|
403
|
+
browsesEverything
|
|
404
|
+
? undefined
|
|
405
|
+
: (chain) => permissionsForPath(groups, chain).has(DocumentPermissions.Read),
|
|
406
|
+
(paging) =>
|
|
407
|
+
port.search(query, {
|
|
408
|
+
...paging,
|
|
409
|
+
trashed: ctx.url.searchParams.get('trashed') === 'true',
|
|
410
|
+
parentKey: ctx.url.searchParams.get('parentId'),
|
|
411
|
+
}),
|
|
412
|
+
page,
|
|
413
|
+
)
|
|
393
414
|
return Response.json({ total: result.total, items: result.items.map(toDocumentItemResponse) })
|
|
394
415
|
})
|
|
395
416
|
|
|
@@ -452,11 +473,12 @@ export function registerDocumentHandlers(
|
|
|
452
473
|
return created(`${ctx.url.origin}/umbraco/management/api/v1/document/${result.key}`, result.key)
|
|
453
474
|
})
|
|
454
475
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
476
|
+
const saveUpdate = async (
|
|
477
|
+
ctx: RequestContext,
|
|
478
|
+
key: string,
|
|
479
|
+
existing: DocumentAggregate,
|
|
480
|
+
body: Record<string, unknown>,
|
|
481
|
+
) => {
|
|
460
482
|
const result = await port.update(
|
|
461
483
|
key,
|
|
462
484
|
{
|
|
@@ -477,6 +499,48 @@ export function registerDocumentHandlers(
|
|
|
477
499
|
}
|
|
478
500
|
ctx.notifications.push({ message: 'Document saved', category: 'Content', type: 'Success' })
|
|
479
501
|
return new Response(null, { status: 200 })
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
router.handle('PutDocumentById', async (ctx) => {
|
|
505
|
+
const key = ctx.params.id as string
|
|
506
|
+
const existing = await port.byKey(key)
|
|
507
|
+
if (!existing) return problemResponse(notFound('The document could not be found'))
|
|
508
|
+
return saveUpdate(ctx, key, existing, await readBody(ctx))
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
// JSON Patch over the update model, Umbraco's filter segments included; the
|
|
512
|
+
// patched model then saves exactly as a PUT would.
|
|
513
|
+
router.handle('PatchDocumentByIdPatch', async (ctx) => {
|
|
514
|
+
const key = ctx.params.id as string
|
|
515
|
+
const existing = await port.byKey(key)
|
|
516
|
+
if (!existing) return problemResponse(notFound('The document could not be found'))
|
|
517
|
+
const invalid = (detail: string, status = 400) =>
|
|
518
|
+
problemResponse(problemDetails({ title: 'Could not patch the document', status, detail }))
|
|
519
|
+
const { operations } = await readBody(ctx)
|
|
520
|
+
if (!Array.isArray(operations) || operations.length === 0)
|
|
521
|
+
return invalid('A patch needs at least one operation.')
|
|
522
|
+
const current = {
|
|
523
|
+
template: existing.componentKey ? { id: existing.componentKey } : null,
|
|
524
|
+
values: existing.values.map(({ alias, culture, segment, value }) => ({
|
|
525
|
+
alias,
|
|
526
|
+
culture,
|
|
527
|
+
segment,
|
|
528
|
+
value,
|
|
529
|
+
})),
|
|
530
|
+
variants: existing.variants.map(({ culture, segment, name }) => ({ culture, segment, name })),
|
|
531
|
+
}
|
|
532
|
+
const patched = applyJsonPatch(current, operations as PatchOperation[])
|
|
533
|
+
if (!patched.ok) return invalid(patched.reason)
|
|
534
|
+
const body = patched.value as Record<string, unknown>
|
|
535
|
+
if (!Array.isArray(body.values) || !Array.isArray(body.variants))
|
|
536
|
+
return invalid('A patch may change values and variants, but not remove them.')
|
|
537
|
+
const aliases = new Set((await port.propertyAliases?.(key)) ?? [])
|
|
538
|
+
const unknown = (body.values as Array<{ alias?: unknown }>).find(
|
|
539
|
+
(value) => !aliases.has(String(value?.alias)),
|
|
540
|
+
)
|
|
541
|
+
if (unknown)
|
|
542
|
+
return invalid(`This document's type has no property "${String(unknown.alias)}".`, 422)
|
|
543
|
+
return saveUpdate(ctx, key, existing, body)
|
|
480
544
|
})
|
|
481
545
|
|
|
482
546
|
router.handle('PutDocumentByIdPublish', async (ctx) => {
|
|
@@ -849,7 +913,6 @@ export function registerDocumentHandlers(
|
|
|
849
913
|
})
|
|
850
914
|
|
|
851
915
|
/** The page's history, from its versions: who saved, published or rolled back, and when. */
|
|
852
|
-
router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
853
916
|
|
|
854
917
|
router.handle('GetDocumentVersion', async (ctx) => {
|
|
855
918
|
const documentId = ctx.url.searchParams.get('documentId')
|
|
@@ -922,40 +985,3 @@ export function registerDocumentHandlers(
|
|
|
922
985
|
})
|
|
923
986
|
})
|
|
924
987
|
}
|
|
925
|
-
|
|
926
|
-
const AUDIT_TYPE = {
|
|
927
|
-
save: 'Save',
|
|
928
|
-
publish: 'Publish',
|
|
929
|
-
rollback: 'RollBack',
|
|
930
|
-
migrate: 'System',
|
|
931
|
-
} as const
|
|
932
|
-
|
|
933
|
-
/** An audit log built from an item's versions, paged and filtered as the contract asks. */
|
|
934
|
-
export async function auditLogResponse(
|
|
935
|
-
ctx: RequestContext,
|
|
936
|
-
port: {
|
|
937
|
-
versions(key: string): Promise<DocumentVersionSummary[]>
|
|
938
|
-
systemUserKey(): Promise<string | null>
|
|
939
|
-
},
|
|
940
|
-
): Promise<Response> {
|
|
941
|
-
const page = paging(ctx)
|
|
942
|
-
if (!page) return problemResponse(invalidSkipTake())
|
|
943
|
-
const since = ctx.url.searchParams.get('sinceDate')
|
|
944
|
-
const sinceTime = since ? Date.parse(since) : Number.NaN
|
|
945
|
-
let versions = await port.versions(ctx.params.id as string)
|
|
946
|
-
if (!Number.isNaN(sinceTime))
|
|
947
|
-
versions = versions.filter((v) => v.versionDate.getTime() >= sinceTime)
|
|
948
|
-
if (ctx.url.searchParams.get('orderDirection') === 'Ascending') versions = versions.reverse()
|
|
949
|
-
const fallbackUser = await port.systemUserKey()
|
|
950
|
-
const window = versions.slice(page.skip, page.skip + page.take)
|
|
951
|
-
return Response.json({
|
|
952
|
-
total: versions.length,
|
|
953
|
-
items: window.map((version) => ({
|
|
954
|
-
user: { id: version.userKey ?? fallbackUser },
|
|
955
|
-
timestamp: version.versionDate.toISOString(),
|
|
956
|
-
logType: AUDIT_TYPE[version.kind ?? 'save'],
|
|
957
|
-
comment: null,
|
|
958
|
-
parameters: null,
|
|
959
|
-
})),
|
|
960
|
-
})
|
|
961
|
-
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic roots: a content picker's start node, resolved from the document being
|
|
3
|
+
* edited by an origin and a list of steps.
|
|
4
|
+
*/
|
|
5
|
+
import { problemDetails, problemResponse } from '@crombolo/core'
|
|
6
|
+
import type { DynamicRootPort } from '../ports.ts'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
import { readBody } from './document.ts'
|
|
9
|
+
|
|
10
|
+
export const DYNAMIC_ROOT_STEP_ALIASES = [
|
|
11
|
+
'NearestAncestorOrSelf',
|
|
12
|
+
'FurthestAncestorOrSelf',
|
|
13
|
+
'NearestDescendantOrSelf',
|
|
14
|
+
'FurthestDescendantOrSelf',
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
const EMPTY_GUID = '00000000-0000-0000-0000-000000000000'
|
|
18
|
+
|
|
19
|
+
const keyOf = (value: unknown): string | null => {
|
|
20
|
+
const id = (value as { id?: unknown } | null | undefined)?.id ?? value
|
|
21
|
+
return typeof id === 'string' && id !== '' && id !== EMPTY_GUID ? id : null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function registerDynamicRootHandlers(
|
|
25
|
+
router: ManagementApiRouter,
|
|
26
|
+
port: DynamicRootPort,
|
|
27
|
+
): void {
|
|
28
|
+
router.handle('GetDynamicRootSteps', () => Response.json(DYNAMIC_ROOT_STEP_ALIASES))
|
|
29
|
+
router.handle('PostDynamicRootQuery', async (ctx) => {
|
|
30
|
+
const body = await readBody(ctx)
|
|
31
|
+
const context = body.context as { id?: unknown; parent?: unknown } | undefined
|
|
32
|
+
const query = body.query as
|
|
33
|
+
| { origin?: { alias?: unknown; id?: unknown }; steps?: unknown }
|
|
34
|
+
| undefined
|
|
35
|
+
if (!context || typeof query?.origin?.alias !== 'string')
|
|
36
|
+
return problemResponse(
|
|
37
|
+
problemDetails({
|
|
38
|
+
title: 'Invalid dynamic root query',
|
|
39
|
+
detail: 'A query needs a context and an origin.',
|
|
40
|
+
status: 400,
|
|
41
|
+
}),
|
|
42
|
+
)
|
|
43
|
+
const steps = Array.isArray(query.steps) ? query.steps : []
|
|
44
|
+
const roots = await port.resolve({
|
|
45
|
+
currentKey: keyOf(context.id),
|
|
46
|
+
parentKey: keyOf(context.parent),
|
|
47
|
+
origin: { alias: query.origin.alias, key: keyOf(query.origin.id) },
|
|
48
|
+
steps: steps.map((step) => {
|
|
49
|
+
const { alias, documentTypeIds } = (step ?? {}) as {
|
|
50
|
+
alias?: unknown
|
|
51
|
+
documentTypeIds?: unknown
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
alias: String(alias ?? ''),
|
|
55
|
+
documentTypeKeys: Array.isArray(documentTypeIds)
|
|
56
|
+
? documentTypeIds.filter((id): id is string => typeof id === 'string')
|
|
57
|
+
: [],
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
return Response.json({ roots })
|
|
62
|
+
})
|
|
63
|
+
}
|
package/src/handlers/element.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { invalidSkipTake, notFound, problemDetails, problemResponse } from '@cro
|
|
|
17
17
|
import type { ElementPort, PortFailure, ReferencePort } from '../ports-content.ts'
|
|
18
18
|
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
19
19
|
import { created, paging, registerFolderHandlers } from './content-type.ts'
|
|
20
|
-
import {
|
|
20
|
+
import { failureResponse, readBody, toSaveDocument } from './document.ts'
|
|
21
21
|
|
|
22
22
|
const API = '/umbraco/management/api/v1'
|
|
23
23
|
|
|
@@ -444,7 +444,6 @@ export function registerElementHandlers(
|
|
|
444
444
|
})
|
|
445
445
|
|
|
446
446
|
// ── history ───────────────────────────────────────────────────────────────
|
|
447
|
-
handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
448
447
|
|
|
449
448
|
handle('GetElementVersion', async (ctx) => {
|
|
450
449
|
const elementId = ctx.url.searchParams.get('elementId')
|
package/src/handlers/media.ts
CHANGED
|
@@ -13,8 +13,8 @@ import {
|
|
|
13
13
|
import type { MediaPort, PortFailure } from '../ports-content.ts'
|
|
14
14
|
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
15
15
|
import { created, paging } from './content-type.ts'
|
|
16
|
-
import {
|
|
17
|
-
import { visibleChildren, visibleRoot, visibleSiblings } from './start-node-trees.ts'
|
|
16
|
+
import { readBody, toSaveDocument, validationProblem } from './document.ts'
|
|
17
|
+
import { visibleChildren, visibleRoot, visibleSearch, visibleSiblings } from './start-node-trees.ts'
|
|
18
18
|
|
|
19
19
|
const API = '/umbraco/management/api/v1'
|
|
20
20
|
|
|
@@ -251,8 +251,6 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
|
|
|
251
251
|
router.handle('PutMediaByIdSortChildren', (ctx) => sortByField(ctx, ctx.params.id as string))
|
|
252
252
|
router.handle('PutMediaRootSortChildren', (ctx) => sortByField(ctx, null))
|
|
253
253
|
|
|
254
|
-
router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
255
|
-
|
|
256
254
|
/** Thumbnail and crop URLs in the query vocabulary the media route understands. */
|
|
257
255
|
router.handle('GetImagingResizeUrls', async (ctx) => {
|
|
258
256
|
const params = new URLSearchParams()
|
|
@@ -341,11 +339,17 @@ export function registerMediaHandlers(router: ManagementApiRouter, port: MediaPo
|
|
|
341
339
|
const page = paging(ctx)
|
|
342
340
|
if (!page) return problemResponse(invalidSkipTake())
|
|
343
341
|
const parentId = ctx.url.searchParams.get('parentId')
|
|
344
|
-
const result = await
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
342
|
+
const result = await visibleSearch(
|
|
343
|
+
mediaStart(ctx),
|
|
344
|
+
undefined,
|
|
345
|
+
(paging) =>
|
|
346
|
+
port.search(ctx.url.searchParams.get('query') ?? '', {
|
|
347
|
+
trashed: ctx.url.searchParams.get('trashed') === 'true',
|
|
348
|
+
parentKey: parentId,
|
|
349
|
+
...paging,
|
|
350
|
+
}),
|
|
351
|
+
page,
|
|
352
|
+
)
|
|
349
353
|
return Response.json({ total: result.total, items: result.items.map(toMediaItem) })
|
|
350
354
|
})
|
|
351
355
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed reference answers: the node object types a relation type can join, and
|
|
3
|
+
* the help pages. Umbraco relays its help pages from our.umbraco.com; there is
|
|
4
|
+
* nothing to relay here, so the list is honestly empty.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, ObjectTypes, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
import { paging } from './content-type.ts'
|
|
9
|
+
|
|
10
|
+
/** Internal markers rather than things a relation can point at. */
|
|
11
|
+
const HIDDEN = new Set<string>(['SystemRoot', 'LockObject'])
|
|
12
|
+
|
|
13
|
+
/** `DocumentTypeContainer` → `Document Type Container`. */
|
|
14
|
+
const spaced = (name: string) => name.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
15
|
+
|
|
16
|
+
export const OBJECT_TYPES = Object.entries(ObjectTypes)
|
|
17
|
+
.filter(([name]) => !HIDDEN.has(name))
|
|
18
|
+
.map(([name, id]) => ({ id, name: spaced(name) }))
|
|
19
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
20
|
+
|
|
21
|
+
export function registerReferenceDataHandlers(router: ManagementApiRouter): void {
|
|
22
|
+
router.handle('GetObjectTypes', (ctx) => {
|
|
23
|
+
const page = paging(ctx)
|
|
24
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
25
|
+
return Response.json({
|
|
26
|
+
total: OBJECT_TYPES.length,
|
|
27
|
+
items: OBJECT_TYPES.slice(page.skip, page.skip + page.take),
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
router.handle('GetHelp', (ctx) => {
|
|
31
|
+
const page = paging(ctx)
|
|
32
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
33
|
+
return Response.json({ total: 0, items: [] })
|
|
34
|
+
})
|
|
35
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation types and their relations, read-only and derived (story 046), and
|
|
3
|
+
* the recycle bins' "what refers to what is in here" lists.
|
|
4
|
+
*/
|
|
5
|
+
import { invalidSkipTake, notFound, paged, parseSkipTake, problemResponse } from '@crombolo/core'
|
|
6
|
+
import type { ReferencePort, RelationPort } from '../ports-content.ts'
|
|
7
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
8
|
+
|
|
9
|
+
const missing = () => problemResponse(notFound('The relation type could not be found'))
|
|
10
|
+
|
|
11
|
+
export function registerRelationHandlers(router: ManagementApiRouter, port: RelationPort): void {
|
|
12
|
+
router.handle('GetRelationType', (ctx) => {
|
|
13
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
14
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
15
|
+
const types = port.types()
|
|
16
|
+
return Response.json(
|
|
17
|
+
paged(types.slice(page.value.skip, page.value.skip + page.value.take), types.length),
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
router.handle('GetRelationTypeById', (ctx) => {
|
|
22
|
+
const id = String(ctx.params.id).toLowerCase()
|
|
23
|
+
const type = port.types().find((t) => t.id === id)
|
|
24
|
+
return type ? Response.json(type) : missing()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
router.handle('GetItemRelationType', (ctx) => {
|
|
28
|
+
const ids = new Set(ctx.url.searchParams.getAll('id').map((id) => id.toLowerCase()))
|
|
29
|
+
// Built in, so none of them can be deleted.
|
|
30
|
+
return Response.json(
|
|
31
|
+
port
|
|
32
|
+
.types()
|
|
33
|
+
.filter((t) => ids.has(t.id))
|
|
34
|
+
.map((t) => ({ id: t.id, name: t.name, isDeletable: false, flags: [] })),
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
router.handle('GetRelationByRelationTypeId', async (ctx) => {
|
|
39
|
+
const page = parseSkipTake(ctx.url.searchParams)
|
|
40
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
41
|
+
const result = await port.relations(String(ctx.params.id), page.value)
|
|
42
|
+
return result ? Response.json(paged(result.items, result.total)) : missing()
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function registerRecycleBinReferenceHandlers(
|
|
47
|
+
router: ManagementApiRouter,
|
|
48
|
+
port: ReferencePort,
|
|
49
|
+
): void {
|
|
50
|
+
const ask = port.recycleBinReferencedBy?.bind(port)
|
|
51
|
+
if (!ask) return
|
|
52
|
+
for (const area of ['Document', 'Media', 'Element'] as const)
|
|
53
|
+
router.handle(`GetRecycleBin${area}ReferencedBy`, async (ctx) => {
|
|
54
|
+
// Umbraco pages these 20 at a time, as it does every references list.
|
|
55
|
+
const page = parseSkipTake(ctx.url.searchParams, 20)
|
|
56
|
+
if (!page.ok) return problemResponse(invalidSkipTake())
|
|
57
|
+
const result = await ask(area, page.value)
|
|
58
|
+
return Response.json(paged(result.items, result.total))
|
|
59
|
+
})
|
|
60
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Examine dashboard: the indexes, their health, rebuilding one, and searching
|
|
3
|
+
* one directly. An index with items still waiting reports itself rebuilding, which
|
|
4
|
+
* is what makes the dashboard poll until they are done. docs/search.md.
|
|
5
|
+
*/
|
|
6
|
+
import { invalidSkipTake, problemDetails, problemResponse } from '@crombolo/core'
|
|
7
|
+
import type { SearchIndexInfo, SearchIndexPort } from '../ports.ts'
|
|
8
|
+
import type { ManagementApiRouter } from '../router.ts'
|
|
9
|
+
import { paging } from './content-type.ts'
|
|
10
|
+
|
|
11
|
+
const waiting = (n: number) => `${n} ${n === 1 ? 'item is' : 'items are'} waiting to be indexed`
|
|
12
|
+
|
|
13
|
+
function toIndexResponse(index: SearchIndexInfo) {
|
|
14
|
+
return {
|
|
15
|
+
name: index.name,
|
|
16
|
+
healthStatus:
|
|
17
|
+
index.pending > 0
|
|
18
|
+
? { status: 'Rebuilding', message: waiting(index.pending) }
|
|
19
|
+
: { status: 'Healthy', message: null },
|
|
20
|
+
canRebuild: true,
|
|
21
|
+
searcherName: index.searcherName,
|
|
22
|
+
documentCount: index.documentCount,
|
|
23
|
+
fieldCount: index.fieldCount,
|
|
24
|
+
providerProperties: null,
|
|
25
|
+
uniqueKeyFieldName: '__Key',
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const indexNotFound = (name: string) =>
|
|
30
|
+
problemResponse(
|
|
31
|
+
problemDetails({
|
|
32
|
+
title: 'Index Not Found',
|
|
33
|
+
detail: `No index found with name = ${name}`,
|
|
34
|
+
status: 400,
|
|
35
|
+
}),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
export function registerSearchIndexHandlers(
|
|
39
|
+
router: ManagementApiRouter,
|
|
40
|
+
port: SearchIndexPort,
|
|
41
|
+
): void {
|
|
42
|
+
router.handle('GetIndexer', async (ctx) => {
|
|
43
|
+
const page = paging(ctx)
|
|
44
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
45
|
+
const indexes = await port.indexes()
|
|
46
|
+
return Response.json({
|
|
47
|
+
total: indexes.length,
|
|
48
|
+
items: indexes.slice(page.skip, page.skip + page.take).map(toIndexResponse),
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
router.handle('GetIndexerByIndexName', async ({ params }) => {
|
|
53
|
+
const name = params.indexName as string
|
|
54
|
+
const index = await port.index(name)
|
|
55
|
+
return index ? Response.json(toIndexResponse(index)) : indexNotFound(name)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
router.handle('PostIndexerByIndexNameRebuild', async ({ params }) => {
|
|
59
|
+
const name = params.indexName as string
|
|
60
|
+
return (await port.rebuild(name)) ? new Response(null, { status: 200 }) : indexNotFound(name)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
router.handle('GetSearcher', (ctx) => {
|
|
64
|
+
const page = paging(ctx)
|
|
65
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
66
|
+
const names = port.searchers()
|
|
67
|
+
return Response.json({
|
|
68
|
+
total: names.length,
|
|
69
|
+
items: names.slice(page.skip, page.skip + page.take).map((name) => ({ name })),
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
router.handle('GetSearcherBySearcherNameQuery', async (ctx) => {
|
|
74
|
+
const page = paging(ctx)
|
|
75
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
76
|
+
const name = ctx.params.searcherName as string
|
|
77
|
+
const result = await port.query(name, ctx.url.searchParams.get('term') ?? '', page)
|
|
78
|
+
if (!result)
|
|
79
|
+
return problemResponse(
|
|
80
|
+
problemDetails({
|
|
81
|
+
title: 'Searcher Not Found',
|
|
82
|
+
detail: `No searcher found with name = ${name}`,
|
|
83
|
+
status: 404,
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
return Response.json({
|
|
87
|
+
total: result.total,
|
|
88
|
+
items: result.items.map((hit) => ({
|
|
89
|
+
id: hit.key,
|
|
90
|
+
score: hit.score,
|
|
91
|
+
fieldCount: hit.fields.length,
|
|
92
|
+
fields: hit.fields,
|
|
93
|
+
})),
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
}
|
|
@@ -23,18 +23,9 @@ const PAGED = [
|
|
|
23
23
|
`GetRecycleBin${b}Children`,
|
|
24
24
|
`GetRecycleBin${b}ReferencedBy`,
|
|
25
25
|
]),
|
|
26
|
-
'GetRecycleBinDocumentReferencedBy', // needs relations, story 046
|
|
27
|
-
'GetRecycleBinMediaReferencedBy',
|
|
28
|
-
'GetRecycleBinElementReferencedBy',
|
|
29
|
-
// The recycle bin and the trash and delete dialogs; needs relations, story 046. Until
|
|
30
|
-
// then they cannot warn that other content links to what is being removed.
|
|
31
|
-
'GetTreeStaticFileRoot',
|
|
32
|
-
'GetTreeStaticFileChildren',
|
|
33
|
-
'GetRelationType',
|
|
34
|
-
'GetWebhook',
|
|
35
26
|
]
|
|
36
27
|
/** Ancestor lookups: a bare array. */
|
|
37
|
-
const ANCESTORS =
|
|
28
|
+
const ANCESTORS = STUB_TREES.map((t) => `GetTree${t}Ancestors`)
|
|
38
29
|
/** Sibling windows: `{ totalBefore, totalAfter, items }`. */
|
|
39
30
|
const SIBLINGS = [
|
|
40
31
|
...STUB_TREES.map((t) => `GetTree${t}Siblings`),
|
|
@@ -82,3 +82,29 @@ export async function visibleSiblings(
|
|
|
82
82
|
const visible = await onTheWay(start, source, window.items)
|
|
83
83
|
return { items: visible.items, totalBefore: 0, totalAfter: 0 }
|
|
84
84
|
}
|
|
85
|
+
|
|
86
|
+
/** How far a filtered search looks before paging: past it, an editor refines the query. */
|
|
87
|
+
const SEARCH_WINDOW = 1_000
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A page of search results as a user may see them: under a start node, and
|
|
91
|
+
* passing `allowed` (Browse, for documents). Unrestricted access pages in the
|
|
92
|
+
* database; otherwise a window of the best matches is filtered, then paged.
|
|
93
|
+
*/
|
|
94
|
+
export async function visibleSearch(
|
|
95
|
+
start: StartNodes,
|
|
96
|
+
allowed: ((chain: readonly string[]) => boolean) | undefined,
|
|
97
|
+
search: (paging: SkipTake) => Promise<Page<DocumentTreeItem>>,
|
|
98
|
+
paging: SkipTake,
|
|
99
|
+
): Promise<Page<DocumentTreeItem>> {
|
|
100
|
+
if (start.root && !allowed) return search(paging)
|
|
101
|
+
const window = await search({ skip: 0, take: SEARCH_WINDOW })
|
|
102
|
+
const visible = window.items.filter((item) => {
|
|
103
|
+
const chain = chainOf(item)
|
|
104
|
+
return hasPathAccess(start, chain, item.isTrashed) && (allowed?.(chain) ?? true)
|
|
105
|
+
})
|
|
106
|
+
return {
|
|
107
|
+
total: visible.length,
|
|
108
|
+
items: visible.slice(paging.skip, paging.skip + paging.take),
|
|
109
|
+
}
|
|
110
|
+
}
|