@crombolo/content 0.0.0-stage → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +33 -4
- package/src/actor.ts +97 -0
- package/src/events.ts +267 -0
- package/src/index.ts +2 -0
- package/src/operations.ts +1036 -0
- package/src/policy.ts +130 -0
- package/src/rules.ts +8 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every document write, once (story 170). The backoffice, the command line, the
|
|
3
|
+
* scheduled job, approvals and form workflows all call these, so a write follows
|
|
4
|
+
* the same rules whoever starts it: the actor's permissions, the approval gate,
|
|
5
|
+
* one transaction, redirect tracking, and one event for the transport to announce.
|
|
6
|
+
*
|
|
7
|
+
* The repository keeps the SQL, the locking and the publish rules that guard
|
|
8
|
+
* every path (published ancestors, mandatory languages, filled mandatory fields).
|
|
9
|
+
* What it does not decide — who may, and whether a page needs approval — is here.
|
|
10
|
+
* docs/architecture.md, "Document writes".
|
|
11
|
+
*/
|
|
12
|
+
import type { DocumentValidationError, DocumentValue } from '@crombolo/core'
|
|
13
|
+
import { normaliseUuid } from '@crombolo/core'
|
|
14
|
+
import {
|
|
15
|
+
appendCacheInstruction,
|
|
16
|
+
type Db,
|
|
17
|
+
DocumentRepository,
|
|
18
|
+
type DocumentRepositoryOptions,
|
|
19
|
+
DomainRepository,
|
|
20
|
+
InvalidParentError,
|
|
21
|
+
NodeRepository,
|
|
22
|
+
type NodeRow,
|
|
23
|
+
PublicAccessRepository,
|
|
24
|
+
PublishBlockedError,
|
|
25
|
+
type SetDomainsResult,
|
|
26
|
+
StaleRevisionError,
|
|
27
|
+
WriteRejectedError,
|
|
28
|
+
} from '@crombolo/data'
|
|
29
|
+
import type { Actor, ContentPrincipal } from './actor.ts'
|
|
30
|
+
import type { DocumentEvent, EventSink } from './events.ts'
|
|
31
|
+
import {
|
|
32
|
+
deniedPropertyWrite,
|
|
33
|
+
mayAt,
|
|
34
|
+
mayUseCultures,
|
|
35
|
+
WRITE_VERBS,
|
|
36
|
+
type WriteKind,
|
|
37
|
+
writesEveryProperty,
|
|
38
|
+
} from './policy.ts'
|
|
39
|
+
|
|
40
|
+
/** A page as a save sends it. */
|
|
41
|
+
export interface DocumentInput {
|
|
42
|
+
key: string
|
|
43
|
+
contentTypeKey: string
|
|
44
|
+
componentKey: string | null
|
|
45
|
+
parentKey: string | null
|
|
46
|
+
values: DocumentValue[]
|
|
47
|
+
variants: Array<{ culture: string | null; segment: string | null; name: string }>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ScheduleEntry {
|
|
51
|
+
culture: string | null
|
|
52
|
+
publishTime: Date | null
|
|
53
|
+
unpublishTime: Date | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Why an operation did nothing: the transport turns `kind` into its own answer. */
|
|
57
|
+
export interface Refusal {
|
|
58
|
+
ok: false
|
|
59
|
+
kind:
|
|
60
|
+
| 'notFound'
|
|
61
|
+
| 'forbidden'
|
|
62
|
+
| 'needsApproval'
|
|
63
|
+
| 'invalid'
|
|
64
|
+
| 'conflict'
|
|
65
|
+
| 'blocked'
|
|
66
|
+
| 'validation'
|
|
67
|
+
/** The page changed after the copy the write was based on (412). */
|
|
68
|
+
| 'stale'
|
|
69
|
+
/** A user's write named no revision to check against (428). */
|
|
70
|
+
| 'revisionRequired'
|
|
71
|
+
status: number
|
|
72
|
+
reason: string
|
|
73
|
+
errors?: DocumentValidationError[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type Done<T extends object = object> = { ok: true } & T
|
|
77
|
+
export type Result<T extends object = object> = Done<T> | Refusal
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The approval workflows' say over a write (story 028). `who` is the user, or
|
|
81
|
+
* undefined for a local operator, who has no publishing rights of their own.
|
|
82
|
+
*/
|
|
83
|
+
export interface ApprovalsGate {
|
|
84
|
+
publishRefusal(
|
|
85
|
+
who: ContentPrincipal | undefined,
|
|
86
|
+
key: string,
|
|
87
|
+
action: 'publish' | 'unpublish',
|
|
88
|
+
): Promise<string | undefined>
|
|
89
|
+
branchRefusal(who: ContentPrincipal | undefined, key: string): Promise<string | undefined>
|
|
90
|
+
newPageRefusal(
|
|
91
|
+
who: ContentPrincipal | undefined,
|
|
92
|
+
parentKey: string | null,
|
|
93
|
+
contentTypeKey: string,
|
|
94
|
+
): Promise<string | undefined>
|
|
95
|
+
beforeSave(who: ContentPrincipal | undefined, key: string): Promise<string | undefined>
|
|
96
|
+
afterSave(who: ContentPrincipal | undefined, key: string): Promise<void>
|
|
97
|
+
/** A publish, schedule or unpublish that passed the gate supersedes a scheduled request. */
|
|
98
|
+
afterDirectPublish(who: ContentPrincipal | undefined, key: string, what: string): Promise<void>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Records redirects from the addresses a page had, once it has new ones. */
|
|
102
|
+
export interface RedirectTracking {
|
|
103
|
+
capture(key: string): Promise<unknown>
|
|
104
|
+
captureChildren(parentKey: string | null): Promise<unknown>
|
|
105
|
+
commit(before: unknown): Promise<void>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DocumentOperationsOptions {
|
|
109
|
+
db: Db
|
|
110
|
+
repository?: DocumentRepositoryOptions
|
|
111
|
+
events: EventSink
|
|
112
|
+
approvals?: ApprovalsGate
|
|
113
|
+
redirects?: RedirectTracking
|
|
114
|
+
/** Told after every committed change, for a cache held in this process. */
|
|
115
|
+
onChange?(): void
|
|
116
|
+
/**
|
|
117
|
+
* After a page's hostnames were saved: a site that keeps `domains.toml` writes
|
|
118
|
+
* them there too. Answers the file it wrote, if any.
|
|
119
|
+
*/
|
|
120
|
+
afterDomains?(node: NodeRow, assignment: DomainAssignment): Promise<string | undefined>
|
|
121
|
+
/**
|
|
122
|
+
* Errors a value's intake throws that are the sender's to fix — an upload that
|
|
123
|
+
* expired — as the reason to give; anything else not a rule is thrown on.
|
|
124
|
+
*/
|
|
125
|
+
refusalReason?(error: unknown): string | undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface DomainAssignment {
|
|
129
|
+
defaultIsoCode: string | null
|
|
130
|
+
domains: Array<{ domainName: string; isoCode: string }>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface PublicAccessInput {
|
|
134
|
+
loginDocumentKey: string
|
|
135
|
+
errorDocumentKey: string
|
|
136
|
+
memberGroupNames: string[]
|
|
137
|
+
memberUserNames: string[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type PublicAccessStatus =
|
|
141
|
+
| 'ok'
|
|
142
|
+
| 'content-not-found'
|
|
143
|
+
| 'login-node-not-found'
|
|
144
|
+
| 'error-node-not-found'
|
|
145
|
+
| 'entry-not-found'
|
|
146
|
+
| 'forbidden'
|
|
147
|
+
|
|
148
|
+
const NOT_FOUND = 'The document could not be found.'
|
|
149
|
+
const STALE =
|
|
150
|
+
'Someone else changed this page after you opened it. Your changes have not been saved; copy what you need, then reload the page to see theirs.'
|
|
151
|
+
const REVISION_REQUIRED =
|
|
152
|
+
'Read the page first and send back the revision it came with (its ETag, as If-Match), so a newer change is not overwritten.'
|
|
153
|
+
|
|
154
|
+
/** What a write was based on (story 172). */
|
|
155
|
+
export interface Precondition {
|
|
156
|
+
/** The page's revision when the copy being saved was read. */
|
|
157
|
+
expectedRevision?: string
|
|
158
|
+
/** The sender said to replace whatever is there (`If-Match: *`): checked by no revision. */
|
|
159
|
+
overwrite?: boolean
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const refuse = (kind: Refusal['kind'], status: number, reason: string): Refusal => ({
|
|
163
|
+
ok: false,
|
|
164
|
+
kind,
|
|
165
|
+
status,
|
|
166
|
+
reason,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
/** A write the repository refused as a rule, as a refusal; anything else is thrown on. */
|
|
170
|
+
function refusalFrom(error: unknown, extra?: (error: unknown) => string | undefined): Refusal {
|
|
171
|
+
if (error instanceof WriteRejectedError) return refuse('conflict', 409, error.message)
|
|
172
|
+
if (error instanceof InvalidParentError) return refuse('invalid', 400, error.message)
|
|
173
|
+
if (error instanceof PublishBlockedError) return refuse('blocked', 400, error.message)
|
|
174
|
+
if (error instanceof StaleRevisionError) return refuse('stale', 412, STALE)
|
|
175
|
+
const reason = extra?.(error)
|
|
176
|
+
if (reason) return refuse('invalid', 400, reason)
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type DocumentOperations = ReturnType<typeof createDocumentOperations>
|
|
181
|
+
|
|
182
|
+
export function createDocumentOperations(options: DocumentOperationsOptions) {
|
|
183
|
+
const { db, events } = options
|
|
184
|
+
const repo = new DocumentRepository(db, options.repository)
|
|
185
|
+
const nodes = new NodeRepository(db)
|
|
186
|
+
const changed = () => options.onChange?.()
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Writes and records in one transaction (story 171): the change, its audit
|
|
190
|
+
* entry and what it announces commit together or not at all. What only a
|
|
191
|
+
* running server does follows the commit.
|
|
192
|
+
*/
|
|
193
|
+
const commit = async <T>(
|
|
194
|
+
actor: Actor,
|
|
195
|
+
write: (tx: DocumentRepository, txDb: Db) => Promise<T>,
|
|
196
|
+
announce: (result: T) => readonly DocumentEvent[],
|
|
197
|
+
): Promise<T> => {
|
|
198
|
+
const recorded: DocumentEvent[] = []
|
|
199
|
+
const result = await repo.atomically(async (tx, txDb) => {
|
|
200
|
+
const written = await write(tx, txDb)
|
|
201
|
+
for (const event of announce(written)) {
|
|
202
|
+
await events.record(txDb, event, actor)
|
|
203
|
+
recorded.push(event)
|
|
204
|
+
}
|
|
205
|
+
return written
|
|
206
|
+
})
|
|
207
|
+
for (const event of recorded) events.afterCommit?.(event, actor)
|
|
208
|
+
return result
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The version author: the user's row, when the actor is one. */
|
|
212
|
+
const userIdOf = async (actor: Actor): Promise<number | undefined> => {
|
|
213
|
+
if (actor.kind !== 'user') return undefined
|
|
214
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
215
|
+
actor.principal.id,
|
|
216
|
+
])
|
|
217
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A document's place: its ancestors' keys then its own, root first. */
|
|
221
|
+
const placeOf = async (
|
|
222
|
+
key: string,
|
|
223
|
+
): Promise<{ node: NodeRow; chain: string[]; trashed: boolean } | undefined> => {
|
|
224
|
+
const node = await nodes.byKey(key)
|
|
225
|
+
if (!node || node.objectType !== repo.objectType) return undefined
|
|
226
|
+
const ids = node.path
|
|
227
|
+
.split(',')
|
|
228
|
+
.map(Number)
|
|
229
|
+
.filter((id) => id > 0)
|
|
230
|
+
const byId = new Map((await nodes.byIds(ids)).map((row) => [row.id, row]))
|
|
231
|
+
const chain = ids.flatMap((id) => {
|
|
232
|
+
const row = byId.get(id)
|
|
233
|
+
return row && row.objectType === repo.objectType ? [row.key] : []
|
|
234
|
+
})
|
|
235
|
+
return { node, chain, trashed: node.trashed }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Whether the actor may do `kind` at `key` (or the root, or the bin). Only a
|
|
240
|
+
* user is checked: an operator holds the database and the system acts on what
|
|
241
|
+
* was already decided. A page that does not exist is not refused here: the
|
|
242
|
+
* operation answers that it is not found.
|
|
243
|
+
*/
|
|
244
|
+
const permits = async (
|
|
245
|
+
actor: Actor,
|
|
246
|
+
kind: WriteKind,
|
|
247
|
+
key: string | 'root' | 'bin',
|
|
248
|
+
): Promise<boolean> => {
|
|
249
|
+
if (actor.kind !== 'user') return true
|
|
250
|
+
if (key === 'root' || key === 'bin') return mayAt(actor.principal, WRITE_VERBS[kind], key)
|
|
251
|
+
const place = await placeOf(key)
|
|
252
|
+
if (!place) return true
|
|
253
|
+
return mayAt(actor.principal, WRITE_VERBS[kind], place.chain, place.trashed)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const forbidden = () => refuse('forbidden', 403, 'You may not do that to this page.')
|
|
257
|
+
/** A user replaces a draft only from a copy whose revision is checked; the site acts on the head. */
|
|
258
|
+
const revisionRefusal = (actor: Actor, precondition: Precondition): Refusal | undefined =>
|
|
259
|
+
actor.kind === 'user' && precondition.expectedRevision === undefined && !precondition.overwrite
|
|
260
|
+
? refuse('revisionRequired', 428, REVISION_REQUIRED)
|
|
261
|
+
: undefined
|
|
262
|
+
|
|
263
|
+
/** Whether the actor may work in these languages; only a user is limited to some. */
|
|
264
|
+
const languagesAllowed = (actor: Actor, cultures: readonly string[] | null) =>
|
|
265
|
+
actor.kind !== 'user' || !cultures || mayUseCultures(actor.principal, cultures)
|
|
266
|
+
|
|
267
|
+
/** The cultures a save names, so a user is held to their languages. */
|
|
268
|
+
const culturesSaved = (input: Pick<DocumentInput, 'variants' | 'values'>) => [
|
|
269
|
+
...new Set([...input.variants, ...input.values].flatMap((v) => (v.culture ? [v.culture] : []))),
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
/** The approval gate, as the actor meets it; `skip` for the system and an operator who chose to. */
|
|
273
|
+
const gateFor = (actor: Actor): { skip: boolean; who: ContentPrincipal | undefined } => {
|
|
274
|
+
if (actor.kind === 'user') return { skip: false, who: actor.principal }
|
|
275
|
+
if (actor.kind === 'operator') return { skip: actor.skipApproval === true, who: undefined }
|
|
276
|
+
return { skip: true, who: undefined }
|
|
277
|
+
}
|
|
278
|
+
const needsApproval = (reason: string) => refuse('needsApproval', 400, reason)
|
|
279
|
+
|
|
280
|
+
/** A user may not change a property their groups only read (story 088). */
|
|
281
|
+
const propertyRefusal = async (
|
|
282
|
+
actor: Actor,
|
|
283
|
+
properties: Array<{ alias: string; key: string; name: string }> | undefined,
|
|
284
|
+
before: readonly DocumentValue[],
|
|
285
|
+
after: readonly DocumentValue[],
|
|
286
|
+
): Promise<Refusal | undefined> => {
|
|
287
|
+
if (actor.kind !== 'user' || !properties || writesEveryProperty(actor.principal))
|
|
288
|
+
return undefined
|
|
289
|
+
const denied = deniedPropertyWrite(actor.principal, properties, before, after)
|
|
290
|
+
return denied
|
|
291
|
+
? refuse('forbidden', 403, `You may not change "${denied.name}" (${denied.alias}).`)
|
|
292
|
+
: undefined
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** The properties a page of this type has, compositions included, for the property check. */
|
|
296
|
+
const propertiesOfType = async (contentTypeKey: string) => {
|
|
297
|
+
const type = await nodes.byKey(contentTypeKey)
|
|
298
|
+
if (!type) return undefined
|
|
299
|
+
const rows = await db.query<{ alias: string; unique_id: string; name: string }>(
|
|
300
|
+
`WITH RECURSIVE ancestry(id) AS (
|
|
301
|
+
SELECT ?
|
|
302
|
+
UNION
|
|
303
|
+
SELECT c.parent_content_type_id FROM content_type_composition c JOIN ancestry a ON a.id = c.child_content_type_id
|
|
304
|
+
)
|
|
305
|
+
SELECT p.alias, p.unique_id, p.name FROM property_type p
|
|
306
|
+
WHERE p.content_type_id IN (SELECT id FROM ancestry) AND p.retired_at IS NULL`,
|
|
307
|
+
[type.id],
|
|
308
|
+
)
|
|
309
|
+
return rows.map((row) => ({
|
|
310
|
+
alias: String(row.alias),
|
|
311
|
+
key: normaliseUuid(String(row.unique_id)),
|
|
312
|
+
name: String(row.name),
|
|
313
|
+
}))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const validationRefusal = (errors: DocumentValidationError[]): Refusal => ({
|
|
317
|
+
...refuse('validation', 400, 'One or more properties did not pass validation'),
|
|
318
|
+
errors,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
async create(actor: Actor, input: DocumentInput): Promise<Result<{ key: string }>> {
|
|
323
|
+
if (!(await permits(actor, 'create', input.parentKey ?? 'root'))) return forbidden()
|
|
324
|
+
if (!languagesAllowed(actor, culturesSaved(input))) return forbidden()
|
|
325
|
+
const denied = await propertyRefusal(
|
|
326
|
+
actor,
|
|
327
|
+
await propertiesOfType(input.contentTypeKey),
|
|
328
|
+
[],
|
|
329
|
+
input.values,
|
|
330
|
+
)
|
|
331
|
+
if (denied) return denied
|
|
332
|
+
try {
|
|
333
|
+
const userId = await userIdOf(actor)
|
|
334
|
+
const created = await commit(
|
|
335
|
+
actor,
|
|
336
|
+
(tx) => tx.create({ ...input, userId }),
|
|
337
|
+
(doc) => [{ type: 'created', key: doc.key }],
|
|
338
|
+
)
|
|
339
|
+
changed()
|
|
340
|
+
return { ok: true, key: created.key }
|
|
341
|
+
} catch (error) {
|
|
342
|
+
return refusalFrom(error, options.refusalReason)
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
|
|
346
|
+
async save(
|
|
347
|
+
actor: Actor,
|
|
348
|
+
key: string,
|
|
349
|
+
input: DocumentInput,
|
|
350
|
+
precondition: Precondition = {},
|
|
351
|
+
): Promise<Result<{ revision?: string }>> {
|
|
352
|
+
const existing = await repo.byKey(key)
|
|
353
|
+
if (!existing) return refuse('notFound', 404, NOT_FOUND)
|
|
354
|
+
if (!(await permits(actor, 'save', key))) return forbidden()
|
|
355
|
+
if (!languagesAllowed(actor, culturesSaved(input))) return forbidden()
|
|
356
|
+
const denied = await propertyRefusal(
|
|
357
|
+
actor,
|
|
358
|
+
await repo.propertyTypeKeys(key),
|
|
359
|
+
existing.values,
|
|
360
|
+
input.values,
|
|
361
|
+
)
|
|
362
|
+
if (denied) return denied
|
|
363
|
+
const unbased = revisionRefusal(actor, precondition)
|
|
364
|
+
if (unbased) return unbased
|
|
365
|
+
const gate = gateFor(actor)
|
|
366
|
+
if (!gate.skip) {
|
|
367
|
+
const waiting = await options.approvals?.beforeSave(gate.who, key)
|
|
368
|
+
if (waiting) return needsApproval(waiting)
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const userId = await userIdOf(actor)
|
|
372
|
+
const updated = await commit(
|
|
373
|
+
actor,
|
|
374
|
+
(tx) =>
|
|
375
|
+
tx.update({
|
|
376
|
+
...input,
|
|
377
|
+
key,
|
|
378
|
+
// The content type never changes on a save.
|
|
379
|
+
contentTypeKey: existing.contentTypeKey,
|
|
380
|
+
userId,
|
|
381
|
+
expectedRevision: precondition.expectedRevision,
|
|
382
|
+
}),
|
|
383
|
+
(doc) => (doc ? [{ type: 'saved', key }] : []),
|
|
384
|
+
)
|
|
385
|
+
if (!updated) return refuse('notFound', 404, NOT_FOUND)
|
|
386
|
+
changed()
|
|
387
|
+
if (!gate.skip) await options.approvals?.afterSave(gate.who, key)
|
|
388
|
+
return { ok: true, revision: updated.revision }
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return refusalFrom(error, options.refusalReason)
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
|
|
394
|
+
async createAndPublish(
|
|
395
|
+
actor: Actor,
|
|
396
|
+
input: DocumentInput,
|
|
397
|
+
cultures: string[] | null,
|
|
398
|
+
): Promise<Result<{ key: string }>> {
|
|
399
|
+
if (!(await permits(actor, 'publish', input.parentKey ?? 'root'))) return forbidden()
|
|
400
|
+
if (!languagesAllowed(actor, cultures) || !languagesAllowed(actor, culturesSaved(input)))
|
|
401
|
+
return forbidden()
|
|
402
|
+
const denied = await propertyRefusal(
|
|
403
|
+
actor,
|
|
404
|
+
await propertiesOfType(input.contentTypeKey),
|
|
405
|
+
[],
|
|
406
|
+
input.values,
|
|
407
|
+
)
|
|
408
|
+
if (denied) return denied
|
|
409
|
+
const gate = gateFor(actor)
|
|
410
|
+
if (!gate.skip) {
|
|
411
|
+
const refusal = await options.approvals?.newPageRefusal(
|
|
412
|
+
gate.who,
|
|
413
|
+
input.parentKey,
|
|
414
|
+
input.contentTypeKey,
|
|
415
|
+
)
|
|
416
|
+
if (refusal) return needsApproval(refusal)
|
|
417
|
+
}
|
|
418
|
+
const errors = await repo.validate(input, cultures)
|
|
419
|
+
if (errors.length > 0) return validationRefusal(errors)
|
|
420
|
+
try {
|
|
421
|
+
const userId = await userIdOf(actor)
|
|
422
|
+
// One transaction: a page that cannot be published is not left behind as a draft.
|
|
423
|
+
const result = await commit(
|
|
424
|
+
actor,
|
|
425
|
+
(tx) => tx.createAndPublish({ ...input, userId }, cultures),
|
|
426
|
+
(done) =>
|
|
427
|
+
done.ok ? [{ type: 'createdAndPublished', key: done.document.key, cultures }] : [],
|
|
428
|
+
)
|
|
429
|
+
if (!result.ok) return validationRefusal(result.errors)
|
|
430
|
+
changed()
|
|
431
|
+
return { ok: true, key: result.document.key }
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return refusalFrom(error, options.refusalReason)
|
|
434
|
+
}
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
async saveAndPublish(
|
|
438
|
+
actor: Actor,
|
|
439
|
+
key: string,
|
|
440
|
+
input: DocumentInput,
|
|
441
|
+
cultures: string[] | null,
|
|
442
|
+
precondition: Precondition = {},
|
|
443
|
+
): Promise<Result<{ revision?: string }>> {
|
|
444
|
+
const existing = await repo.byKey(key)
|
|
445
|
+
if (!existing) return refuse('notFound', 404, NOT_FOUND)
|
|
446
|
+
if (!(await permits(actor, 'publish', key))) return forbidden()
|
|
447
|
+
if (!languagesAllowed(actor, cultures) || !languagesAllowed(actor, culturesSaved(input)))
|
|
448
|
+
return forbidden()
|
|
449
|
+
const denied = await propertyRefusal(
|
|
450
|
+
actor,
|
|
451
|
+
await repo.propertyTypeKeys(key),
|
|
452
|
+
existing.values,
|
|
453
|
+
input.values,
|
|
454
|
+
)
|
|
455
|
+
if (denied) return denied
|
|
456
|
+
const unbased = revisionRefusal(actor, precondition)
|
|
457
|
+
if (unbased) return unbased
|
|
458
|
+
const gate = gateFor(actor)
|
|
459
|
+
if (!gate.skip) {
|
|
460
|
+
const refusal =
|
|
461
|
+
(await options.approvals?.beforeSave(gate.who, key)) ??
|
|
462
|
+
(await options.approvals?.publishRefusal(gate.who, key, 'publish'))
|
|
463
|
+
if (refusal) return needsApproval(refusal)
|
|
464
|
+
}
|
|
465
|
+
try {
|
|
466
|
+
const before = await options.redirects?.capture(key)
|
|
467
|
+
const userId = await userIdOf(actor)
|
|
468
|
+
const result = await commit(
|
|
469
|
+
actor,
|
|
470
|
+
(tx) =>
|
|
471
|
+
tx.updateAndPublish(
|
|
472
|
+
{ ...input, key, userId, expectedRevision: precondition.expectedRevision },
|
|
473
|
+
cultures,
|
|
474
|
+
),
|
|
475
|
+
(done) => (done?.ok ? [{ type: 'savedAndPublished', key, cultures }] : []),
|
|
476
|
+
)
|
|
477
|
+
if (!result) return refuse('notFound', 404, NOT_FOUND)
|
|
478
|
+
if (!result.ok) return validationRefusal(result.errors)
|
|
479
|
+
changed()
|
|
480
|
+
await options.redirects?.commit(before)
|
|
481
|
+
if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
|
|
482
|
+
return { ok: true, revision: result.document.revision }
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return refusalFrom(error, options.refusalReason)
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Publishes a page now, or schedules it: a schedule entry with a publish time
|
|
490
|
+
* still to come schedules that culture instead of publishing it.
|
|
491
|
+
*/
|
|
492
|
+
async publish(
|
|
493
|
+
actor: Actor,
|
|
494
|
+
key: string,
|
|
495
|
+
request: {
|
|
496
|
+
cultures: string[] | null
|
|
497
|
+
schedules?: readonly ScheduleEntry[]
|
|
498
|
+
now?: number
|
|
499
|
+
/** Checked when sent: a publish from a stale copy would publish what its sender never saw. */
|
|
500
|
+
expectedRevision?: string
|
|
501
|
+
/** The event an approval was given for; a later one is not published. */
|
|
502
|
+
expectedHead?: number
|
|
503
|
+
},
|
|
504
|
+
): Promise<Result<{ scheduledOnly: boolean; revision?: string }>> {
|
|
505
|
+
if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
|
|
506
|
+
if (!(await permits(actor, 'publish', key))) return forbidden()
|
|
507
|
+
const schedules = request.schedules ?? []
|
|
508
|
+
const named = [
|
|
509
|
+
...(request.cultures ?? []),
|
|
510
|
+
...schedules.flatMap((s) => (s.culture ? [s.culture] : [])),
|
|
511
|
+
]
|
|
512
|
+
if (!languagesAllowed(actor, named)) return forbidden()
|
|
513
|
+
const gate = gateFor(actor)
|
|
514
|
+
if (!gate.skip) {
|
|
515
|
+
const refusal = await options.approvals?.publishRefusal(gate.who, key, 'publish')
|
|
516
|
+
if (refusal) return needsApproval(refusal)
|
|
517
|
+
}
|
|
518
|
+
const now = request.now ?? Date.now()
|
|
519
|
+
const later = (s: ScheduleEntry) => s.publishTime !== null && s.publishTime.getTime() > now
|
|
520
|
+
try {
|
|
521
|
+
const immediate = schedules.filter((s) => !later(s))
|
|
522
|
+
const scheduledOnly = schedules.length > 0 && immediate.length === 0
|
|
523
|
+
const cultures = immediate.some((s) => s.culture !== null)
|
|
524
|
+
? immediate.flatMap((s) => (s.culture === null ? [] : [s.culture]))
|
|
525
|
+
: request.cultures
|
|
526
|
+
const before = scheduledOnly ? undefined : await options.redirects?.capture(key)
|
|
527
|
+
// The schedule and the publish are one change: neither lands without the other.
|
|
528
|
+
const published = await commit(
|
|
529
|
+
actor,
|
|
530
|
+
async (tx) => {
|
|
531
|
+
if (schedules.length > 0)
|
|
532
|
+
await tx.setSchedule(
|
|
533
|
+
key,
|
|
534
|
+
schedules.map((s) => ({ ...s, publishTime: later(s) ? s.publishTime : null })),
|
|
535
|
+
)
|
|
536
|
+
if (scheduledOnly) return 'scheduled' as const
|
|
537
|
+
return tx.publish(key, cultures, {
|
|
538
|
+
expectedRevision: request.expectedRevision,
|
|
539
|
+
expectedHead: request.expectedHead,
|
|
540
|
+
})
|
|
541
|
+
},
|
|
542
|
+
(done): DocumentEvent[] =>
|
|
543
|
+
done === 'scheduled'
|
|
544
|
+
? [{ type: 'scheduled', key }]
|
|
545
|
+
: done
|
|
546
|
+
? [{ type: 'published', key, cultures }]
|
|
547
|
+
: [],
|
|
548
|
+
)
|
|
549
|
+
if (published === 'scheduled') {
|
|
550
|
+
changed()
|
|
551
|
+
if (!gate.skip)
|
|
552
|
+
await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
|
|
553
|
+
return { ok: true, scheduledOnly: true }
|
|
554
|
+
}
|
|
555
|
+
if (!published) return refuse('notFound', 404, NOT_FOUND)
|
|
556
|
+
changed()
|
|
557
|
+
await options.redirects?.commit(before)
|
|
558
|
+
if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'published it')
|
|
559
|
+
return { ok: true, scheduledOnly: false, revision: published.revision }
|
|
560
|
+
} catch (error) {
|
|
561
|
+
return refusalFrom(error, options.refusalReason)
|
|
562
|
+
}
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
async unpublish(actor: Actor, key: string, cultures: string[] | null): Promise<Result> {
|
|
566
|
+
if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
|
|
567
|
+
if (!(await permits(actor, 'unpublish', key))) return forbidden()
|
|
568
|
+
if (!languagesAllowed(actor, cultures)) return forbidden()
|
|
569
|
+
const gate = gateFor(actor)
|
|
570
|
+
if (!gate.skip) {
|
|
571
|
+
const refusal = await options.approvals?.publishRefusal(gate.who, key, 'unpublish')
|
|
572
|
+
if (refusal) return needsApproval(refusal)
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
const result = await commit(
|
|
576
|
+
actor,
|
|
577
|
+
(tx) => tx.unpublish(key, cultures),
|
|
578
|
+
(done) => (done ? [{ type: 'unpublished', key, cultures }] : []),
|
|
579
|
+
)
|
|
580
|
+
if (!result) return refuse('notFound', 404, NOT_FOUND)
|
|
581
|
+
changed()
|
|
582
|
+
if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'took it down')
|
|
583
|
+
return { ok: true }
|
|
584
|
+
} catch (error) {
|
|
585
|
+
return refusalFrom(error, options.refusalReason)
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Publishes or unpublishes several pages as one change: every page is checked
|
|
591
|
+
* first, then all are written in one transaction, so a run never stops half
|
|
592
|
+
* done. Parents first, so a child is not blocked by an ancestor the same run
|
|
593
|
+
* takes live.
|
|
594
|
+
*/
|
|
595
|
+
async publishMany(
|
|
596
|
+
actor: Actor,
|
|
597
|
+
keys: readonly string[],
|
|
598
|
+
cultures: string[] | null,
|
|
599
|
+
action: 'publish' | 'unpublish',
|
|
600
|
+
): Promise<
|
|
601
|
+
| Result<{ changed: string[] }>
|
|
602
|
+
| (Refusal & { blocked?: Array<{ key: string; reasons: string[] }> })
|
|
603
|
+
> {
|
|
604
|
+
const targets: NodeRow[] = []
|
|
605
|
+
for (const key of keys) {
|
|
606
|
+
const node = await nodes.byKey(key)
|
|
607
|
+
if (!node || node.objectType !== repo.objectType) return refuse('notFound', 404, NOT_FOUND)
|
|
608
|
+
targets.push(node)
|
|
609
|
+
}
|
|
610
|
+
targets.sort((a, b) => a.level - b.level)
|
|
611
|
+
for (const node of targets) if (!(await permits(actor, action, node.key))) return forbidden()
|
|
612
|
+
if (!languagesAllowed(actor, cultures)) return forbidden()
|
|
613
|
+
const gate = gateFor(actor)
|
|
614
|
+
if (!gate.skip)
|
|
615
|
+
for (const node of targets) {
|
|
616
|
+
const refusal = await options.approvals?.publishRefusal(gate.who, node.key, action)
|
|
617
|
+
if (refusal) return needsApproval(refusal)
|
|
618
|
+
}
|
|
619
|
+
if (action === 'publish') {
|
|
620
|
+
const selection = new Set(targets.map((node) => node.id))
|
|
621
|
+
const blocked: Array<{ key: string; reasons: string[] }> = []
|
|
622
|
+
for (const node of targets) {
|
|
623
|
+
const reasons = await repo.publishBlockers(node.key, cultures, selection)
|
|
624
|
+
if (reasons.length > 0) blocked.push({ key: node.key, reasons })
|
|
625
|
+
}
|
|
626
|
+
if (blocked.length > 0)
|
|
627
|
+
return {
|
|
628
|
+
...refuse(
|
|
629
|
+
'blocked',
|
|
630
|
+
400,
|
|
631
|
+
`${blocked.length} of ${targets.length} page(s) cannot be published, so nothing was published.`,
|
|
632
|
+
),
|
|
633
|
+
blocked,
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
const befores = new Map<string, unknown>()
|
|
638
|
+
if (action === 'publish')
|
|
639
|
+
for (const node of targets)
|
|
640
|
+
befores.set(node.key, await options.redirects?.capture(node.key))
|
|
641
|
+
const done = await commit(
|
|
642
|
+
actor,
|
|
643
|
+
async (tx) => {
|
|
644
|
+
const out: string[] = []
|
|
645
|
+
for (const node of targets) {
|
|
646
|
+
const result =
|
|
647
|
+
action === 'publish'
|
|
648
|
+
? await tx.publish(node.key, cultures)
|
|
649
|
+
: await tx.unpublish(node.key, cultures)
|
|
650
|
+
if (result) out.push(node.key)
|
|
651
|
+
}
|
|
652
|
+
return out
|
|
653
|
+
},
|
|
654
|
+
(keys) =>
|
|
655
|
+
keys.map(
|
|
656
|
+
(key): DocumentEvent =>
|
|
657
|
+
action === 'publish'
|
|
658
|
+
? { type: 'published', key, cultures }
|
|
659
|
+
: { type: 'unpublished', key, cultures },
|
|
660
|
+
),
|
|
661
|
+
)
|
|
662
|
+
changed()
|
|
663
|
+
for (const key of done) {
|
|
664
|
+
if (action === 'publish') await options.redirects?.commit(befores.get(key))
|
|
665
|
+
if (!gate.skip)
|
|
666
|
+
await options.approvals?.afterDirectPublish(
|
|
667
|
+
gate.who,
|
|
668
|
+
key,
|
|
669
|
+
action === 'publish' ? 'published it' : 'took it down',
|
|
670
|
+
)
|
|
671
|
+
}
|
|
672
|
+
return { ok: true, changed: done }
|
|
673
|
+
} catch (error) {
|
|
674
|
+
return refusalFrom(error, options.refusalReason)
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
/** Sets when a page goes live or comes down, without publishing anything now. */
|
|
679
|
+
async schedule(actor: Actor, key: string, entries: readonly ScheduleEntry[]): Promise<Result> {
|
|
680
|
+
if (!(await repo.byKey(key))) return refuse('notFound', 404, NOT_FOUND)
|
|
681
|
+
if (!(await permits(actor, 'publish', key))) return forbidden()
|
|
682
|
+
if (
|
|
683
|
+
!languagesAllowed(
|
|
684
|
+
actor,
|
|
685
|
+
entries.flatMap((e) => (e.culture ? [e.culture] : [])),
|
|
686
|
+
)
|
|
687
|
+
)
|
|
688
|
+
return forbidden()
|
|
689
|
+
const gate = gateFor(actor)
|
|
690
|
+
if (!gate.skip) {
|
|
691
|
+
const refusal = await options.approvals?.publishRefusal(gate.who, key, 'publish')
|
|
692
|
+
if (refusal) return needsApproval(refusal)
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
await commit(
|
|
696
|
+
actor,
|
|
697
|
+
(tx) => tx.setSchedule(key, entries),
|
|
698
|
+
() => [{ type: 'scheduled', key }],
|
|
699
|
+
)
|
|
700
|
+
changed()
|
|
701
|
+
if (!gate.skip) await options.approvals?.afterDirectPublish(gate.who, key, 'rescheduled it')
|
|
702
|
+
return { ok: true }
|
|
703
|
+
} catch (error) {
|
|
704
|
+
return refusalFrom(error, options.refusalReason)
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
|
|
708
|
+
async publishBranch(
|
|
709
|
+
actor: Actor,
|
|
710
|
+
key: string,
|
|
711
|
+
cultures: string[] | null,
|
|
712
|
+
includeUnpublished: boolean,
|
|
713
|
+
): Promise<Result<{ published: string[]; failed: Array<{ key: string; reason: string }> }>> {
|
|
714
|
+
const root = await placeOf(key)
|
|
715
|
+
if (!root) return refuse('notFound', 404, NOT_FOUND)
|
|
716
|
+
if (actor.kind === 'user') {
|
|
717
|
+
const below = await db.query<{ unique_id: string }>(
|
|
718
|
+
'SELECT unique_id FROM node WHERE id = ? OR path LIKE ?',
|
|
719
|
+
[root.node.id, `${root.node.path},%`],
|
|
720
|
+
)
|
|
721
|
+
for (const row of below)
|
|
722
|
+
if (!(await permits(actor, 'publish', normaliseUuid(String(row.unique_id)))))
|
|
723
|
+
return forbidden()
|
|
724
|
+
}
|
|
725
|
+
if (!languagesAllowed(actor, cultures)) return forbidden()
|
|
726
|
+
const gate = gateFor(actor)
|
|
727
|
+
if (!gate.skip) {
|
|
728
|
+
const refusal = await options.approvals?.branchRefusal(gate.who, key)
|
|
729
|
+
if (refusal) return needsApproval(refusal)
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const before = await options.redirects?.capture(key)
|
|
733
|
+
const result = await commit(
|
|
734
|
+
actor,
|
|
735
|
+
(tx) => tx.publishBranch(key, cultures, includeUnpublished),
|
|
736
|
+
(done) => (done ? [{ type: 'branchPublished', key, published: done.published }] : []),
|
|
737
|
+
)
|
|
738
|
+
if (!result) return refuse('notFound', 404, NOT_FOUND)
|
|
739
|
+
changed()
|
|
740
|
+
await options.redirects?.commit(before)
|
|
741
|
+
if (!gate.skip)
|
|
742
|
+
for (const published of result.published)
|
|
743
|
+
await options.approvals?.afterDirectPublish(gate.who, published, 'published its branch')
|
|
744
|
+
return { ok: true, ...result }
|
|
745
|
+
} catch (error) {
|
|
746
|
+
return refusalFrom(error, options.refusalReason)
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
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 }
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
/** Deletes a page for good; `fromBin` refuses one that is not in the recycle bin. */
|
|
763
|
+
async delete(actor: Actor, key: string, options_: { fromBin?: boolean } = {}): Promise<Result> {
|
|
764
|
+
const node = await nodes.byKey(key)
|
|
765
|
+
if (!node || node.objectType !== repo.objectType) return refuse('notFound', 404, NOT_FOUND)
|
|
766
|
+
if (options_.fromBin && !node.trashed)
|
|
767
|
+
return refuse('invalid', 400, 'The document is not in the recycle bin')
|
|
768
|
+
if (!(await permits(actor, 'delete', options_.fromBin ? 'bin' : key))) return forbidden()
|
|
769
|
+
const removed = await commit(
|
|
770
|
+
actor,
|
|
771
|
+
(tx) => tx.delete(key),
|
|
772
|
+
(done) => (done ? [{ type: 'deleted', key, name: node.text ?? null }] : []),
|
|
773
|
+
)
|
|
774
|
+
if (!removed) return refuse('notFound', 404, NOT_FOUND)
|
|
775
|
+
changed()
|
|
776
|
+
return { ok: true }
|
|
777
|
+
},
|
|
778
|
+
|
|
779
|
+
async emptyBin(actor: Actor): Promise<Result<{ deleted: number }>> {
|
|
780
|
+
if (!(await permits(actor, 'emptyBin', 'bin'))) return forbidden()
|
|
781
|
+
// One change: each page deleted is announced as a delete from the bin is,
|
|
782
|
+
// and the emptying itself is recorded once.
|
|
783
|
+
const deleted = await commit(
|
|
784
|
+
actor,
|
|
785
|
+
async (tx) => {
|
|
786
|
+
const gone: Array<{ key: string; name: string | null }> = []
|
|
787
|
+
for (const node of await tx.trashedRoots())
|
|
788
|
+
if (await tx.delete(node.key)) gone.push({ key: node.key, name: node.text ?? null })
|
|
789
|
+
return gone
|
|
790
|
+
},
|
|
791
|
+
(gone): DocumentEvent[] => [
|
|
792
|
+
...gone.map((node): DocumentEvent => ({ type: 'deleted', ...node })),
|
|
793
|
+
{ type: 'binEmptied' },
|
|
794
|
+
],
|
|
795
|
+
)
|
|
796
|
+
changed()
|
|
797
|
+
return { ok: true, deleted: deleted.length }
|
|
798
|
+
},
|
|
799
|
+
|
|
800
|
+
async restore(
|
|
801
|
+
actor: Actor,
|
|
802
|
+
key: string,
|
|
803
|
+
targetKey: string | null | undefined,
|
|
804
|
+
): Promise<Result> {
|
|
805
|
+
if (!(await permits(actor, 'restore', 'bin'))) return forbidden()
|
|
806
|
+
try {
|
|
807
|
+
const result = await commit(
|
|
808
|
+
actor,
|
|
809
|
+
(tx) => tx.restore(key, targetKey),
|
|
810
|
+
(done) => (done === 'notFound' || done === 'invalid' ? [] : [{ type: 'restored', key }]),
|
|
811
|
+
)
|
|
812
|
+
if (result === 'notFound')
|
|
813
|
+
return refuse('notFound', 404, 'The document is not in the recycle bin.')
|
|
814
|
+
if (result === 'invalid')
|
|
815
|
+
return refuse(
|
|
816
|
+
'invalid',
|
|
817
|
+
400,
|
|
818
|
+
'The restore target does not exist or is in the recycle bin.',
|
|
819
|
+
)
|
|
820
|
+
changed()
|
|
821
|
+
return { ok: true }
|
|
822
|
+
} catch (error) {
|
|
823
|
+
return refusalFrom(error, options.refusalReason)
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
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
|
+
}
|
|
851
|
+
},
|
|
852
|
+
|
|
853
|
+
async copy(
|
|
854
|
+
actor: Actor,
|
|
855
|
+
key: string,
|
|
856
|
+
targetKey: string | null,
|
|
857
|
+
copyOptions: { includeDescendants: boolean },
|
|
858
|
+
): Promise<Result<{ key: string }>> {
|
|
859
|
+
if (!(await permits(actor, 'copy', key))) return forbidden()
|
|
860
|
+
if (!(await permits(actor, 'intoTarget', targetKey ?? 'root'))) return forbidden()
|
|
861
|
+
try {
|
|
862
|
+
const userId = await userIdOf(actor)
|
|
863
|
+
const copied = await commit(
|
|
864
|
+
actor,
|
|
865
|
+
(tx) =>
|
|
866
|
+
tx.copy(key, targetKey, { includeDescendants: copyOptions.includeDescendants, userId }),
|
|
867
|
+
(copyKey) => (copyKey ? [{ type: 'copied', key, copyKey }] : []),
|
|
868
|
+
)
|
|
869
|
+
if (!copied) return refuse('notFound', 404, 'The document or target could not be found.')
|
|
870
|
+
changed()
|
|
871
|
+
return { ok: true, key: copied }
|
|
872
|
+
} catch (error) {
|
|
873
|
+
return refusalFrom(error, options.refusalReason)
|
|
874
|
+
}
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
async sort(
|
|
878
|
+
actor: Actor,
|
|
879
|
+
parentKey: string | null,
|
|
880
|
+
sorting: ReadonlyArray<{ key: string; sortOrder: number }>,
|
|
881
|
+
): Promise<Result> {
|
|
882
|
+
if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
|
|
883
|
+
for (const item of sorting) if (!(await permits(actor, 'sort', item.key))) return forbidden()
|
|
884
|
+
try {
|
|
885
|
+
const before = await options.redirects?.captureChildren(parentKey)
|
|
886
|
+
const result = await commit(
|
|
887
|
+
actor,
|
|
888
|
+
(tx) => tx.sort(parentKey, sorting),
|
|
889
|
+
(done) => (done === 'notFound' ? [] : [{ type: 'sorted', parentKey }]),
|
|
890
|
+
)
|
|
891
|
+
if (result === 'notFound')
|
|
892
|
+
return refuse(
|
|
893
|
+
'notFound',
|
|
894
|
+
404,
|
|
895
|
+
'The parent, or one of the items, could not be found under it.',
|
|
896
|
+
)
|
|
897
|
+
changed()
|
|
898
|
+
await options.redirects?.commit(before)
|
|
899
|
+
return { ok: true }
|
|
900
|
+
} catch (error) {
|
|
901
|
+
return refusalFrom(error, options.refusalReason)
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
|
|
905
|
+
async sortChildrenBy(
|
|
906
|
+
actor: Actor,
|
|
907
|
+
parentKey: string | null,
|
|
908
|
+
field: 'Name' | 'CreateDate' | 'UpdateDate',
|
|
909
|
+
direction: 'Ascending' | 'Descending',
|
|
910
|
+
): Promise<Result> {
|
|
911
|
+
if (!(await permits(actor, 'sort', parentKey ?? 'root'))) return forbidden()
|
|
912
|
+
try {
|
|
913
|
+
const before = await options.redirects?.captureChildren(parentKey)
|
|
914
|
+
const result = await commit(
|
|
915
|
+
actor,
|
|
916
|
+
(tx) => tx.sortChildrenBy(parentKey, field, direction),
|
|
917
|
+
(done) => (done === 'notFound' ? [] : [{ type: 'sorted', parentKey }]),
|
|
918
|
+
)
|
|
919
|
+
if (result === 'notFound') return refuse('notFound', 404, NOT_FOUND)
|
|
920
|
+
changed()
|
|
921
|
+
await options.redirects?.commit(before)
|
|
922
|
+
return { ok: true }
|
|
923
|
+
} catch (error) {
|
|
924
|
+
return refusalFrom(error, options.refusalReason)
|
|
925
|
+
}
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
async rollback(
|
|
929
|
+
actor: Actor,
|
|
930
|
+
versionId: string,
|
|
931
|
+
precondition: Precondition = {},
|
|
932
|
+
): Promise<Result<{ revision?: string }>> {
|
|
933
|
+
const document = await repo.atVersion(versionId)
|
|
934
|
+
if (!document) return refuse('notFound', 404, 'The version could not be found.')
|
|
935
|
+
if (!(await permits(actor, 'rollback', document.document.key))) return forbidden()
|
|
936
|
+
try {
|
|
937
|
+
const result = await commit(
|
|
938
|
+
actor,
|
|
939
|
+
(tx) => tx.rollback(versionId, precondition),
|
|
940
|
+
(done) => (done ? [{ type: 'rolledBack', key: done.key, versionId }] : []),
|
|
941
|
+
)
|
|
942
|
+
if (!result) return refuse('notFound', 404, 'The version could not be found.')
|
|
943
|
+
changed()
|
|
944
|
+
return { ok: true, revision: result.revision }
|
|
945
|
+
} catch (error) {
|
|
946
|
+
return refusalFrom(error, options.refusalReason)
|
|
947
|
+
}
|
|
948
|
+
},
|
|
949
|
+
|
|
950
|
+
async setDomains(
|
|
951
|
+
actor: Actor,
|
|
952
|
+
key: string,
|
|
953
|
+
assignment: DomainAssignment,
|
|
954
|
+
): Promise<
|
|
955
|
+
| Exclude<SetDomainsResult, { status: 'ok' }>
|
|
956
|
+
| { status: 'notFound' }
|
|
957
|
+
| { status: 'forbidden' }
|
|
958
|
+
| { status: 'ok'; file?: string }
|
|
959
|
+
> {
|
|
960
|
+
const node = await nodes.byKey(key)
|
|
961
|
+
if (!node || node.objectType !== repo.objectType) return { status: 'notFound' }
|
|
962
|
+
if (!(await permits(actor, 'domains', key))) return { status: 'forbidden' }
|
|
963
|
+
const result = await commit(
|
|
964
|
+
actor,
|
|
965
|
+
async (_tx, txDb) => {
|
|
966
|
+
const set = await new DomainRepository(txDb).setForNode(node.id, assignment)
|
|
967
|
+
// Every node routes by hostname from its published cache, so they are all told.
|
|
968
|
+
if (set.status === 'ok') await repoInstruction(node.key, txDb)
|
|
969
|
+
return set
|
|
970
|
+
},
|
|
971
|
+
(set) => (set.status === 'ok' ? [{ type: 'domainsAssigned', key: node.key }] : []),
|
|
972
|
+
)
|
|
973
|
+
if (result.status !== 'ok') return result
|
|
974
|
+
changed()
|
|
975
|
+
const file = await options.afterDomains?.(node, assignment)
|
|
976
|
+
return file ? { status: 'ok', file } : { status: 'ok' }
|
|
977
|
+
},
|
|
978
|
+
|
|
979
|
+
async setPublicAccess(
|
|
980
|
+
actor: Actor,
|
|
981
|
+
key: string,
|
|
982
|
+
input: PublicAccessInput,
|
|
983
|
+
change: 'added' | 'set',
|
|
984
|
+
): Promise<PublicAccessStatus> {
|
|
985
|
+
if (!(await isDocument(key))) return 'content-not-found'
|
|
986
|
+
if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
|
|
987
|
+
if (!(await isDocument(input.loginDocumentKey))) return 'login-node-not-found'
|
|
988
|
+
if (!(await isDocument(input.errorDocumentKey))) return 'error-node-not-found'
|
|
989
|
+
const saved = await commit(
|
|
990
|
+
actor,
|
|
991
|
+
(_tx, txDb) =>
|
|
992
|
+
new PublicAccessRepository(txDb).save({
|
|
993
|
+
nodeKey: key,
|
|
994
|
+
loginNodeKey: input.loginDocumentKey,
|
|
995
|
+
errorNodeKey: input.errorDocumentKey,
|
|
996
|
+
memberGroupNames: input.memberGroupNames,
|
|
997
|
+
memberUserNames: input.memberUserNames,
|
|
998
|
+
}),
|
|
999
|
+
(done) => (done ? [{ type: 'publicAccessChanged', key, change }] : []),
|
|
1000
|
+
)
|
|
1001
|
+
if (!saved) return 'content-not-found'
|
|
1002
|
+
changed()
|
|
1003
|
+
return 'ok'
|
|
1004
|
+
},
|
|
1005
|
+
|
|
1006
|
+
async removePublicAccess(actor: Actor, key: string): Promise<PublicAccessStatus> {
|
|
1007
|
+
if (!(await isDocument(key))) return 'content-not-found'
|
|
1008
|
+
if (!(await permits(actor, 'publicAccess', key))) return 'forbidden'
|
|
1009
|
+
const removed = await commit(
|
|
1010
|
+
actor,
|
|
1011
|
+
(_tx, txDb) => new PublicAccessRepository(txDb).remove(key),
|
|
1012
|
+
(done) => (done ? [{ type: 'publicAccessChanged', key, change: 'removed' }] : []),
|
|
1013
|
+
)
|
|
1014
|
+
if (!removed) return 'entry-not-found'
|
|
1015
|
+
changed()
|
|
1016
|
+
return 'ok'
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
/** The repository these operations write through, for reads that go with them. */
|
|
1020
|
+
repository: repo,
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function isDocument(key: string): Promise<boolean> {
|
|
1024
|
+
const node = await nodes.byKey(key)
|
|
1025
|
+
return node?.objectType === repo.objectType
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** The content cache instruction other nodes rebuild on. */
|
|
1029
|
+
async function repoInstruction(key: string, on: Db = db): Promise<void> {
|
|
1030
|
+
await appendCacheInstruction(on, {
|
|
1031
|
+
kind: 'content',
|
|
1032
|
+
payload: { key },
|
|
1033
|
+
by: options.repository?.nodeId,
|
|
1034
|
+
})
|
|
1035
|
+
}
|
|
1036
|
+
}
|