@crombolo/content 0.26.0 → 0.27.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@crombolo/content",
3
- "version": "0.26.0",
4
- "description": "The rules every document write follows, whoever starts it: who may, approvals, and what it announces. No transport.",
3
+ "version": "0.27.0",
4
+ "description": "The rules every page and media write follows, whoever starts it: who may, where it may go, approvals, and what it announces. No transport.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
7
7
  "homepage": "https://github.com/kineco-au/crombolo-cms#readme",
@@ -29,7 +29,7 @@
29
29
  "access": "public"
30
30
  },
31
31
  "dependencies": {
32
- "@crombolo/core": "0.26.0",
33
- "@crombolo/data": "0.26.0"
32
+ "@crombolo/core": "0.27.0",
33
+ "@crombolo/data": "0.27.0"
34
34
  }
35
35
  }
package/src/actor.ts CHANGED
@@ -15,7 +15,7 @@ export interface ContentPrincipal {
15
15
  allowedSections: readonly string[]
16
16
  hasAccessToAllLanguages: boolean
17
17
  languages: readonly string[]
18
- startNodes: { document: StartNodes }
18
+ startNodes: { document: StartNodes; media: StartNodes }
19
19
  groups: readonly GroupGrant[]
20
20
  }
21
21
 
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
+ export * from './media-operations.ts'
1
2
  export * from './operations.ts'
2
3
  export * from './rules.ts'
