@crombolo/content 0.25.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 +4 -4
- package/src/actor.ts +1 -1
- package/src/index.ts +1 -0
- package/src/media-operations.ts +554 -0
- package/src/operations.ts +216 -36
- package/src/policy.ts +19 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/content",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The rules every
|
|
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.
|
|
33
|
-
"@crombolo/data": "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
|
@@ -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, "
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
829
|
-
if (
|
|
830
|
-
|
|
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
|