@crombolo/api-management 0.10.0 → 0.11.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/package.json +3 -3
- package/src/audit.ts +430 -0
- package/src/handlers/audit-log.ts +131 -0
- package/src/handlers/document-blueprint.ts +1 -3
- package/src/handlers/document.ts +25 -44
- package/src/handlers/element.ts +1 -2
- package/src/handlers/media.ts +13 -9
- package/src/handlers/search-index.ts +96 -0
- package/src/handlers/start-node-trees.ts +26 -0
- package/src/index.ts +1 -0
- package/src/ports.ts +50 -0
- package/src/router.ts +105 -0
- package/src/routes.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/api-management",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Umbraco-compatible Management API handlers for crombolo.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/contracts": "0.
|
|
31
|
+
"@crombolo/core": "0.11.0",
|
|
32
|
+
"@crombolo/contracts": "0.11.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/audit.ts
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What each backoffice change writes to the audit log. Derived from the
|
|
3
|
+
* operation's name, as server events are, with the exceptions listed; every
|
|
4
|
+
* contract operation that changes something is either mapped here or listed as
|
|
5
|
+
* not a change, and a test holds that to the whole contract. docs/audit-log.md.
|
|
6
|
+
*/
|
|
7
|
+
export type AuditCategory =
|
|
8
|
+
| 'content'
|
|
9
|
+
| 'media'
|
|
10
|
+
| 'member'
|
|
11
|
+
| 'schema'
|
|
12
|
+
| 'settings'
|
|
13
|
+
| 'user'
|
|
14
|
+
| 'permission'
|
|
15
|
+
| 'sign-in'
|
|
16
|
+
| 'system'
|
|
17
|
+
|
|
18
|
+
export interface AuditPlan {
|
|
19
|
+
category: AuditCategory
|
|
20
|
+
entityType: string
|
|
21
|
+
action: string
|
|
22
|
+
/** Where the item's key is: a path parameter, the created resource, or the request body. */
|
|
23
|
+
key: 'param' | 'generated' | 'none' | ((body: unknown) => string[])
|
|
24
|
+
/** Other items the change names in its body, listed in the entry's detail: a group's users. */
|
|
25
|
+
related?: { entityType: string; label: string; keys: (body: unknown) => string[] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One entry the router is about to write, before the item is named. */
|
|
29
|
+
export interface AuditDraft {
|
|
30
|
+
category: AuditCategory
|
|
31
|
+
action: string
|
|
32
|
+
entityType: string
|
|
33
|
+
entityKey: string | null
|
|
34
|
+
detail?: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AuditRecord extends AuditDraft {
|
|
38
|
+
entityName: string | null
|
|
39
|
+
userKey: string | null
|
|
40
|
+
userName: string | null
|
|
41
|
+
ipAddress: string | null
|
|
42
|
+
forwardedFor: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where the router writes. The server adapts the data layer's `AuditLog` to this. */
|
|
46
|
+
export interface AuditSink {
|
|
47
|
+
/** Names an item, re-keying it when it belongs to another (a version to its page). */
|
|
48
|
+
describe(
|
|
49
|
+
entityType: string,
|
|
50
|
+
key: string,
|
|
51
|
+
): Promise<{ entityType: string; entityKey: string; entityName: string | null }>
|
|
52
|
+
record(entries: AuditRecord[]): Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Writes that change nothing anyone would answer for: checks, previews, a user's own UI state. */
|
|
56
|
+
export const NOT_AUDITED = new Set([
|
|
57
|
+
'DeletePreview',
|
|
58
|
+
'DeleteTemporaryFileById',
|
|
59
|
+
'DeleteUserDataById',
|
|
60
|
+
'PostDocumentTypeAvailableCompositions',
|
|
61
|
+
'PostDynamicRootQuery',
|
|
62
|
+
'PostHealthCheckGroupByNameCheck',
|
|
63
|
+
'PostInstallValidateDatabase',
|
|
64
|
+
'PostMediaTypeAvailableCompositions',
|
|
65
|
+
'PostMemberTypeAvailableCompositions',
|
|
66
|
+
'PostSecurityForgotPassword',
|
|
67
|
+
'PostSecurityForgotPasswordVerify',
|
|
68
|
+
'PostTemplateQueryExecute',
|
|
69
|
+
'PostTemporaryFile',
|
|
70
|
+
'PostUpgradeAuthorize',
|
|
71
|
+
'PostUserData',
|
|
72
|
+
'PostUserInviteVerify',
|
|
73
|
+
'PutUserData',
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
const ids = (field: string) => (body: unknown) => {
|
|
77
|
+
const list = (body as Record<string, unknown> | null)?.[field]
|
|
78
|
+
return Array.isArray(list)
|
|
79
|
+
? list.map((item) => (item as { id?: unknown })?.id).filter((id) => typeof id === 'string')
|
|
80
|
+
: []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const listed = (body: unknown) =>
|
|
84
|
+
Array.isArray(body)
|
|
85
|
+
? body.map((item) => (item as { id?: unknown })?.id).filter((id) => typeof id === 'string')
|
|
86
|
+
: []
|
|
87
|
+
|
|
88
|
+
const GROUPS = { entityType: 'UserGroup', label: 'Groups', keys: ids('userGroupIds') }
|
|
89
|
+
const USERS = { entityType: 'User', label: 'Users', keys: listed }
|
|
90
|
+
|
|
91
|
+
const reference = (field: string) => (body: unknown) => {
|
|
92
|
+
const id = ((body as Record<string, unknown> | null)?.[field] as { id?: unknown } | null)?.id
|
|
93
|
+
return typeof id === 'string' ? [id] : []
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const EXCEPTIONS: Record<string, AuditPlan> = {
|
|
97
|
+
DeleteUser: { category: 'user', entityType: 'User', action: 'Delete', key: ids('userIds') },
|
|
98
|
+
PostUserDisable: { category: 'user', entityType: 'User', action: 'Disable', key: ids('userIds') },
|
|
99
|
+
PostUserEnable: { category: 'user', entityType: 'User', action: 'Enable', key: ids('userIds') },
|
|
100
|
+
PostUserUnlock: { category: 'user', entityType: 'User', action: 'Unlock', key: ids('userIds') },
|
|
101
|
+
PostUserInvite: { category: 'user', entityType: 'User', action: 'Invite', key: 'generated' },
|
|
102
|
+
PostUserInviteResend: {
|
|
103
|
+
category: 'user',
|
|
104
|
+
entityType: 'User',
|
|
105
|
+
action: 'ResendInvite',
|
|
106
|
+
key: reference('user'),
|
|
107
|
+
},
|
|
108
|
+
PostUserInviteCreatePassword: {
|
|
109
|
+
category: 'user',
|
|
110
|
+
entityType: 'User',
|
|
111
|
+
action: 'AcceptInvite',
|
|
112
|
+
key: reference('user'),
|
|
113
|
+
},
|
|
114
|
+
PostSecurityForgotPasswordReset: {
|
|
115
|
+
category: 'user',
|
|
116
|
+
entityType: 'User',
|
|
117
|
+
action: 'ResetPasswordByLink',
|
|
118
|
+
key: reference('user'),
|
|
119
|
+
},
|
|
120
|
+
PostUserSetUserGroups: {
|
|
121
|
+
category: 'permission',
|
|
122
|
+
entityType: 'User',
|
|
123
|
+
action: 'SetUserGroups',
|
|
124
|
+
key: ids('userIds'),
|
|
125
|
+
related: GROUPS,
|
|
126
|
+
},
|
|
127
|
+
PostUser: {
|
|
128
|
+
category: 'user',
|
|
129
|
+
entityType: 'User',
|
|
130
|
+
action: 'Create',
|
|
131
|
+
key: 'generated',
|
|
132
|
+
related: GROUPS,
|
|
133
|
+
},
|
|
134
|
+
PutUserById: {
|
|
135
|
+
category: 'user',
|
|
136
|
+
entityType: 'User',
|
|
137
|
+
action: 'Save',
|
|
138
|
+
key: 'param',
|
|
139
|
+
related: GROUPS,
|
|
140
|
+
},
|
|
141
|
+
PostUserGroupByIdUsers: {
|
|
142
|
+
category: 'permission',
|
|
143
|
+
entityType: 'UserGroup',
|
|
144
|
+
action: 'AddUsers',
|
|
145
|
+
key: 'param',
|
|
146
|
+
related: USERS,
|
|
147
|
+
},
|
|
148
|
+
DeleteUserGroupByIdUsers: {
|
|
149
|
+
category: 'permission',
|
|
150
|
+
entityType: 'UserGroup',
|
|
151
|
+
action: 'RemoveUsers',
|
|
152
|
+
key: 'param',
|
|
153
|
+
related: USERS,
|
|
154
|
+
},
|
|
155
|
+
DeleteUserGroup: {
|
|
156
|
+
category: 'permission',
|
|
157
|
+
entityType: 'UserGroup',
|
|
158
|
+
action: 'Delete',
|
|
159
|
+
key: ids('userGroupIds'),
|
|
160
|
+
},
|
|
161
|
+
PutDocumentSort: {
|
|
162
|
+
category: 'content',
|
|
163
|
+
entityType: 'Document',
|
|
164
|
+
action: 'Sort',
|
|
165
|
+
key: reference('parent'),
|
|
166
|
+
},
|
|
167
|
+
PutMediaSort: {
|
|
168
|
+
category: 'media',
|
|
169
|
+
entityType: 'Media',
|
|
170
|
+
action: 'Sort',
|
|
171
|
+
key: reference('parent'),
|
|
172
|
+
},
|
|
173
|
+
PutDocumentRootSortChildren: {
|
|
174
|
+
category: 'content',
|
|
175
|
+
entityType: 'Document',
|
|
176
|
+
action: 'Sort',
|
|
177
|
+
key: 'none',
|
|
178
|
+
},
|
|
179
|
+
PutMediaRootSortChildren: { category: 'media', entityType: 'Media', action: 'Sort', key: 'none' },
|
|
180
|
+
PutUserCurrentProfile: {
|
|
181
|
+
category: 'user',
|
|
182
|
+
entityType: 'User',
|
|
183
|
+
action: 'SaveProfile',
|
|
184
|
+
key: 'none',
|
|
185
|
+
},
|
|
186
|
+
PostUserCurrentChangePassword: {
|
|
187
|
+
category: 'user',
|
|
188
|
+
entityType: 'User',
|
|
189
|
+
action: 'ChangePassword',
|
|
190
|
+
key: 'none',
|
|
191
|
+
},
|
|
192
|
+
PostUserCurrentAvatar: { category: 'user', entityType: 'User', action: 'SetAvatar', key: 'none' },
|
|
193
|
+
DeleteUserCurrentAvatar: {
|
|
194
|
+
category: 'user',
|
|
195
|
+
entityType: 'User',
|
|
196
|
+
action: 'RemoveAvatar',
|
|
197
|
+
key: 'none',
|
|
198
|
+
},
|
|
199
|
+
PostUserCurrent2faByProviderName: {
|
|
200
|
+
category: 'user',
|
|
201
|
+
entityType: 'User',
|
|
202
|
+
action: 'EnableTwoFactor',
|
|
203
|
+
key: 'none',
|
|
204
|
+
},
|
|
205
|
+
DeleteUserCurrent2faByProviderName: {
|
|
206
|
+
category: 'user',
|
|
207
|
+
entityType: 'User',
|
|
208
|
+
action: 'DisableTwoFactor',
|
|
209
|
+
key: 'none',
|
|
210
|
+
},
|
|
211
|
+
PostInstallSetup: { category: 'system', entityType: 'Install', action: 'Install', key: 'none' },
|
|
212
|
+
PostPublishedCacheRebuild: {
|
|
213
|
+
category: 'system',
|
|
214
|
+
entityType: 'PublishedCache',
|
|
215
|
+
action: 'Rebuild',
|
|
216
|
+
key: 'none',
|
|
217
|
+
},
|
|
218
|
+
PostPublishedCacheReload: {
|
|
219
|
+
category: 'system',
|
|
220
|
+
entityType: 'PublishedCache',
|
|
221
|
+
action: 'Reload',
|
|
222
|
+
key: 'none',
|
|
223
|
+
},
|
|
224
|
+
PostModelsBuilderBuild: {
|
|
225
|
+
category: 'system',
|
|
226
|
+
entityType: 'ModelsBuilder',
|
|
227
|
+
action: 'Build',
|
|
228
|
+
key: 'none',
|
|
229
|
+
},
|
|
230
|
+
PostTelemetryLevel: {
|
|
231
|
+
category: 'settings',
|
|
232
|
+
entityType: 'Telemetry',
|
|
233
|
+
action: 'Save',
|
|
234
|
+
key: 'none',
|
|
235
|
+
},
|
|
236
|
+
PutProfilingStatus: {
|
|
237
|
+
category: 'settings',
|
|
238
|
+
entityType: 'Profiling',
|
|
239
|
+
action: 'Save',
|
|
240
|
+
key: 'none',
|
|
241
|
+
},
|
|
242
|
+
PostRedirectManagementStatus: {
|
|
243
|
+
category: 'settings',
|
|
244
|
+
entityType: 'RedirectManagement',
|
|
245
|
+
action: 'SetStatus',
|
|
246
|
+
key: 'none',
|
|
247
|
+
},
|
|
248
|
+
PostHealthCheckExecuteAction: {
|
|
249
|
+
category: 'system',
|
|
250
|
+
entityType: 'HealthCheck',
|
|
251
|
+
action: 'Execute',
|
|
252
|
+
key: 'none',
|
|
253
|
+
},
|
|
254
|
+
PostIndexerByIndexNameRebuild: {
|
|
255
|
+
category: 'system',
|
|
256
|
+
entityType: 'Index',
|
|
257
|
+
action: 'Rebuild',
|
|
258
|
+
key: 'param',
|
|
259
|
+
},
|
|
260
|
+
PostDocumentCreateAndPublish: {
|
|
261
|
+
category: 'content',
|
|
262
|
+
entityType: 'Document',
|
|
263
|
+
action: 'CreateAndPublish',
|
|
264
|
+
key: 'generated',
|
|
265
|
+
},
|
|
266
|
+
PostElementCreateAndPublish: {
|
|
267
|
+
category: 'content',
|
|
268
|
+
entityType: 'Element',
|
|
269
|
+
action: 'CreateAndPublish',
|
|
270
|
+
key: 'generated',
|
|
271
|
+
},
|
|
272
|
+
PostDocumentBlueprintFromDocument: {
|
|
273
|
+
category: 'content',
|
|
274
|
+
entityType: 'DocumentBlueprint',
|
|
275
|
+
action: 'Create',
|
|
276
|
+
key: 'generated',
|
|
277
|
+
},
|
|
278
|
+
PostDictionaryImport: {
|
|
279
|
+
category: 'settings',
|
|
280
|
+
entityType: 'DictionaryItem',
|
|
281
|
+
action: 'Import',
|
|
282
|
+
key: 'generated',
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Operation-name entity prefixes, longest first so `DocumentType` wins over `Document`. */
|
|
287
|
+
const ENTITIES: Array<[string, AuditCategory]> = [
|
|
288
|
+
['DocumentBlueprint', 'content'],
|
|
289
|
+
['DocumentVersion', 'content'],
|
|
290
|
+
['DocumentType', 'schema'],
|
|
291
|
+
['Document', 'content'],
|
|
292
|
+
['ElementVersion', 'content'],
|
|
293
|
+
['Element', 'content'],
|
|
294
|
+
['MediaType', 'schema'],
|
|
295
|
+
['Media', 'media'],
|
|
296
|
+
['MemberGroup', 'member'],
|
|
297
|
+
['MemberType', 'schema'],
|
|
298
|
+
['Member', 'member'],
|
|
299
|
+
['DataType', 'schema'],
|
|
300
|
+
['Template', 'schema'],
|
|
301
|
+
['PartialView', 'schema'],
|
|
302
|
+
['Script', 'schema'],
|
|
303
|
+
['Stylesheet', 'schema'],
|
|
304
|
+
['Language', 'settings'],
|
|
305
|
+
['Dictionary', 'settings'],
|
|
306
|
+
['LogViewerSavedSearch', 'settings'],
|
|
307
|
+
['RedirectManagement', 'settings'],
|
|
308
|
+
['PackageCreated', 'settings'],
|
|
309
|
+
['Package', 'system'],
|
|
310
|
+
['Webhook', 'settings'],
|
|
311
|
+
['UserGroup', 'permission'],
|
|
312
|
+
['User', 'user'],
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
const ENTITY_TYPE: Record<string, string> = {
|
|
316
|
+
Dictionary: 'DictionaryItem',
|
|
317
|
+
PackageCreated: 'Package',
|
|
318
|
+
RedirectManagement: 'Redirect',
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The part after the item's key, to the action it names; anything else is named as it reads. */
|
|
322
|
+
const SUFFIX_ACTION: Record<string, string> = {
|
|
323
|
+
Move: 'Move',
|
|
324
|
+
MoveToRecycleBin: 'Trash',
|
|
325
|
+
Copy: 'Copy',
|
|
326
|
+
Publish: 'Publish',
|
|
327
|
+
Unpublish: 'Unpublish',
|
|
328
|
+
UpdateAndPublish: 'SaveAndPublish',
|
|
329
|
+
PublishWithDescendants: 'PublishWithDescendants',
|
|
330
|
+
SortChildren: 'Sort',
|
|
331
|
+
Domains: 'AssignDomains',
|
|
332
|
+
Notifications: 'SetNotifications',
|
|
333
|
+
Restore: 'Restore',
|
|
334
|
+
Rollback: 'Rollback',
|
|
335
|
+
PreventCleanup: 'PreventCleanup',
|
|
336
|
+
Import: 'Import',
|
|
337
|
+
Rename: 'Rename',
|
|
338
|
+
Template: 'CreateTemplate',
|
|
339
|
+
ChangePassword: 'ChangePassword',
|
|
340
|
+
ResetPassword: 'ResetPassword',
|
|
341
|
+
RunMigration: 'RunMigration',
|
|
342
|
+
Patch: 'Save',
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const KEY_SEGMENT = /^By(Id|Path|IsoCode|Name|IndexName)/
|
|
346
|
+
|
|
347
|
+
const plans = new Map<string, AuditPlan | null>()
|
|
348
|
+
|
|
349
|
+
/** The audit entry an operation writes when it succeeds, or undefined for one that changes nothing. */
|
|
350
|
+
export function auditPlanFor(operationId: string): AuditPlan | undefined {
|
|
351
|
+
if (plans.has(operationId)) return plans.get(operationId) ?? undefined
|
|
352
|
+
const plan = derive(operationId)
|
|
353
|
+
plans.set(operationId, plan ?? null)
|
|
354
|
+
return plan
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function derive(operationId: string): AuditPlan | undefined {
|
|
358
|
+
if (NOT_AUDITED.has(operationId) || /Validate$/.test(operationId)) return undefined
|
|
359
|
+
const exception = EXCEPTIONS[operationId]
|
|
360
|
+
if (exception) return exception
|
|
361
|
+
const verb = /^(Post|Put|Delete|Patch)(.+)$/.exec(operationId)
|
|
362
|
+
if (!verb) return undefined
|
|
363
|
+
const method = verb[1] as string
|
|
364
|
+
let rest = verb[2] as string
|
|
365
|
+
const recycleBin = rest.startsWith('RecycleBin')
|
|
366
|
+
if (recycleBin) rest = rest.slice('RecycleBin'.length)
|
|
367
|
+
const found = ENTITIES.find(([name]) => rest.startsWith(name))
|
|
368
|
+
if (!found) return undefined
|
|
369
|
+
const [entity, category] = found
|
|
370
|
+
let after = rest.slice(entity.length)
|
|
371
|
+
let entityType = ENTITY_TYPE[entity] ?? entity
|
|
372
|
+
if (after.startsWith('Folder')) {
|
|
373
|
+
entityType = `${entityType}Folder`
|
|
374
|
+
after = after.slice('Folder'.length)
|
|
375
|
+
}
|
|
376
|
+
const keyed = KEY_SEGMENT.exec(after)
|
|
377
|
+
if (keyed) after = after.slice(keyed[0].length)
|
|
378
|
+
|
|
379
|
+
if (recycleBin && method === 'Delete' && !keyed)
|
|
380
|
+
return { category, entityType, action: 'EmptyRecycleBin', key: 'none' }
|
|
381
|
+
if (after === '') {
|
|
382
|
+
const action = method === 'Post' ? 'Create' : method === 'Delete' ? 'Delete' : 'Save'
|
|
383
|
+
return { category, entityType, action, key: keyed ? 'param' : 'generated' }
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
category: after === 'PublicAccess' ? 'permission' : category,
|
|
387
|
+
entityType,
|
|
388
|
+
action: actionFor(method, after),
|
|
389
|
+
key: keyed ? 'param' : 'none',
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function actionFor(method: string, after: string): string {
|
|
394
|
+
const mapped = SUFFIX_ACTION[after]
|
|
395
|
+
if (mapped) return mapped
|
|
396
|
+
const add = method === 'Delete' ? 'Remove' : method === 'Post' ? 'Add' : 'Set'
|
|
397
|
+
if (after === 'PublicAccess') return `${add}PublicAccess`
|
|
398
|
+
if (after === 'Avatar') return `${add}Avatar`
|
|
399
|
+
if (after.startsWith('ClientCredentials')) return `${add}ClientCredentials`
|
|
400
|
+
if (after.startsWith('2fa')) return 'DisableTwoFactor'
|
|
401
|
+
return `${method}${after}`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Whether the router must read the request body for this operation's keys before the handler does. */
|
|
405
|
+
export function auditReadsBody(plan: AuditPlan): boolean {
|
|
406
|
+
return typeof plan.key === 'function' || plan.related !== undefined
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Whether the item must be named before the handler runs, because it will be gone after. */
|
|
410
|
+
export function auditNamesFirst(plan: AuditPlan): boolean {
|
|
411
|
+
return plan.action === 'Delete' || plan.action === 'Trash'
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** The entries for a successful operation, keyed but not yet named. */
|
|
415
|
+
export function auditDrafts(
|
|
416
|
+
plan: AuditPlan,
|
|
417
|
+
params: Readonly<Record<string, string>>,
|
|
418
|
+
response: Response,
|
|
419
|
+
body: unknown,
|
|
420
|
+
): AuditDraft[] {
|
|
421
|
+
const base = { category: plan.category, action: plan.action, entityType: plan.entityType }
|
|
422
|
+
if (typeof plan.key === 'function')
|
|
423
|
+
return plan.key(body).map((key) => ({ ...base, entityKey: key }))
|
|
424
|
+
if (plan.key === 'none') return [{ ...base, entityKey: null }]
|
|
425
|
+
const key =
|
|
426
|
+
plan.key === 'generated'
|
|
427
|
+
? response.headers.get('umb-generated-resource')
|
|
428
|
+
: (params.id ?? params.path ?? params.isoCode ?? params.name ?? params.indexName)
|
|
429
|
+
return [{ ...base, entityKey: key ?? null }]
|
|
430
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An item's audit log, for the Info tab of pages, media, elements and
|
|
3
|
+
* blueprints. It answers from the audit log; for the time before the log
|
|
4
|
+
* began, the item's versions stand in, as they did before there was one.
|
|
5
|
+
* docs/audit-log.md.
|
|
6
|
+
*/
|
|
7
|
+
import type { DocumentVersionSummary } from '@crombolo/core'
|
|
8
|
+
import { invalidSkipTake, problemResponse } from '@crombolo/core'
|
|
9
|
+
import type { AuditHistoryPort } from '../ports.ts'
|
|
10
|
+
import type { ManagementApiRouter, RequestContext } from '../router.ts'
|
|
11
|
+
import { paging } from './content-type.ts'
|
|
12
|
+
|
|
13
|
+
interface VersionSource {
|
|
14
|
+
versions(key: string): Promise<DocumentVersionSummary[]>
|
|
15
|
+
systemUserKey(): Promise<string | null>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const VERSION_TYPE = {
|
|
19
|
+
save: 'Save',
|
|
20
|
+
publish: 'Publish',
|
|
21
|
+
rollback: 'RollBack',
|
|
22
|
+
migrate: 'System',
|
|
23
|
+
} as const
|
|
24
|
+
|
|
25
|
+
/** The Info tab's names for what an entry records; anything else is `Custom`, with the action as its comment. */
|
|
26
|
+
const ACTION_TYPE: Record<string, string> = {
|
|
27
|
+
Create: 'New',
|
|
28
|
+
CreateAndPublish: 'Publish',
|
|
29
|
+
Save: 'Save',
|
|
30
|
+
Publish: 'Publish',
|
|
31
|
+
SaveAndPublish: 'Publish',
|
|
32
|
+
PublishWithDescendants: 'Publish',
|
|
33
|
+
Unpublish: 'Unpublish',
|
|
34
|
+
Move: 'Move',
|
|
35
|
+
Trash: 'Move',
|
|
36
|
+
Restore: 'Move',
|
|
37
|
+
Copy: 'Copy',
|
|
38
|
+
Sort: 'Sort',
|
|
39
|
+
Delete: 'Delete',
|
|
40
|
+
AssignDomains: 'AssignDomain',
|
|
41
|
+
SetPublicAccess: 'PublicAccess',
|
|
42
|
+
AddPublicAccess: 'PublicAccess',
|
|
43
|
+
RemovePublicAccess: 'PublicAccess',
|
|
44
|
+
SetNotifications: 'Notify',
|
|
45
|
+
Rollback: 'RollBack',
|
|
46
|
+
PreventCleanup: 'ContentVersionPreventCleanup',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COMMENT: Record<string, string> = {
|
|
50
|
+
Trash: 'Moved to the recycle bin',
|
|
51
|
+
Restore: 'Restored from the recycle bin',
|
|
52
|
+
PublishWithDescendants: 'Published with its descendants',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Line {
|
|
56
|
+
user: string | null
|
|
57
|
+
timestamp: Date
|
|
58
|
+
logType: string
|
|
59
|
+
comment: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The log for one item, paged and filtered as the contract asks. */
|
|
63
|
+
export async function auditLogResponse(
|
|
64
|
+
ctx: RequestContext,
|
|
65
|
+
source: VersionSource,
|
|
66
|
+
history: AuditHistoryPort | undefined,
|
|
67
|
+
): Promise<Response> {
|
|
68
|
+
const page = paging(ctx)
|
|
69
|
+
if (!page) return problemResponse(invalidSkipTake())
|
|
70
|
+
const key = ctx.params.id as string
|
|
71
|
+
const since = ctx.url.searchParams.get('sinceDate')
|
|
72
|
+
const sinceTime = since ? Date.parse(since) : Number.NaN
|
|
73
|
+
const fallbackUser = await source.systemUserKey()
|
|
74
|
+
|
|
75
|
+
const started = await history?.startedAt()
|
|
76
|
+
const retained = history?.retainedSince()
|
|
77
|
+
const lines: Line[] = (await source.versions(key))
|
|
78
|
+
.filter((v) => !started || v.versionDate < started)
|
|
79
|
+
.filter((v) => !retained || v.versionDate >= retained)
|
|
80
|
+
.map((v) => ({
|
|
81
|
+
user: v.userKey,
|
|
82
|
+
timestamp: v.versionDate,
|
|
83
|
+
logType: VERSION_TYPE[v.kind ?? 'save'],
|
|
84
|
+
comment: null,
|
|
85
|
+
}))
|
|
86
|
+
for (const entry of (await history?.forItem(key)) ?? [])
|
|
87
|
+
lines.push({
|
|
88
|
+
user: entry.userKey,
|
|
89
|
+
timestamp: entry.occurredAt,
|
|
90
|
+
logType: ACTION_TYPE[entry.action] ?? 'Custom',
|
|
91
|
+
comment: COMMENT[entry.action] ?? (ACTION_TYPE[entry.action] ? entry.detail : entry.action),
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
let shown = Number.isNaN(sinceTime)
|
|
95
|
+
? lines
|
|
96
|
+
: lines.filter((l) => l.timestamp.getTime() >= sinceTime)
|
|
97
|
+
shown = shown.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
|
|
98
|
+
if (ctx.url.searchParams.get('orderDirection') === 'Ascending') shown = shown.reverse()
|
|
99
|
+
return Response.json({
|
|
100
|
+
total: shown.length,
|
|
101
|
+
items: shown.slice(page.skip, page.skip + page.take).map((line) => ({
|
|
102
|
+
user: { id: line.user ?? fallbackUser },
|
|
103
|
+
timestamp: line.timestamp.toISOString(),
|
|
104
|
+
logType: line.logType,
|
|
105
|
+
comment: line.comment,
|
|
106
|
+
parameters: null,
|
|
107
|
+
})),
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function registerAuditLogHandlers(
|
|
112
|
+
router: ManagementApiRouter,
|
|
113
|
+
sources: {
|
|
114
|
+
documents?: VersionSource
|
|
115
|
+
media?: VersionSource
|
|
116
|
+
elements?: VersionSource
|
|
117
|
+
blueprints?: VersionSource
|
|
118
|
+
},
|
|
119
|
+
history: AuditHistoryPort | undefined,
|
|
120
|
+
): void {
|
|
121
|
+
const { documents, media, elements, blueprints } = sources
|
|
122
|
+
if (documents)
|
|
123
|
+
router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, documents, history))
|
|
124
|
+
if (media) router.handle('GetMediaByIdAuditLog', (ctx) => auditLogResponse(ctx, media, history))
|
|
125
|
+
if (elements)
|
|
126
|
+
router.handle('GetElementByIdAuditLog', (ctx) => auditLogResponse(ctx, elements, history))
|
|
127
|
+
if (blueprints)
|
|
128
|
+
router.handle('GetDocumentBlueprintByIdAuditLog', (ctx) =>
|
|
129
|
+
auditLogResponse(ctx, blueprints, history),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
@@ -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,12 @@ import type {
|
|
|
7
7
|
DocumentTreeItem,
|
|
8
8
|
DocumentValidationError,
|
|
9
9
|
DocumentValue,
|
|
10
|
-
DocumentVersionSummary,
|
|
11
10
|
} from '@crombolo/core'
|
|
12
11
|
import {
|
|
12
|
+
DocumentPermissions,
|
|
13
13
|
invalidSkipTake,
|
|
14
14
|
notFound,
|
|
15
|
+
permissionsForPath,
|
|
15
16
|
problemDetails,
|
|
16
17
|
problemResponse,
|
|
17
18
|
ROOT_ACCESS,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
type TreeSource,
|
|
25
26
|
visibleChildren,
|
|
26
27
|
visibleRoot,
|
|
28
|
+
visibleSearch,
|
|
27
29
|
visibleSiblings,
|
|
28
30
|
} from './start-node-trees.ts'
|
|
29
31
|
|
|
@@ -385,11 +387,28 @@ export function registerDocumentHandlers(
|
|
|
385
387
|
const page = paging(ctx)
|
|
386
388
|
if (!page) return problemResponse(invalidSkipTake())
|
|
387
389
|
const query = ctx.url.searchParams.get('query') ?? ''
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
390
|
+
const groups = ctx.principal?.groups ?? []
|
|
391
|
+
// Browse is checked per item only when some group could withhold it somewhere.
|
|
392
|
+
const browsesEverything =
|
|
393
|
+
!ctx.principal ||
|
|
394
|
+
groups.some(
|
|
395
|
+
(group) =>
|
|
396
|
+
group.defaults.includes(DocumentPermissions.Read) &&
|
|
397
|
+
[...group.granular.values()].every((verbs) => verbs.includes(DocumentPermissions.Read)),
|
|
398
|
+
)
|
|
399
|
+
const result = await visibleSearch(
|
|
400
|
+
documentStart(ctx),
|
|
401
|
+
browsesEverything
|
|
402
|
+
? undefined
|
|
403
|
+
: (chain) => permissionsForPath(groups, chain).has(DocumentPermissions.Read),
|
|
404
|
+
(paging) =>
|
|
405
|
+
port.search(query, {
|
|
406
|
+
...paging,
|
|
407
|
+
trashed: ctx.url.searchParams.get('trashed') === 'true',
|
|
408
|
+
parentKey: ctx.url.searchParams.get('parentId'),
|
|
409
|
+
}),
|
|
410
|
+
page,
|
|
411
|
+
)
|
|
393
412
|
return Response.json({ total: result.total, items: result.items.map(toDocumentItemResponse) })
|
|
394
413
|
})
|
|
395
414
|
|
|
@@ -849,7 +868,6 @@ export function registerDocumentHandlers(
|
|
|
849
868
|
})
|
|
850
869
|
|
|
851
870
|
/** The page's history, from its versions: who saved, published or rolled back, and when. */
|
|
852
|
-
router.handle('GetDocumentByIdAuditLog', (ctx) => auditLogResponse(ctx, port))
|
|
853
871
|
|
|
854
872
|
router.handle('GetDocumentVersion', async (ctx) => {
|
|
855
873
|
const documentId = ctx.url.searchParams.get('documentId')
|
|
@@ -922,40 +940,3 @@ export function registerDocumentHandlers(
|
|
|
922
940
|
})
|
|
923
941
|
})
|
|
924
942
|
}
|
|
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
|
-
}
|
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,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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/index.ts
CHANGED
package/src/ports.ts
CHANGED
|
@@ -111,6 +111,54 @@ export interface PublishedCachePort {
|
|
|
111
111
|
isRebuilding(): boolean
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/** An index as the Examine dashboard lists it. */
|
|
115
|
+
export interface SearchIndexInfo {
|
|
116
|
+
name: string
|
|
117
|
+
searcherName: string
|
|
118
|
+
documentCount: number
|
|
119
|
+
fieldCount: number
|
|
120
|
+
/** Items waiting to be indexed; while any are, the index reports itself rebuilding. */
|
|
121
|
+
pending: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An item's entries in the audit log, for its Info tab. docs/audit-log.md. */
|
|
125
|
+
export interface AuditHistoryPort {
|
|
126
|
+
forItem(
|
|
127
|
+
key: string,
|
|
128
|
+
): Promise<
|
|
129
|
+
Array<{ userKey: string | null; occurredAt: Date; action: string; detail: string | null }>
|
|
130
|
+
>
|
|
131
|
+
/** When the log began; an item's versions from before then stand in for its entries. */
|
|
132
|
+
startedAt(): Promise<Date | undefined>
|
|
133
|
+
/** Entries older than this have been aged out, and versions standing in for them are too. */
|
|
134
|
+
retainedSince(): Date | undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The backoffice search index behind the Examine dashboard. docs/search.md. */
|
|
138
|
+
export interface SearchIndexPort {
|
|
139
|
+
indexes(): Promise<SearchIndexInfo[]>
|
|
140
|
+
index(name: string): Promise<SearchIndexInfo | undefined>
|
|
141
|
+
/** False when there is no index by that name. */
|
|
142
|
+
rebuild(name: string): Promise<boolean>
|
|
143
|
+
searchers(): string[]
|
|
144
|
+
/** Undefined when there is no searcher by that name. */
|
|
145
|
+
query(
|
|
146
|
+
searcherName: string,
|
|
147
|
+
term: string,
|
|
148
|
+
paging: { skip: number; take: number },
|
|
149
|
+
): Promise<
|
|
150
|
+
| {
|
|
151
|
+
total: number
|
|
152
|
+
items: Array<{
|
|
153
|
+
key: string
|
|
154
|
+
score: number
|
|
155
|
+
fields: Array<{ name: string; values: string[] }>
|
|
156
|
+
}>
|
|
157
|
+
}
|
|
158
|
+
| undefined
|
|
159
|
+
>
|
|
160
|
+
}
|
|
161
|
+
|
|
114
162
|
/** Embed markup for a URL, from its oEmbed provider. */
|
|
115
163
|
export interface OEmbedPort {
|
|
116
164
|
markup(
|
|
@@ -213,6 +261,8 @@ export interface ManagementApiDeps {
|
|
|
213
261
|
logViewer?: LogViewerPort
|
|
214
262
|
modelsBuilder?: ModelsBuilderPort
|
|
215
263
|
publishedCache?: PublishedCachePort
|
|
264
|
+
searchIndex?: SearchIndexPort
|
|
265
|
+
auditHistory?: AuditHistoryPort
|
|
216
266
|
/** The template editor's query builder. */
|
|
217
267
|
templateQuery?: TemplateQueryPort
|
|
218
268
|
memberGroups?: MemberGroupPort
|
package/src/router.ts
CHANGED
|
@@ -14,6 +14,15 @@ import {
|
|
|
14
14
|
type StartNodes,
|
|
15
15
|
unauthorized,
|
|
16
16
|
} from '@crombolo/core'
|
|
17
|
+
import {
|
|
18
|
+
type AuditPlan,
|
|
19
|
+
type AuditRecord,
|
|
20
|
+
type AuditSink,
|
|
21
|
+
auditDrafts,
|
|
22
|
+
auditNamesFirst,
|
|
23
|
+
auditPlanFor,
|
|
24
|
+
auditReadsBody,
|
|
25
|
+
} from './audit.ts'
|
|
17
26
|
import { authorize, LOCAL_LOGIN_OPERATIONS, type NodeLookup } from './authorization.ts'
|
|
18
27
|
import { type ServerEvent, serverEventFor } from './server-events.ts'
|
|
19
28
|
|
|
@@ -75,8 +84,24 @@ export interface RouterOptions {
|
|
|
75
84
|
nodes?: NodeLookup
|
|
76
85
|
/** Whether users may sign in with a password, which opens the password-reset endpoints to everyone. */
|
|
77
86
|
allowLocalLogin?: boolean
|
|
87
|
+
/** Where every successful change is recorded. docs/audit-log.md. */
|
|
88
|
+
audit?: AuditSink
|
|
89
|
+
/** Where a request came from, for its audit entry. */
|
|
90
|
+
clientAddress?: (request: Request) => ClientAddress
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ClientAddress {
|
|
94
|
+
/** The peer that connected: a proxy, when there is one. */
|
|
95
|
+
ipAddress: string | null
|
|
96
|
+
/** What a proxy said the client was; anyone can send it, so it is kept apart. */
|
|
97
|
+
forwardedFor: string | null
|
|
78
98
|
}
|
|
79
99
|
|
|
100
|
+
const forwardedOnly = (request: Request): ClientAddress => ({
|
|
101
|
+
ipAddress: null,
|
|
102
|
+
forwardedFor: request.headers.get('x-forwarded-for'),
|
|
103
|
+
})
|
|
104
|
+
|
|
80
105
|
export type OperationHandler = (ctx: RequestContext) => Response | Promise<Response>
|
|
81
106
|
|
|
82
107
|
interface CompiledRoute {
|
|
@@ -95,6 +120,8 @@ export class ManagementApiRouter {
|
|
|
95
120
|
readonly #onServerEvent: RouterOptions['onServerEvent']
|
|
96
121
|
readonly #nodes: NodeLookup | undefined
|
|
97
122
|
readonly #allowLocalLogin: boolean
|
|
123
|
+
readonly #audit: AuditSink | undefined
|
|
124
|
+
readonly #clientAddress: (request: Request) => ClientAddress
|
|
98
125
|
|
|
99
126
|
constructor(options: RouterOptions = {}) {
|
|
100
127
|
const operations = options.operations ?? listOperations(loadSpec())
|
|
@@ -104,6 +131,8 @@ export class ManagementApiRouter {
|
|
|
104
131
|
this.#onServerEvent = options.onServerEvent
|
|
105
132
|
this.#nodes = options.nodes
|
|
106
133
|
this.#allowLocalLogin = options.allowLocalLogin ?? true
|
|
134
|
+
this.#audit = options.audit
|
|
135
|
+
this.#clientAddress = options.clientAddress ?? forwardedOnly
|
|
107
136
|
for (const operation of operations) {
|
|
108
137
|
const segments = operation.path.split('/').filter((s) => s.length > 0)
|
|
109
138
|
const paramNames = segments.map((s) =>
|
|
@@ -201,9 +230,13 @@ export class ManagementApiRouter {
|
|
|
201
230
|
|
|
202
231
|
const handler = this.#handlers.get(operation.operationId)
|
|
203
232
|
if (!handler) this.#onNotImplemented?.(operation, request)
|
|
233
|
+
const plan = handler && this.#audit ? auditPlanFor(operation.operationId) : undefined
|
|
234
|
+
const audit = plan ? await this.#beforeAudit(plan, params, request) : undefined
|
|
204
235
|
const response = handler
|
|
205
236
|
? await handler({ request, url, params, operation, notifications, principal })
|
|
206
237
|
: problemResponse(notImplemented(operation.operationId))
|
|
238
|
+
if (plan && audit && response.status >= 200 && response.status < 300)
|
|
239
|
+
await this.#recordAudit(plan, audit, params, request, response, principal)
|
|
207
240
|
if (handler && this.#onServerEvent) {
|
|
208
241
|
const event = serverEventFor(operation, params, response)
|
|
209
242
|
if (event) this.#onServerEvent(event)
|
|
@@ -213,4 +246,76 @@ export class ManagementApiRouter {
|
|
|
213
246
|
headers.set('cache-control', 'no-store')
|
|
214
247
|
return new Response(response.body, { status: response.status, headers })
|
|
215
248
|
}
|
|
249
|
+
|
|
250
|
+
/** Reads what the entry needs before the handler consumes the body or removes the item. */
|
|
251
|
+
async #beforeAudit(
|
|
252
|
+
plan: AuditPlan,
|
|
253
|
+
params: Readonly<Record<string, string>>,
|
|
254
|
+
request: Request,
|
|
255
|
+
): Promise<AuditState> {
|
|
256
|
+
const body = auditReadsBody(plan)
|
|
257
|
+
? await request
|
|
258
|
+
.clone()
|
|
259
|
+
.json()
|
|
260
|
+
.catch(() => undefined)
|
|
261
|
+
: undefined
|
|
262
|
+
const named = new Map<string, Subject>()
|
|
263
|
+
if (auditNamesFirst(plan) && this.#audit)
|
|
264
|
+
for (const draft of auditDrafts(plan, params, new Response(), body))
|
|
265
|
+
if (draft.entityKey)
|
|
266
|
+
named.set(draft.entityKey, await this.#audit.describe(draft.entityType, draft.entityKey))
|
|
267
|
+
return { body, named }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async #recordAudit(
|
|
271
|
+
plan: AuditPlan,
|
|
272
|
+
state: AuditState,
|
|
273
|
+
params: Readonly<Record<string, string>>,
|
|
274
|
+
request: Request,
|
|
275
|
+
response: Response,
|
|
276
|
+
principal: Principal | undefined,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
const sink = this.#audit
|
|
279
|
+
if (!sink) return
|
|
280
|
+
const address = this.#clientAddress(request)
|
|
281
|
+
let detail: string | null = null
|
|
282
|
+
if (plan.related) {
|
|
283
|
+
const names: string[] = []
|
|
284
|
+
for (const key of plan.related.keys(state.body))
|
|
285
|
+
names.push((await sink.describe(plan.related.entityType, key)).entityName ?? key)
|
|
286
|
+
detail = `${plan.related.label}: ${names.length > 0 ? names.join(', ') : 'none'}`
|
|
287
|
+
}
|
|
288
|
+
const records: AuditRecord[] = []
|
|
289
|
+
for (const draft of auditDrafts(plan, params, response, state.body)) {
|
|
290
|
+
const key = draft.entityKey ?? (plan.entityType === 'User' ? (principal?.id ?? null) : null)
|
|
291
|
+
const subject = key
|
|
292
|
+
? (state.named.get(key) ?? (await sink.describe(draft.entityType, key)))
|
|
293
|
+
: undefined
|
|
294
|
+
// A reset link or an accepted invite is anonymous: the user it names is who acted.
|
|
295
|
+
const self = !principal && draft.entityType === 'User' && subject
|
|
296
|
+
records.push({
|
|
297
|
+
...draft,
|
|
298
|
+
entityType: subject?.entityType ?? draft.entityType,
|
|
299
|
+
entityKey: subject?.entityKey ?? key,
|
|
300
|
+
entityName: subject?.entityName ?? null,
|
|
301
|
+
detail,
|
|
302
|
+
userKey: principal?.id ?? (self ? subject.entityKey : null),
|
|
303
|
+
userName: principal?.name ?? (self ? subject.entityName : null),
|
|
304
|
+
ipAddress: address.ipAddress,
|
|
305
|
+
forwardedFor: address.forwardedFor,
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
await sink.record(records)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface Subject {
|
|
313
|
+
entityType: string
|
|
314
|
+
entityKey: string
|
|
315
|
+
entityName: string | null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
interface AuditState {
|
|
319
|
+
body: unknown
|
|
320
|
+
named: Map<string, Subject>
|
|
216
321
|
}
|
package/src/routes.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Composition root for the Management API. Handlers are registered here as they
|
|
3
3
|
* are implemented; every other operation in the contract answers 501.
|
|
4
4
|
*/
|
|
5
|
+
import { registerAuditLogHandlers } from './handlers/audit-log.ts'
|
|
5
6
|
import { registerContentTypeHandlers } from './handlers/content-type.ts'
|
|
6
7
|
import { registerDataTypeHandlers } from './handlers/data-type.ts'
|
|
7
8
|
import { registerDictionaryHandlers } from './handlers/dictionary.ts'
|
|
@@ -23,6 +24,7 @@ import { registerPublicAccessHandlers } from './handlers/public-access.ts'
|
|
|
23
24
|
import { registerPublishedCacheHandlers } from './handlers/published-cache.ts'
|
|
24
25
|
import { registerRedirectHandlers } from './handlers/redirects.ts'
|
|
25
26
|
import { registerReferenceHandlers } from './handlers/references.ts'
|
|
27
|
+
import { registerSearchIndexHandlers } from './handlers/search-index.ts'
|
|
26
28
|
import { registerServerHandlers } from './handlers/server.ts'
|
|
27
29
|
import { registerSettingsStubHandlers } from './handlers/settings-stubs.ts'
|
|
28
30
|
import { registerTemplateHandlers } from './handlers/template.ts'
|
|
@@ -39,6 +41,8 @@ export interface ManagementApiOptions {
|
|
|
39
41
|
onServerEvent?: RouterOptions['onServerEvent']
|
|
40
42
|
nodes?: RouterOptions['nodes']
|
|
41
43
|
allowLocalLogin?: boolean
|
|
44
|
+
audit?: RouterOptions['audit']
|
|
45
|
+
clientAddress?: RouterOptions['clientAddress']
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export function createManagementApiRouter(options: ManagementApiOptions = {}): ManagementApiRouter {
|
|
@@ -48,6 +52,8 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
48
52
|
onServerEvent: options.onServerEvent,
|
|
49
53
|
nodes: options.nodes,
|
|
50
54
|
allowLocalLogin: options.allowLocalLogin,
|
|
55
|
+
audit: options.audit,
|
|
56
|
+
clientAddress: options.clientAddress,
|
|
51
57
|
})
|
|
52
58
|
const { deps } = options
|
|
53
59
|
if (!deps) return router
|
|
@@ -84,11 +90,22 @@ export function createManagementApiRouter(options: ManagementApiOptions = {}): M
|
|
|
84
90
|
if (deps.logViewer) registerLogViewerHandlers(router, deps.logViewer)
|
|
85
91
|
if (deps.modelsBuilder) registerModelsBuilderHandlers(router, deps.modelsBuilder)
|
|
86
92
|
if (deps.publishedCache) registerPublishedCacheHandlers(router, deps.publishedCache)
|
|
93
|
+
if (deps.searchIndex) registerSearchIndexHandlers(router, deps.searchIndex)
|
|
87
94
|
if (deps.temporaryFiles) registerImportHandlers(router, deps.temporaryFiles)
|
|
88
95
|
if (deps.memberGroups) registerMemberGroupHandlers(router, deps.memberGroups)
|
|
89
96
|
if (deps.members) registerMemberHandlers(router, deps.members)
|
|
90
97
|
if (deps.publicAccess) registerPublicAccessHandlers(router, deps.publicAccess)
|
|
91
98
|
if (deps.elements) registerElementHandlers(router, deps.elements, deps.references)
|
|
99
|
+
registerAuditLogHandlers(
|
|
100
|
+
router,
|
|
101
|
+
{
|
|
102
|
+
documents: deps.documents,
|
|
103
|
+
media: deps.media,
|
|
104
|
+
elements: deps.elements,
|
|
105
|
+
blueprints: deps.blueprints,
|
|
106
|
+
},
|
|
107
|
+
deps.auditHistory,
|
|
108
|
+
)
|
|
92
109
|
if (deps.redirects) registerRedirectHandlers(router, deps.redirects)
|
|
93
110
|
if (deps.packages) registerPackageHandlers(router, deps.packages)
|
|
94
111
|
if (deps.references)
|