@@ -0,0 +1,554 @@
1
+ /**
2
+ * Every media write, once (story 165), as `operations.ts` is for pages: the
3
+ * backoffice and the command line call these, so a write follows the same rules
4
+ * whoever starts it — the actor's permissions, where an item's type may go, and
5
+ * one transaction holding the change, its audit entry, its webhook event and the
6
+ * cache instruction. Files go after the rows they belong to have.
7
+ */
8
+ import {
9
+ type DocumentValue,
10
+ mediaFileOf,
11
+ normaliseUuid,
12
+ ObjectTypes,
13
+ SystemNodes,
14
+ } from '@crombolo/core'
15
+ import {
16
+ appendCacheInstruction,
17
+ ContentTypeRepository,
18
+ type Db,
19
+ DocumentRepository,
20
+ type DocumentRepositoryOptions,
21
+ InvalidParentError,
22
+ NodeRepository,
23
+ type NodeRow,
24
+ WriteRejectedError,
25
+ } from '@crombolo/data'
26
+ import { type Actor, actorName, actorUserKey } from './actor.ts'
27
+ import type { Refusal, Result, SelectionResult } from './operations.ts'
28
+ import { mayChangeMedia } from './policy.ts'
29
+
30
+ /** A media item as a save sends it. */
31
+ export interface MediaInput {
32
+ key: string
33
+ contentTypeKey: string
34
+ parentKey: string | null
35
+ values: DocumentValue[]
36
+ variants: Array<{ culture: string | null; segment: string | null; name: string }>
37
+ }
38
+
39
+ export type MediaEvent =
40
+ | { type: 'created'; key: string }
41
+ | { type: 'saved'; key: string }
42
+ | { type: 'trashed'; key: string }
43
+ /** `name` is read before it went, since it cannot be read after. */
44
+ | { type: 'deleted'; key: string; name: string | null }
45
+ | { type: 'restored'; key: string }
46
+ | { type: 'binEmptied' }
47
+ | { type: 'moved'; key: string }
48
+ /** `parentKey` null: the top level. */
49
+ | { type: 'sorted'; parentKey: string | null }
50
+
51
+ /** Where a media operation records what it did, inside the change's transaction. */
52
+ export interface MediaEventSink {
53
+ record(tx: Db, event: MediaEvent, actor: Actor): Promise<void>
54
+ afterCommit?(event: MediaEvent, actor: Actor): void
55
+ }
56
+
57
+ /** The audit entry a media event writes: the entries the backoffice has always written. Pure. */
58
+ export function mediaAuditFor(event: MediaEvent): {
59
+ category: 'media'
60
+ action: string
61
+ entityType: 'Media'
62
+ entityKey: string | null
63
+ } {
64
+ const entry = (action: string, key: string | null) => ({
65
+ category: 'media' as const,
66
+ action,
67
+ entityType: 'Media' as const,
68
+ entityKey: key,
69
+ })
70
+ switch (event.type) {
71
+ case 'created':
72
+ return entry('Create', event.key)
73
+ case 'saved':
74
+ return entry('Save', event.key)
75
+ case 'trashed':
76
+ return entry('Trash', event.key)
77
+ case 'deleted':
78
+ return entry('Delete', event.key)
79
+ case 'restored':
80
+ return entry('Restore', event.key)
81
+ case 'binEmptied':
82
+ return entry('EmptyRecycleBin', null)
83
+ case 'moved':
84
+ return entry('Move', event.key)
85
+ case 'sorted':
86
+ return entry('Sort', event.parentKey)
87
+ }
88
+ }
89
+
90
+ /** The webhook a media event raises, by Umbraco's alias. Pure. */
91
+ export function mediaWebhookFor(event: MediaEvent): { alias: string; key: string } | undefined {
92
+ switch (event.type) {
93
+ case 'created':
94
+ case 'saved':
95
+ return { alias: 'Umbraco.MediaSave', key: event.key }
96
+ case 'deleted':
97
+ return { alias: 'Umbraco.MediaDelete', key: event.key }
98
+ default:
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ /** The whole audit entry a media event writes, with who acted and from where. Pure. */
104
+ export function mediaAuditEntryFor(event: MediaEvent, actor: Actor) {
105
+ return {
106
+ ...mediaAuditFor(event),
107
+ source: actor.origin.source,
108
+ userKey: actorUserKey(actor),
109
+ userName: actorName(actor),
110
+ ipAddress: actor.origin.ipAddress ?? null,
111
+ forwardedFor: actor.origin.forwardedFor ?? null,
112
+ detail: null,
113
+ }
114
+ }
115
+
116
+ export interface MediaOperationsOptions {
117
+ db: Db
118
+ repository?: Omit<DocumentRepositoryOptions, 'kind'>
119
+ events: MediaEventSink
120
+ /** Where an item's file goes when the item is deleted for good. */
121
+ files?: { remove(path: string): Promise<void> }
122
+ /** Told after every committed change, for a cache held in this process. */
123
+ onChange?(): void | Promise<void>
124
+ /** Errors a value's intake throws that are the sender's to fix, as the reason to give. */
125
+ refusalReason?(error: unknown): string | undefined
126
+ }
127
+
128
+ const NOT_FOUND = 'The media item could not be found.'
129
+
130
+ const refuse = (kind: Refusal['kind'], status: number, reason: string): Refusal => ({
131
+ ok: false,
132
+ kind,
133
+ status,
134
+ reason,
135
+ })
136
+
137
+ function refusalFrom(error: unknown, extra?: (error: unknown) => string | undefined): Refusal {
138
+ if (error instanceof WriteRejectedError) return refuse('conflict', 409, error.message)
139
+ if (error instanceof InvalidParentError) return refuse('invalid', 400, error.message)
140
+ const reason = extra?.(error)
141
+ if (reason) return refuse('invalid', 400, reason)
142
+ throw error
143
+ }
144
+
145
+ export type MediaOperations = ReturnType<typeof createMediaOperations>
146
+
147
+ export function createMediaOperations(options: MediaOperationsOptions) {
148
+ const { db, events } = options
149
+ const repo = new DocumentRepository(db, { ...options.repository, kind: 'media' })
150
+ const nodes = new NodeRepository(db)
151
+ const types = new ContentTypeRepository(db, { kind: 'media' })
152
+ const nodeId = options.repository?.nodeId
153
+ const changed = async () => {
154
+ await options.onChange?.()
155
+ }
156
+
157
+ /** The change, its events and the cache instruction in one transaction (stories 171, 174). */
158
+ const commit = async <T>(
159
+ actor: Actor,
160
+ write: (tx: DocumentRepository, txDb: Db) => Promise<T>,
161
+ announce: (result: T) => readonly MediaEvent[],
162
+ ): Promise<T> => {
163
+ const recorded: MediaEvent[] = []
164
+ const result = await repo.atomically(async (tx, txDb) => {
165
+ const written = await write(tx, txDb)
166
+ const said = announce(written)
167
+ if (said.length > 0)
168
+ await appendCacheInstruction(txDb, {
169
+ kind: 'content',
170
+ payload: { media: true },
171
+ by: nodeId,
172
+ })
173
+ for (const event of said) {
174
+ await events.record(txDb, event, actor)
175
+ recorded.push(event)
176
+ }
177
+ return written
178
+ })
179
+ for (const event of recorded) events.afterCommit?.(event, actor)
180
+ if (recorded.length > 0) await changed()
181
+ return result
182
+ }
183
+
184
+ const userIdOf = async (actor: Actor): Promise<number | undefined> => {
185
+ if (actor.kind !== 'user') return undefined
186
+ const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
187
+ actor.principal.id,
188
+ ])
189
+ return rows[0] ? Number(rows[0].id) : undefined
190
+ }
191
+
192
+ /** A media item and its media ancestors' keys, root first. */
193
+ const placeOf = async (key: string) => {
194
+ const node = await nodes.byKey(key)
195
+ if (!node || node.objectType !== ObjectTypes.Media) return undefined
196
+ const ids = node.path
197
+ .split(',')
198
+ .map(Number)
199
+ .filter((id) => id > 0)
200
+ const byId = new Map((await nodes.byIds(ids)).map((row) => [row.id, row]))
201
+ const chain = ids.flatMap((id) => {
202
+ const row = byId.get(id)
203
+ return row && row.objectType === ObjectTypes.Media ? [row.key] : []
204
+ })
205
+ return { node, chain }
206
+ }
207
+
208
+ /** Whether the actor may change media at `key`, the root or the bin; only a user is checked. */
209
+ const permits = async (actor: Actor, key: string | 'root' | 'bin'): Promise<boolean> => {
210
+ if (actor.kind !== 'user') return true
211
+ if (key === 'root' || key === 'bin') return mayChangeMedia(actor.principal, key)
212
+ const place = await placeOf(key)
213
+ if (!place) return mayChangeMedia(actor.principal, [])
214
+ return mayChangeMedia(actor.principal, place.chain, place.node.trashed)
215
+ }
216
+ const forbidden = () => refuse('forbidden', 403, 'You may not do that to this media item.')
217
+
218
+ /** Where an item of this type may go; the system acts on what was already decided. */
219
+ const placement = async (actor: Actor, typeKey: string, parentKey: string | null) =>
220
+ actor.kind === 'system' ? undefined : types.placementRefusal(typeKey, parentKey)
221
+ const typeOf = async (key: string): Promise<string | undefined> => {
222
+ const rows = await db.query<{ unique_id: string }>(
223
+ `SELECT t.unique_id FROM node n JOIN content c ON c.node_id = n.id
224
+ JOIN node t ON t.id = c.content_type_id WHERE n.unique_id = ?`,
225
+ [normaliseUuid(key)],
226
+ )
227
+ return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
228
+ }
229
+
230
+ /** The files a branch holds, read before its rows go. */
231
+ const filesIn = async (root: NodeRow): Promise<string[]> => {
232
+ const rows = await db.query<{ unique_id: string }>(
233
+ 'SELECT unique_id FROM node WHERE (id = ? OR path LIKE ?) AND node_object_type = ?',
234
+ [root.id, `${root.path},%`, ObjectTypes.Media],
235
+ )
236
+ const found: string[] = []
237
+ for (const row of rows) {
238
+ const media = await repo.byKey(normaliseUuid(String(row.unique_id)))
239
+ const file = media ? mediaFileOf(media) : null
240
+ if (file) found.push(file)
241
+ }
242
+ return found
243
+ }
244
+ const removeFiles = async (paths: readonly string[]) => {
245
+ for (const path of paths) await options.files?.remove(path)
246
+ }
247
+
248
+ /** Every item checked before any is written: the reasons each one cannot be moved there. */
249
+ const moveBlockers = async (
250
+ actor: Actor,
251
+ targets: readonly NodeRow[],
252
+ target: NodeRow | undefined,
253
+ ) => {
254
+ const blocked: Array<{ key: string; reasons: string[] }> = []
255
+ for (const node of targets) {
256
+ const reasons: string[] = []
257
+ if (target && (target.id === node.id || `${target.path},`.startsWith(`${node.path},`)))
258
+ reasons.push('A media item cannot move below itself.')
259
+ const type = await typeOf(node.key)
260
+ const misplaced = type ? await placement(actor, type, target?.key ?? null) : undefined
261
+ if (misplaced) reasons.push(misplaced)
262
+ if (reasons.length > 0) blocked.push({ key: node.key, reasons })
263
+ }
264
+ return blocked
265
+ }
266
+
267
+ /** The items the actor may not change, as one refusal naming each. */
268
+ const deniedOf = async (
269
+ actor: Actor,
270
+ found: readonly NodeRow[],
271
+ reason: string,
272
+ ): Promise<(Refusal & { blocked: Array<{ key: string; reasons: string[] }> }) | undefined> => {
273
+ const blocked: Array<{ key: string; reasons: string[] }> = []
274
+ for (const node of found)
275
+ if (!(await permits(actor, node.key))) blocked.push({ key: node.key, reasons: [reason] })
276
+ if (blocked.length === 0) return undefined
277
+ return {
278
+ ...refuse(
279
+ 'forbidden',
280
+ 403,
281
+ `You may not change ${blocked.length} of ${found.length} item(s), so nothing was changed.`,
282
+ ),
283
+ blocked,
284
+ }
285
+ }
286
+
287
+ const nodesFor = async (keys: readonly string[]): Promise<NodeRow[] | Refusal> => {
288
+ const out: NodeRow[] = []
289
+ for (const key of keys) {
290
+ const node = await nodes.byKey(key)
291
+ if (!node || node.objectType !== ObjectTypes.Media) return refuse('notFound', 404, NOT_FOUND)
292
+ if (!out.some((seen) => seen.id === node.id)) out.push(node)
293
+ }
294
+ return out
295
+ }
296
+
297
+ const operations = {
298
+ async create(actor: Actor, input: MediaInput): Promise<Result<{ key: string }>> {
299
+ if (!(await permits(actor, input.parentKey ?? 'root'))) return forbidden()
300
+ const misplaced = await placement(actor, input.contentTypeKey, input.parentKey)
301
+ if (misplaced) return refuse('invalid', 400, misplaced)
302
+ try {
303
+ const userId = await userIdOf(actor)
304
+ const created = await commit(
305
+ actor,
306
+ (tx) => tx.create({ ...input, componentKey: null, userId }),
307
+ (doc) => [{ type: 'created', key: doc.key }],
308
+ )
309
+ return { ok: true, key: created.key }
310
+ } catch (error) {
311
+ return refusalFrom(error, options.refusalReason)
312
+ }
313
+ },
314
+
315
+ /** Saves an item's values and names; its type and place stay as they are. */
316
+ async save(actor: Actor, key: string, input: Omit<MediaInput, 'key'>): Promise<Result> {
317
+ const before = await repo.byKey(key)
318
+ if (!before) return refuse('notFound', 404, NOT_FOUND)
319
+ if (!(await permits(actor, key))) return forbidden()
320
+ try {
321
+ const userId = await userIdOf(actor)
322
+ const updated = await commit(
323
+ actor,
324
+ (tx) =>
325
+ tx.update({
326
+ ...input,
327
+ key,
328
+ contentTypeKey: before.contentTypeKey,
329
+ parentKey: before.parentKey,
330
+ componentKey: null,
331
+ userId,
332
+ }),
333
+ (doc) => (doc ? [{ type: 'saved', key }] : []),
334
+ )
335
+ if (!updated) return refuse('notFound', 404, NOT_FOUND)
336
+ // A replaced file is no longer anyone's: Umbraco deletes it too.
337
+ const was = mediaFileOf(before)
338
+ if (was && was !== mediaFileOf(updated)) await removeFiles([was])
339
+ return { ok: true }
340
+ } catch (error) {
341
+ return refusalFrom(error, options.refusalReason)
342
+ }
343
+ },
344
+
345
+ async trash(actor: Actor, key: string): Promise<Result> {
346
+ const result = await operations.trashMany(actor, [key])
347
+ return result.ok ? { ok: true } : result
348
+ },
349
+
350
+ /**
351
+ * Moves several items to the recycle bin in one transaction, every one
352
+ * checked first. One already there is unchanged; one below another in the
353
+ * selection goes with it.
354
+ */
355
+ async trashMany(
356
+ actor: Actor,
357
+ keys: readonly string[],
358
+ many: { dryRun?: boolean } = {},
359
+ ): Promise<SelectionResult> {
360
+ const found = await nodesFor(keys)
361
+ if (!Array.isArray(found)) return found
362
+ const denied = await deniedOf(actor, found, 'You may not delete this media item.')
363
+ if (denied) return denied
364
+ const covered = (node: NodeRow) =>
365
+ found.some((other) => other.id !== node.id && `${node.path},`.startsWith(`${other.path},`))
366
+ const writes = found.filter((node) => !node.trashed && !covered(node))
367
+ const unchanged = found.filter((node) => node.trashed).map((node) => node.key)
368
+ const changedKeys = found.filter((node) => !node.trashed).map((node) => node.key)
369
+ if (many.dryRun || writes.length === 0) return { ok: true, changed: changedKeys, unchanged }
370
+ await commit(
371
+ actor,
372
+ async (tx) => {
373
+ const out: string[] = []
374
+ for (const node of writes) if (await tx.moveToRecycleBin(node.key)) out.push(node.key)
375
+ return out
376
+ },
377
+ (done) => done.map((key): MediaEvent => ({ type: 'trashed', key })),
378
+ )
379
+ return { ok: true, changed: changedKeys, unchanged }
380
+ },
381
+
382
+ /** Deletes an item and its branch for good, with their files; `fromBin` only one in the bin. */
383
+ async delete(actor: Actor, key: string, deleting: { fromBin?: boolean } = {}): Promise<Result> {
384
+ const node = await nodes.byKey(key)
385
+ if (!node || node.objectType !== ObjectTypes.Media) return refuse('notFound', 404, NOT_FOUND)
386
+ if (deleting.fromBin && !node.trashed)
387
+ return refuse('invalid', 400, 'The media item is not in the recycle bin')
388
+ if (!(await permits(actor, deleting.fromBin ? 'bin' : key))) return forbidden()
389
+ const paths = await filesIn(node)
390
+ const removed = await commit(
391
+ actor,
392
+ (tx) => tx.delete(key),
393
+ (done) => (done ? [{ type: 'deleted', key, name: node.text ?? null }] : []),
394
+ )
395
+ if (!removed) return refuse('notFound', 404, NOT_FOUND)
396
+ await removeFiles(paths)
397
+ return { ok: true }
398
+ },
399
+
400
+ async emptyBin(actor: Actor): Promise<Result<{ deleted: number }>> {
401
+ if (!(await permits(actor, 'bin'))) return forbidden()
402
+ const roots = await repo.trashedRoots()
403
+ const paths: string[] = []
404
+ for (const node of roots) paths.push(...(await filesIn(node)))
405
+ const gone = await commit(
406
+ actor,
407
+ async (tx) => {
408
+ const out: Array<{ key: string; name: string | null }> = []
409
+ for (const node of roots)
410
+ if (await tx.delete(node.key)) out.push({ key: node.key, name: node.text ?? null })
411
+ return out
412
+ },
413
+ (done): MediaEvent[] => [
414
+ ...done.map((node): MediaEvent => ({ type: 'deleted', ...node })),
415
+ { type: 'binEmptied' },
416
+ ],
417
+ )
418
+ await removeFiles(paths)
419
+ return { ok: true, deleted: gone.length }
420
+ },
421
+
422
+ async restore(
423
+ actor: Actor,
424
+ key: string,
425
+ targetKey: string | null | undefined,
426
+ ): Promise<Result> {
427
+ if (!(await permits(actor, 'bin'))) return forbidden()
428
+ if (targetKey !== undefined) {
429
+ const type = await typeOf(key)
430
+ const misplaced = type ? await placement(actor, type, targetKey) : undefined
431
+ if (misplaced) return refuse('invalid', 400, misplaced)
432
+ }
433
+ const result = await commit(
434
+ actor,
435
+ (tx) => tx.restore(key, targetKey),
436
+ (done) => (done === 'restored' ? [{ type: 'restored', key }] : []),
437
+ )
438
+ if (result === 'notFound')
439
+ return refuse('notFound', 404, 'The media item is not in the recycle bin.')
440
+ if (result === 'invalid')
441
+ return refuse('invalid', 400, 'The restore target does not exist or is in the recycle bin.')
442
+ return { ok: true }
443
+ },
444
+
445
+ async move(actor: Actor, key: string, targetKey: string | null): Promise<Result> {
446
+ const result = await operations.moveMany(actor, [key], targetKey)
447
+ if (result.ok) return { ok: true }
448
+ const first = 'blocked' in result ? result.blocked?.[0]?.reasons[0] : undefined
449
+ return first ? { ...result, reason: first } : result
450
+ },
451
+
452
+ /**
453
+ * Moves several items under `targetKey` (null: the top level) in one
454
+ * transaction, every one checked first. One already there is unchanged.
455
+ */
456
+ async moveMany(
457
+ actor: Actor,
458
+ keys: readonly string[],
459
+ targetKey: string | null,
460
+ many: { dryRun?: boolean } = {},
461
+ ): Promise<SelectionResult> {
462
+ const found = await nodesFor(keys)
463
+ if (!Array.isArray(found)) return found
464
+ const target = targetKey ? await nodes.byKey(targetKey) : undefined
465
+ if (targetKey && (!target || target.objectType !== ObjectTypes.Media))
466
+ return refuse('notFound', 404, 'The media item or target could not be found.')
467
+ if (target?.trashed) return refuse('invalid', 400, "Media can't move into the recycle bin.")
468
+ if (!(await permits(actor, target?.key ?? 'root'))) return forbidden()
469
+ const denied = await deniedOf(actor, found, 'You may not move this media item.')
470
+ if (denied) return denied
471
+ const blocked = await moveBlockers(actor, found, target)
472
+ if (blocked.length > 0)
473
+ return {
474
+ ...refuse(
475
+ 'invalid',
476
+ 400,
477
+ `${blocked.length} of ${found.length} item(s) cannot be moved there, so nothing was moved.`,
478
+ ),
479
+ blocked,
480
+ }
481
+ const parentId = target?.id ?? SystemNodes.Root
482
+ const stays = (node: NodeRow) => !node.trashed && node.parentId === parentId
483
+ const writes = found.filter((node) => !stays(node))
484
+ const unchanged = found.filter(stays).map((node) => node.key)
485
+ if (many.dryRun || writes.length === 0)
486
+ return { ok: true, changed: writes.map((node) => node.key), unchanged }
487
+ try {
488
+ const done = await commit(
489
+ actor,
490
+ async (tx) => {
491
+ const out: string[] = []
492
+ for (const node of writes)
493
+ if ((await tx.move(node.key, targetKey)) === 'moved') out.push(node.key)
494
+ return out
495
+ },
496
+ (moved) => moved.map((key): MediaEvent => ({ type: 'moved', key })),
497
+ )
498
+ return { ok: true, changed: done, unchanged }
499
+ } catch (error) {
500
+ return refusalFrom(error, options.refusalReason)
501
+ }
502
+ },
503
+
504
+ async sort(
505
+ actor: Actor,
506
+ parentKey: string | null,
507
+ sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
508
+ sortOptions: { dryRun?: boolean } = {},
509
+ ): Promise<Result> {
510
+ if (!(await permits(actor, parentKey ?? 'root'))) return forbidden()
511
+ for (const item of sorting) if (!(await permits(actor, item.key))) return forbidden()
512
+ if (sortOptions.dryRun) {
513
+ const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
514
+ const children = await nodes.byKeys(sorting.map((item) => item.key))
515
+ const fits =
516
+ parentId !== undefined &&
517
+ children.length === sorting.length &&
518
+ children.every(
519
+ (node) => node.objectType === ObjectTypes.Media && node.parentId === parentId,
520
+ )
521
+ return fits
522
+ ? { ok: true }
523
+ : refuse('notFound', 404, 'The parent, or one of the items, could not be found under it.')
524
+ }
525
+ const result = await commit(
526
+ actor,
527
+ (tx) => tx.sort(parentKey, sorting),
528
+ (done) => (done === 'sorted' ? [{ type: 'sorted', parentKey }] : []),
529
+ )
530
+ return result === 'sorted'
531
+ ? { ok: true }
532
+ : refuse('notFound', 404, 'The parent, or one of the items, could not be found under it.')
533
+ },
534
+
535
+ async sortChildrenBy(
536
+ actor: Actor,
537
+ parentKey: string | null,
538
+ field: 'Name' | 'CreateDate' | 'UpdateDate',
539
+ direction: 'Ascending' | 'Descending',
540
+ ): Promise<Result> {
541
+ if (!(await permits(actor, parentKey ?? 'root'))) return forbidden()
542
+ const result = await commit(
543
+ actor,
544
+ (tx) => tx.sortChildrenBy(parentKey, field, direction),
545
+ (done) => (done === 'sorted' ? [{ type: 'sorted', parentKey }] : []),
546
+ )
547
+ return result === 'sorted' ? { ok: true } : refuse('notFound', 404, NOT_FOUND)
548
+ },
549
+
550
+ /** The repository these operations write through, for reads that go with them. */
551
+ repository: repo,
552
+ }
553
+ return operations
554
+ }
package/src/operations.ts CHANGED
@@ -7,12 +7,13 @@
7
7
  * The repository keeps the SQL, the locking and the publish rules that guard
8
8
  * every path (published ancestors, mandatory languages, filled mandatory fields).
9
9
  * What it does not decide — who may, and whether a page needs approval — is here.
10
- * docs/architecture.md, "Document writes".
10
+ * docs/architecture.md, "Content writes".
11
11
  */
12
12
  import type { DocumentValidationError, DocumentValue } from '@crombolo/core'
13
- import { normaliseUuid } from '@crombolo/core'
13
+ import { normaliseUuid, SystemNodes } from '@crombolo/core'
14
14
  import {
15
15
  appendCacheInstruction,
16
+ ContentTypeRepository,
16
17
  type Db,
17
18
  DocumentRepository,
18
19
  type DocumentRepositoryOptions,
@@ -76,6 +77,17 @@ export interface Refusal {
76
77
  export type Done<T extends object = object> = { ok: true } & T
77
78
  export type Result<T extends object = object> = Done<T> | Refusal
78
79
 
80
+ /** Why each item of a selection cannot be written; any one of them stops the whole selection. */
81
+ export type Blocked = Array<{ key: string; reasons: string[] }>
82
+
83
+ /**
84
+ * What a write over several items did: the items it changed (or, in a dry run,
85
+ * would change) and those already as asked; or, refused, each item's reasons.
86
+ */
87
+ export type SelectionResult =
88
+ | Done<{ changed: string[]; unchanged: string[] }>
89
+ | (Refusal & { blocked?: Blocked })
90
+
79
91
  /**
80
92
  * The approval workflows' say over a write (story 028). `who` is the user, or
81
93
  * undefined for a local operator, who has no publishing rights of their own.
@@ -183,6 +195,9 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
183
195
  const { db, events } = options
184
196
  const repo = new DocumentRepository(db, options.repository)
185
197
  const nodes = new NodeRepository(db)
198
+ const types = new ContentTypeRepository(db, {
199
+ kind: repo.kind === 'media' ? 'media' : 'document',
200
+ })
186
201
  const changed = () => options.onChange?.()
187
202
 
188
203
  /**
@@ -313,6 +328,159 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
313
328
  }))
314
329
  }
315
330
 
331
+ /** Where a page of this type may go; the system acts on what was already decided. */
332
+ const placement = async (actor: Actor, typeKey: string, parentKey: string | null) =>
333
+ actor.kind === 'system' || repo.kind === 'blueprint'
334
+ ? undefined
335
+ : types.placementRefusal(typeKey, parentKey)
336
+ const typeOf = async (key: string): Promise<string | undefined> => {
337
+ const rows = await db.query<{ unique_id: string }>(
338
+ `SELECT t.unique_id FROM node n JOIN content c ON c.node_id = n.id
339
+ JOIN node t ON t.id = c.content_type_id WHERE n.unique_id = ?`,
340
+ [normaliseUuid(key)],
341
+ )
342
+ return rows[0] ? normaliseUuid(String(rows[0].unique_id)) : undefined
343
+ }
344
+ const misplacedAt = async (actor: Actor, key: string, parentKey: string | null) => {
345
+ const type = await typeOf(key)
346
+ return type ? placement(actor, type, parentKey) : undefined
347
+ }
348
+
349
+ /** The selection's pages, each once, or the refusal for one that is not a page. */
350
+ const pagesFor = async (keys: readonly string[]): Promise<NodeRow[] | Refusal> => {
351
+ const out: NodeRow[] = []
352
+ for (const key of keys) {
353
+ const node = await nodes.byKey(key)
354
+ if (!node || node.objectType !== repo.objectType) return refuse('notFound', 404, NOT_FOUND)
355
+ if (!out.some((seen) => seen.id === node.id)) out.push(node)
356
+ }
357
+ return out
358
+ }
359
+
360
+ /** The pages of a selection the actor may not `kind`, as one refusal naming each. */
361
+ const deniedOf = async (
362
+ actor: Actor,
363
+ kind: WriteKind,
364
+ found: readonly NodeRow[],
365
+ ): Promise<(Refusal & { blocked: Blocked }) | undefined> => {
366
+ const blocked: Blocked = []
367
+ for (const node of found)
368
+ if (!(await permits(actor, kind, node.key)))
369
+ blocked.push({ key: node.key, reasons: ['You may not do that to this page.'] })
370
+ if (blocked.length === 0) return undefined
371
+ return {
372
+ ...refuse(
373
+ 'forbidden',
374
+ 403,
375
+ found.length === 1
376
+ ? 'You may not do that to this page.'
377
+ : `You may not change ${blocked.length} of ${found.length} page(s), so nothing was changed.`,
378
+ ),
379
+ blocked,
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Moves pages under `targetKey` (null: the top level) in one transaction,
385
+ * every one checked first; one already there is unchanged.
386
+ */
387
+ const moveMany = async (
388
+ actor: Actor,
389
+ keys: readonly string[],
390
+ targetKey: string | null,
391
+ many: { dryRun?: boolean } = {},
392
+ ): Promise<SelectionResult> => {
393
+ const found = await pagesFor(keys)
394
+ if (!Array.isArray(found)) return found
395
+ const target = targetKey ? await nodes.byKey(targetKey) : undefined
396
+ if (targetKey && (!target || target.objectType !== repo.objectType))
397
+ return refuse('notFound', 404, 'The document or target could not be found.')
398
+ const denied = await deniedOf(actor, 'move', found)
399
+ if (denied) return denied
400
+ if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
401
+ const blocked: Blocked = []
402
+ for (const node of found) {
403
+ const reasons: string[] = []
404
+ if (
405
+ target &&
406
+ (target.trashed || target.id === node.id || `${target.path},`.startsWith(`${node.path},`))
407
+ )
408
+ reasons.push('A document cannot be moved below itself or into the recycle bin.')
409
+ const misplaced = await misplacedAt(actor, node.key, targetKey)
410
+ if (misplaced) reasons.push(misplaced)
411
+ if (reasons.length > 0) blocked.push({ key: node.key, reasons })
412
+ }
413
+ if (blocked.length > 0)
414
+ return {
415
+ ...refuse(
416
+ 'invalid',
417
+ 400,
418
+ found.length === 1
419
+ ? (blocked[0]?.reasons[0] as string)
420
+ : `${blocked.length} of ${found.length} page(s) cannot be moved there, so nothing was moved.`,
421
+ ),
422
+ blocked,
423
+ }
424
+ const parentId = target?.id ?? SystemNodes.Root
425
+ const stays = (node: NodeRow) => !node.trashed && node.parentId === parentId
426
+ const writes = found.filter((node) => !stays(node))
427
+ const unchanged = found.filter(stays).map((node) => node.key)
428
+ if (many.dryRun || writes.length === 0)
429
+ return { ok: true, changed: writes.map((node) => node.key), unchanged }
430
+ try {
431
+ const befores: unknown[] = []
432
+ for (const node of writes) befores.push(await options.redirects?.capture(node.key))
433
+ const done = await commit(
434
+ actor,
435
+ async (tx) => {
436
+ const out: string[] = []
437
+ for (const node of writes)
438
+ if ((await tx.move(node.key, targetKey)) === 'moved') out.push(node.key)
439
+ return out
440
+ },
441
+ (moved) => moved.map((key): DocumentEvent => ({ type: 'moved', key })),
442
+ )
443
+ changed()
444
+ for (const before of befores) await options.redirects?.commit(before)
445
+ return { ok: true, changed: done, unchanged }
446
+ } catch (error) {
447
+ return refusalFrom(error, options.refusalReason)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Moves pages to the recycle bin in one transaction, every one checked first.
453
+ * One already there is unchanged; one below another in the selection goes
454
+ * with it.
455
+ */
456
+ const trashMany = async (
457
+ actor: Actor,
458
+ keys: readonly string[],
459
+ many: { dryRun?: boolean } = {},
460
+ ): Promise<SelectionResult> => {
461
+ const found = await pagesFor(keys)
462
+ if (!Array.isArray(found)) return found
463
+ const denied = await deniedOf(actor, 'trash', found)
464
+ if (denied) return denied
465
+ const covered = (node: NodeRow) =>
466
+ found.some((other) => other.id !== node.id && `${node.path},`.startsWith(`${other.path},`))
467
+ const writes = found.filter((node) => !node.trashed && !covered(node))
468
+ const changedKeys = found.filter((node) => !node.trashed).map((node) => node.key)
469
+ const unchanged = found.filter((node) => node.trashed).map((node) => node.key)
470
+ if (many.dryRun || writes.length === 0) return { ok: true, changed: changedKeys, unchanged }
471
+ await commit(
472
+ actor,
473
+ async (tx) => {
474
+ const out: string[] = []
475
+ for (const node of writes) if (await tx.moveToRecycleBin(node.key)) out.push(node.key)
476
+ return out
477
+ },
478
+ (done) => done.map((key): DocumentEvent => ({ type: 'trashed', key })),
479
+ )
480
+ changed()
481
+ return { ok: true, changed: changedKeys, unchanged }
482
+ }
483
+
316
484
  const validationRefusal = (errors: DocumentValidationError[]): Refusal => ({
317
485
  ...refuse('validation', 400, 'One or more properties did not pass validation'),
318
486
  errors,
@@ -329,6 +497,8 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
329
497
  input.values,
330
498
  )
331
499
  if (denied) return denied
500
+ const misplaced = await placement(actor, input.contentTypeKey, input.parentKey)
501
+ if (misplaced) return refuse('invalid', 400, misplaced)
332
502
  try {
333
503
  const userId = await userIdOf(actor)
334
504
  const created = await commit(
@@ -406,6 +576,8 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
406
576
  input.values,
407
577
  )
408
578
  if (denied) return denied
579
+ const misplaced = await placement(actor, input.contentTypeKey, input.parentKey)
580
+ if (misplaced) return refuse('invalid', 400, misplaced)
409
581
  const gate = gateFor(actor)
410
582
  if (!gate.skip) {
411
583
  const refusal = await options.approvals?.newPageRefusal(
@@ -748,17 +920,13 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
748
920
  },
749
921
 
750
922
  async trash(actor: Actor, key: string): Promise<Result> {
751
- if (!(await permits(actor, 'trash', key))) return forbidden()
752
- const moved = await commit(
753
- actor,
754
- (tx) => tx.moveToRecycleBin(key),
755
- (done) => (done ? [{ type: 'trashed', key }] : []),
756
- )
757
- if (!moved) return refuse('notFound', 404, NOT_FOUND)
758
- changed()
759
- return { ok: true }
923
+ const result = await trashMany(actor, [key])
924
+ if (result.ok) return { ok: true }
925
+ return result.kind === 'forbidden' ? forbidden() : result
760
926
  },
761
927
 
928
+ trashMany,
929
+
762
930
  /** Deletes a page for good; `fromBin` refuses one that is not in the recycle bin. */
763
931
  async delete(actor: Actor, key: string, options_: { fromBin?: boolean } = {}): Promise<Result> {
764
932
  const node = await nodes.byKey(key)
@@ -803,6 +971,10 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
803
971
  targetKey: string | null | undefined,
804
972
  ): Promise<Result> {
805
973
  if (!(await permits(actor, 'restore', 'bin'))) return forbidden()
974
+ if (targetKey !== undefined) {
975
+ const misplaced = await misplacedAt(actor, key, targetKey)
976
+ if (misplaced) return refuse('invalid', 400, misplaced)
977
+ }
806
978
  try {
807
979
  const result = await commit(
808
980
  actor,
@@ -825,39 +997,33 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
825
997
  },
826
998
 
827
999
  async move(actor: Actor, key: string, targetKey: string | null): Promise<Result> {
828
- if (!(await permits(actor, 'move', key))) return forbidden()
829
- if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
830
- try {
831
- const before = await options.redirects?.capture(key)
832
- const result = await commit(
833
- actor,
834
- (tx) => tx.move(key, targetKey),
835
- (done) => (done === 'notFound' || done === 'invalid' ? [] : [{ type: 'moved', key }]),
836
- )
837
- if (result === 'notFound')
838
- return refuse('notFound', 404, 'The document or target could not be found.')
839
- if (result === 'invalid')
840
- return refuse(
841
- 'invalid',
842
- 400,
843
- 'A document cannot be moved below itself or into the recycle bin.',
844
- )
845
- changed()
846
- await options.redirects?.commit(before)
847
- return { ok: true }
848
- } catch (error) {
849
- return refusalFrom(error, options.refusalReason)
850
- }
1000
+ const result = await moveMany(actor, [key], targetKey)
1001
+ if (result.ok) return { ok: true }
1002
+ return result.kind === 'forbidden' ? forbidden() : result
851
1003
  },
852
1004
 
1005
+ moveMany,
1006
+
853
1007
  async copy(
854
1008
  actor: Actor,
855
1009
  key: string,
856
1010
  targetKey: string | null,
857
- copyOptions: { includeDescendants: boolean },
858
- ): Promise<Result<{ key: string }>> {
1011
+ copyOptions: { includeDescendants: boolean; dryRun?: boolean },
1012
+ ): Promise<Result<{ key: string | null }>> {
859
1013
  if (!(await permits(actor, 'copy', key))) return forbidden()
860
1014
  if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
1015
+ if (!(await isDocument(key)))
1016
+ return refuse('notFound', 404, 'The document or target could not be found.')
1017
+ // Only the top page is placed anew: the pages below it keep the parents they had.
1018
+ const misplaced = await misplacedAt(actor, key, targetKey)
1019
+ if (misplaced) return refuse('invalid', 400, misplaced)
1020
+ if (copyOptions.dryRun) {
1021
+ const target = targetKey ? await nodes.byKey(targetKey) : undefined
1022
+ if (targetKey && (!target || target.objectType !== repo.objectType))
1023
+ return refuse('notFound', 404, 'The document or target could not be found.')
1024
+ if (target?.trashed) return refuse('invalid', 400, 'The parent is in the recycle bin.')
1025
+ return { ok: true, key: null }
1026
+ }
861
1027
  try {
862
1028
  const userId = await userIdOf(actor)
863
1029
  const copied = await commit(
@@ -878,9 +1044,23 @@ export function createDocumentOperations(options: DocumentOperationsOptions) {
878
1044
  actor: Actor,
879
1045
  parentKey: string | null,
880
1046
  sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
1047
+ sortOptions: { dryRun?: boolean } = {},
881
1048
  ): Promise<Result> {
882
1049
  if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
883
1050
  for (const item of sorting) if (!(await permits(actor, 'sort', item.key))) return forbidden()
1051
+ if (sortOptions.dryRun) {
1052
+ const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
1053
+ const children = await nodes.byKeys(sorting.map((item) => item.key))
1054
+ const fits =
1055
+ parentId !== undefined &&
1056
+ children.length === sorting.length &&
1057
+ children.every(
1058
+ (node) => node.objectType === repo.objectType && node.parentId === parentId,
1059
+ )
1060
+ return fits
1061
+ ? { ok: true }
1062
+ : refuse('notFound', 404, 'The parent, or one of the items, could not be found under it.')
1063
+ }
884
1064
  try {
885
1065
  const before = await options.redirects?.captureChildren(parentKey)
886
1066
  const result = await commit(
package/src/policy.ts CHANGED
@@ -6,7 +6,9 @@
6
6
  */
7
7
  import {
8
8
  DocumentPermissions as D,
9
+ fallbackPermissions,
9
10
  hasPathAccess,
11
+ MediaPermissions as M,
10
12
  permissionsForPath,
11
13
  propertyValuePermissions,
12
14
  sectionKey,
@@ -61,6 +63,23 @@ export function mayAt(
61
63
  return verbs.every((verb) => granted.has(verb))
62
64
  }
63
65
 
66
+ /**
67
+ * Whether the user may change media at `place` (an item's media ancestors' keys,
68
+ * then its own), or at the top level or the recycle bin: the group-wide media
69
+ * verb, within their media start nodes, as the Management API has always
70
+ * checked. Pure.
71
+ */
72
+ export function mayChangeMedia(
73
+ principal: ContentPrincipal,
74
+ place: readonly string[] | 'root' | 'bin',
75
+ trashed = false,
76
+ ): boolean {
77
+ if (!fallbackPermissions(principal.groups).has(M.Update)) return false
78
+ const start = principal.startNodes.media
79
+ if (place === 'root' || place === 'bin') return start.root
80
+ return place.length === 0 || hasPathAccess(start, place, trashed)
81
+ }
82
+
64
83
  /** Whether the user may work in every culture named. Pure. */
65
84
  export function mayUseCultures(principal: ContentPrincipal, cultures: readonly string[]): boolean {
66
85
  if (principal.hasAccessToAllLanguages) return true