@crombolo/server 0.0.0-stage → 0.10.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/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library section: Umbraco 18's Elements.
|
|
3
|
+
*
|
|
4
|
+
* An element is a node facet exactly as a document is — `node` + `content` +
|
|
5
|
+
* `content_version` — so versioning, publishing and the recycle bin are machinery
|
|
6
|
+
* that already exists and the document repository takes `element` as a fifth
|
|
7
|
+
* `kind`. What an element does not have is a URL or a template: it is content
|
|
8
|
+
* something else points at, so there is no route to resolve and no view to pick.
|
|
9
|
+
*
|
|
10
|
+
* Its folders are plain `node` rows with the ElementContainer object type, carried
|
|
11
|
+
* by the generic folder repository, which is why they worked before elements
|
|
12
|
+
* themselves did.
|
|
13
|
+
*/
|
|
14
|
+
import type { ElementPort, Principal } from '@crombolo/api-management'
|
|
15
|
+
import { type ElementTreeItem, normaliseUuid, ObjectTypes, SystemNodes } from '@crombolo/core'
|
|
16
|
+
import {
|
|
17
|
+
appendCacheInstruction,
|
|
18
|
+
type Db,
|
|
19
|
+
DocumentRepository,
|
|
20
|
+
FolderRepository,
|
|
21
|
+
NodeRepository,
|
|
22
|
+
type NodeRow,
|
|
23
|
+
type NodeSchemaState,
|
|
24
|
+
PublishBlockedError,
|
|
25
|
+
type ValueIntake,
|
|
26
|
+
WriteRejectedError,
|
|
27
|
+
} from '@crombolo/data'
|
|
28
|
+
import { TemporaryFileMissingError } from '../file-intake.ts'
|
|
29
|
+
import { createFolderPort, createSettingsTree } from './content.ts'
|
|
30
|
+
|
|
31
|
+
export function createElementPort(
|
|
32
|
+
db: Db,
|
|
33
|
+
options: {
|
|
34
|
+
nodeState?: NodeSchemaState
|
|
35
|
+
nodeId?: string
|
|
36
|
+
valueIntake?: ValueIntake
|
|
37
|
+
/** Told after every change, so pages that resolved an element refresh. */
|
|
38
|
+
onChange?: () => void | Promise<void>
|
|
39
|
+
} = {},
|
|
40
|
+
): ElementPort {
|
|
41
|
+
const repo = new DocumentRepository(db, { ...options, kind: 'element' })
|
|
42
|
+
const nodes = new NodeRepository(db)
|
|
43
|
+
const folders = new FolderRepository(db, ObjectTypes.ElementContainer, ObjectTypes.Element)
|
|
44
|
+
|
|
45
|
+
const changed = async () => {
|
|
46
|
+
await appendCacheInstruction(db, {
|
|
47
|
+
kind: 'content',
|
|
48
|
+
payload: { elements: true },
|
|
49
|
+
by: options.nodeId,
|
|
50
|
+
})
|
|
51
|
+
await options.onChange?.()
|
|
52
|
+
}
|
|
53
|
+
const tree = createSettingsTree(nodes, ObjectTypes.ElementContainer, ObjectTypes.Element, changed)
|
|
54
|
+
|
|
55
|
+
const userIdOf = async (principal: Principal): Promise<number | undefined> => {
|
|
56
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
57
|
+
principal.id,
|
|
58
|
+
])
|
|
59
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
60
|
+
}
|
|
61
|
+
const notFound = (reason = 'The element could not be found.') => ({
|
|
62
|
+
ok: false as const,
|
|
63
|
+
reason,
|
|
64
|
+
status: 404,
|
|
65
|
+
})
|
|
66
|
+
const failure = (error: unknown) => {
|
|
67
|
+
if (error instanceof WriteRejectedError)
|
|
68
|
+
return { ok: false as const, reason: error.message, status: 409 }
|
|
69
|
+
if (error instanceof PublishBlockedError) return { ok: false as const, reason: error.message }
|
|
70
|
+
if (error instanceof TemporaryFileMissingError)
|
|
71
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
72
|
+
throw error
|
|
73
|
+
}
|
|
74
|
+
/** An element has no template, whatever a save sends. */
|
|
75
|
+
const saved = <T extends object>(input: T) => ({ ...input, componentKey: null })
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
folders: createFolderPort(folders, tree, changed),
|
|
79
|
+
|
|
80
|
+
// The Library tree mixes folders and elements, so these read `node` rows for
|
|
81
|
+
// both object types and shape each accordingly, rather than using the generic
|
|
82
|
+
// settings tree: an element has to carry its type and its variants' publish
|
|
83
|
+
// state, which a folder has none of.
|
|
84
|
+
treeRoot: (paging) => treePage(null, paging),
|
|
85
|
+
treeChildren: (parentKey, paging) => treePage(parentKey, paging),
|
|
86
|
+
async ancestors(descendantKey) {
|
|
87
|
+
const rows = (await nodes.ancestors(descendantKey)).filter(
|
|
88
|
+
(row) =>
|
|
89
|
+
row.objectType === ObjectTypes.Element || row.objectType === ObjectTypes.ElementContainer,
|
|
90
|
+
)
|
|
91
|
+
return shape(rows)
|
|
92
|
+
},
|
|
93
|
+
async items(keys) {
|
|
94
|
+
const rows = await Promise.all(keys.map((key) => nodes.byKey(key)))
|
|
95
|
+
return shape(
|
|
96
|
+
rows.filter(
|
|
97
|
+
(row): row is NodeRow =>
|
|
98
|
+
row?.objectType === ObjectTypes.Element ||
|
|
99
|
+
row?.objectType === ObjectTypes.ElementContainer,
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
async treeSiblings(target, before, after) {
|
|
104
|
+
const window = await nodes.siblings(
|
|
105
|
+
target,
|
|
106
|
+
[ObjectTypes.ElementContainer, ObjectTypes.Element],
|
|
107
|
+
before,
|
|
108
|
+
after,
|
|
109
|
+
)
|
|
110
|
+
if (!window) return undefined
|
|
111
|
+
return { ...window, items: await shape(window.items) }
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
byKey: (key) => repo.byKey(key),
|
|
115
|
+
byKeyPublished: (key) => repo.byKeyPublished(key),
|
|
116
|
+
validate: (input, cultures) => repo.validate(input, cultures),
|
|
117
|
+
|
|
118
|
+
async create(input, principal) {
|
|
119
|
+
try {
|
|
120
|
+
const created = await repo.create(saved({ ...input, userId: await userIdOf(principal) }))
|
|
121
|
+
await changed()
|
|
122
|
+
return { ok: true as const, key: created.key }
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return failure(error)
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async update(key, input, principal) {
|
|
129
|
+
try {
|
|
130
|
+
const updated = await repo.update(
|
|
131
|
+
saved({ ...input, key, userId: await userIdOf(principal) }),
|
|
132
|
+
)
|
|
133
|
+
if (!updated) return notFound()
|
|
134
|
+
await changed()
|
|
135
|
+
return { ok: true as const }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
return failure(error)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
async createAndPublish(input, cultures, principal) {
|
|
142
|
+
try {
|
|
143
|
+
const result = await repo.createAndPublish(
|
|
144
|
+
saved({ ...input, userId: await userIdOf(principal) }),
|
|
145
|
+
cultures,
|
|
146
|
+
)
|
|
147
|
+
if (!result.ok)
|
|
148
|
+
return {
|
|
149
|
+
ok: false as const,
|
|
150
|
+
reason: 'One or more properties did not pass validation',
|
|
151
|
+
status: 400,
|
|
152
|
+
errors: result.errors,
|
|
153
|
+
}
|
|
154
|
+
await changed()
|
|
155
|
+
return { ok: true as const, key: result.document.key }
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return failure(error)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async updateAndPublish(key, input, cultures, principal) {
|
|
162
|
+
try {
|
|
163
|
+
const result = await repo.updateAndPublish(
|
|
164
|
+
saved({ ...input, key, userId: await userIdOf(principal) }),
|
|
165
|
+
cultures,
|
|
166
|
+
)
|
|
167
|
+
if (!result) return notFound()
|
|
168
|
+
if (!result.ok)
|
|
169
|
+
return {
|
|
170
|
+
ok: false as const,
|
|
171
|
+
reason: 'One or more properties did not pass validation',
|
|
172
|
+
status: 400,
|
|
173
|
+
errors: result.errors,
|
|
174
|
+
}
|
|
175
|
+
await changed()
|
|
176
|
+
return { ok: true as const }
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return failure(error)
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async publish(key, cultures) {
|
|
183
|
+
try {
|
|
184
|
+
const published = await repo.publish(key, cultures)
|
|
185
|
+
if (!published) return notFound()
|
|
186
|
+
await changed()
|
|
187
|
+
return { ok: true as const }
|
|
188
|
+
} catch (error) {
|
|
189
|
+
return failure(error)
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async unpublish(key, cultures) {
|
|
194
|
+
try {
|
|
195
|
+
const result = await repo.unpublish(key, cultures)
|
|
196
|
+
if (!result) return notFound()
|
|
197
|
+
await changed()
|
|
198
|
+
return { ok: true as const }
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return failure(error)
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async copy(key, targetKey, copyOptions, principal) {
|
|
205
|
+
try {
|
|
206
|
+
const copied = await repo.copy(key, targetKey, {
|
|
207
|
+
includeDescendants: copyOptions.includeDescendants,
|
|
208
|
+
userId: await userIdOf(principal),
|
|
209
|
+
})
|
|
210
|
+
// The repository answers the new key, or undefined when either end is missing.
|
|
211
|
+
if (!copied) return notFound('The element or target could not be found.')
|
|
212
|
+
await changed()
|
|
213
|
+
return { ok: true as const, key: copied }
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return failure(error)
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async move(key, targetKey) {
|
|
220
|
+
const result = await repo.move(key, targetKey)
|
|
221
|
+
if (result === 'moved') {
|
|
222
|
+
await changed()
|
|
223
|
+
return { ok: true as const }
|
|
224
|
+
}
|
|
225
|
+
return result === 'notFound'
|
|
226
|
+
? notFound('The element or target could not be found.')
|
|
227
|
+
: {
|
|
228
|
+
ok: false as const,
|
|
229
|
+
reason: 'An element cannot be moved below itself or into the recycle bin.',
|
|
230
|
+
status: 400,
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async moveToRecycleBin(key) {
|
|
235
|
+
const moved = await repo.moveToRecycleBin(key)
|
|
236
|
+
if (moved) await changed()
|
|
237
|
+
return moved
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async remove(key) {
|
|
241
|
+
const removed = await repo.delete(key)
|
|
242
|
+
if (removed) await changed()
|
|
243
|
+
return removed
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async restore(key, targetKey) {
|
|
247
|
+
const result = await repo.restore(key, targetKey)
|
|
248
|
+
if (result === 'restored') {
|
|
249
|
+
await changed()
|
|
250
|
+
return { ok: true as const }
|
|
251
|
+
}
|
|
252
|
+
return result === 'notFound'
|
|
253
|
+
? notFound('The element is not in the recycle bin.')
|
|
254
|
+
: {
|
|
255
|
+
ok: false as const,
|
|
256
|
+
reason: 'The restore target does not exist or is in the recycle bin.',
|
|
257
|
+
status: 400,
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
async emptyRecycleBin() {
|
|
262
|
+
for (const node of await repo.trashedRoots()) {
|
|
263
|
+
if (await repo.delete(node.key)) await changed()
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
originalParent: (key) => repo.originalParent(key),
|
|
268
|
+
|
|
269
|
+
async recycleBin(parentKey, paging) {
|
|
270
|
+
const page = await repo.trashed(parentKey, paging.skip, paging.take)
|
|
271
|
+
return { total: page.total, items: await shape(page.items) }
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async recycleBinSiblings(target, before, after) {
|
|
275
|
+
const window = await repo.siblings(target, before, after)
|
|
276
|
+
if (!window) return undefined
|
|
277
|
+
return { ...window, items: await shape(window.items) }
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
async search(query, searchOptions) {
|
|
281
|
+
const page = await repo.search(query, searchOptions)
|
|
282
|
+
return { total: page.total, items: await shape(page.items) }
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
versions: (key) => repo.versions(key),
|
|
286
|
+
atVersion: (versionId) => repo.atVersion(versionId),
|
|
287
|
+
async rollback(versionId) {
|
|
288
|
+
const result = await repo.rollback(versionId)
|
|
289
|
+
if (result) await changed()
|
|
290
|
+
return result !== undefined
|
|
291
|
+
},
|
|
292
|
+
setPreventCleanup: (versionId, prevent) => repo.setPreventCleanup(versionId, prevent),
|
|
293
|
+
|
|
294
|
+
// The document repository's move refuses anything that is not an element, so a
|
|
295
|
+
// folder moves through the node repository instead, with the same two rules:
|
|
296
|
+
// the target must be a folder, and nothing may move inside itself.
|
|
297
|
+
async moveFolder(key, targetKey) {
|
|
298
|
+
const node = await nodes.byKey(key)
|
|
299
|
+
if (!node || node.objectType !== ObjectTypes.ElementContainer)
|
|
300
|
+
return notFound('The folder could not be found.')
|
|
301
|
+
const target = targetKey ? await nodes.byKey(targetKey) : undefined
|
|
302
|
+
if (targetKey && (!target || target.objectType !== ObjectTypes.ElementContainer))
|
|
303
|
+
return notFound('The target folder could not be found.')
|
|
304
|
+
if (target && (target.id === node.id || `${target.path},`.startsWith(`${node.path},`)))
|
|
305
|
+
return {
|
|
306
|
+
ok: false as const,
|
|
307
|
+
reason: 'A folder cannot be moved below itself.',
|
|
308
|
+
status: 400,
|
|
309
|
+
}
|
|
310
|
+
await nodes.move(node.id, target?.id ?? SystemNodes.Root)
|
|
311
|
+
await changed()
|
|
312
|
+
return { ok: true as const }
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
async folderToRecycleBin(key) {
|
|
316
|
+
const node = await nodes.byKey(key)
|
|
317
|
+
if (!node || node.objectType !== ObjectTypes.ElementContainer) return false
|
|
318
|
+
await nodes.move(node.id, SystemNodes.ElementRecycleBin)
|
|
319
|
+
// The whole branch is trashed, elements inside it included, and an element
|
|
320
|
+
// stays unpublished while it is in the bin.
|
|
321
|
+
const moved = await nodes.byKey(key)
|
|
322
|
+
await db.exec('UPDATE node SET trashed = ? WHERE id = ? OR path LIKE ?', [
|
|
323
|
+
db.dialect.boolValue(true),
|
|
324
|
+
node.id,
|
|
325
|
+
`${moved?.path ?? node.path},%`,
|
|
326
|
+
])
|
|
327
|
+
await db.exec(
|
|
328
|
+
`UPDATE document SET published = ?, edited = ?
|
|
329
|
+
WHERE node_id IN (SELECT id FROM node WHERE id = ? OR path LIKE ?)`,
|
|
330
|
+
[
|
|
331
|
+
db.dialect.boolValue(false),
|
|
332
|
+
db.dialect.boolValue(true),
|
|
333
|
+
node.id,
|
|
334
|
+
`${moved?.path ?? node.path},%`,
|
|
335
|
+
],
|
|
336
|
+
)
|
|
337
|
+
await changed()
|
|
338
|
+
return true
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async folderItems(keys) {
|
|
342
|
+
const rows = await Promise.all(keys.map((key) => nodes.byKey(key)))
|
|
343
|
+
return shape(
|
|
344
|
+
rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.ElementContainer),
|
|
345
|
+
)
|
|
346
|
+
},
|
|
347
|
+
async systemUserKey() {
|
|
348
|
+
const rows = await db.query<{ key: string }>(
|
|
349
|
+
'SELECT key FROM user_account ORDER BY id LIMIT 1',
|
|
350
|
+
)
|
|
351
|
+
return rows[0] ? normaliseUuid(String(rows[0].key)) : null
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Elements and folders as tree rows; a folder reports no type and no content. */
|
|
356
|
+
async function shape(rows: readonly NodeRow[]): Promise<ElementTreeItem[]> {
|
|
357
|
+
const items: ElementTreeItem[] = []
|
|
358
|
+
for (const row of rows) {
|
|
359
|
+
const isFolder = row.objectType === ObjectTypes.ElementContainer
|
|
360
|
+
const parent = row.parentId > 0 ? await nodes.byId(row.parentId) : undefined
|
|
361
|
+
const parentKey =
|
|
362
|
+
parent && parent.objectType !== ObjectTypes.ElementRecycleBin ? parent.key : null
|
|
363
|
+
if (isFolder) {
|
|
364
|
+
const inside = await nodes.childrenOf(
|
|
365
|
+
row.id,
|
|
366
|
+
[ObjectTypes.ElementContainer, ObjectTypes.Element],
|
|
367
|
+
0,
|
|
368
|
+
1,
|
|
369
|
+
)
|
|
370
|
+
items.push({
|
|
371
|
+
key: row.key,
|
|
372
|
+
name: row.text ?? '',
|
|
373
|
+
hasChildren: inside.total > 0,
|
|
374
|
+
parentKey,
|
|
375
|
+
contentTypeKey: '',
|
|
376
|
+
icon: 'icon-folder',
|
|
377
|
+
isTrashed: row.trashed,
|
|
378
|
+
createDate: row.createDate ?? new Date(),
|
|
379
|
+
ancestorKeys: [],
|
|
380
|
+
variants: [{ culture: null, name: row.text ?? '', state: 'NotCreated' }],
|
|
381
|
+
isFolder: true,
|
|
382
|
+
})
|
|
383
|
+
continue
|
|
384
|
+
}
|
|
385
|
+
const [item] = await repo.treeItems([row])
|
|
386
|
+
if (item) items.push({ ...item, parentKey, isFolder: false })
|
|
387
|
+
}
|
|
388
|
+
return items
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function treePage(
|
|
392
|
+
parentKey: string | null,
|
|
393
|
+
paging: { skip: number; take: number; foldersOnly?: boolean },
|
|
394
|
+
) {
|
|
395
|
+
const parentId = parentKey ? (await nodes.byKey(parentKey))?.id : SystemNodes.Root
|
|
396
|
+
if (parentId === undefined) return { total: 0, items: [] }
|
|
397
|
+
const types = paging.foldersOnly
|
|
398
|
+
? [ObjectTypes.ElementContainer]
|
|
399
|
+
: [ObjectTypes.ElementContainer, ObjectTypes.Element]
|
|
400
|
+
const result = await nodes.childrenOf(parentId, types, paging.skip, paging.take)
|
|
401
|
+
return { total: result.total, items: await shape(result.items) }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A file-system area on disk — components, stylesheets or scripts — as the
|
|
3
|
+
* Management API sees it: paths relative to the area's root, which never
|
|
4
|
+
* escape it, and only files of the area's extension.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import type { FileEntry, FileResult, FileSystemPort } from '@crombolo/api-management'
|
|
10
|
+
|
|
11
|
+
export interface FileAreaOptions {
|
|
12
|
+
/** The extension files are stored with, e.g. `.css`. */
|
|
13
|
+
extension: string
|
|
14
|
+
/** Extensions a name may arrive with that are stored as `extension` instead. */
|
|
15
|
+
rewrites?: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const INVALID_CHARACTERS = /[<>:"|?*\\/]/
|
|
19
|
+
|
|
20
|
+
/** A name no file system objects to: no path characters and no control characters. */
|
|
21
|
+
const invalidName = (name: string) =>
|
|
22
|
+
INVALID_CHARACTERS.test(name) || [...name].some((c) => (c.codePointAt(0) ?? 0) < 0x20)
|
|
23
|
+
|
|
24
|
+
/** A path's segments, or undefined when it is not a plain path below the root. */
|
|
25
|
+
function segmentsOf(path: string | null): string[] | undefined {
|
|
26
|
+
if (path === null) return []
|
|
27
|
+
const trimmed = path.replace(/^\/+|\/+$/g, '')
|
|
28
|
+
if (trimmed === '') return []
|
|
29
|
+
const segments = trimmed.split('/')
|
|
30
|
+
return segments.every((s) => s !== '' && s !== '.' && s !== '..' && !s.includes('\\'))
|
|
31
|
+
? segments
|
|
32
|
+
: undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const toPath = (segments: readonly string[]) => `/${segments.join('/')}`
|
|
36
|
+
|
|
37
|
+
export function createFileSystemPort(root: string, options: FileAreaOptions): FileSystemPort {
|
|
38
|
+
const extension = options.extension.toLowerCase()
|
|
39
|
+
const locate = (path: string | null) => {
|
|
40
|
+
const segments = segmentsOf(path)
|
|
41
|
+
return segments ? { segments, file: join(root, ...segments) } : undefined
|
|
42
|
+
}
|
|
43
|
+
const kindOf = async (file: string): Promise<'file' | 'folder' | undefined> => {
|
|
44
|
+
try {
|
|
45
|
+
const info = await stat(file)
|
|
46
|
+
return info.isDirectory() ? 'folder' : info.isFile() ? 'file' : undefined
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const visible = (name: string, isFolder: boolean) =>
|
|
52
|
+
!name.startsWith('.') && (isFolder || name.toLowerCase().endsWith(extension))
|
|
53
|
+
|
|
54
|
+
/** The name as stored: with the area's extension, whichever accepted one it came with. */
|
|
55
|
+
const fileName = (name: string): string | 'InvalidName' | 'InvalidFileExtension' => {
|
|
56
|
+
const trimmed = name.trim()
|
|
57
|
+
if (!trimmed || trimmed.startsWith('.') || invalidName(trimmed)) return 'InvalidName'
|
|
58
|
+
const lower = trimmed.toLowerCase()
|
|
59
|
+
if (lower.endsWith(extension)) return trimmed
|
|
60
|
+
const rewrite = options.rewrites?.find((r) => lower.endsWith(r.toLowerCase()))
|
|
61
|
+
if (rewrite) return `${trimmed.slice(0, -rewrite.length)}${options.extension}`
|
|
62
|
+
return 'InvalidFileExtension'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const entryAt = async (segments: string[]): Promise<FileEntry | undefined> => {
|
|
66
|
+
const file = join(root, ...segments)
|
|
67
|
+
const kind = await kindOf(file)
|
|
68
|
+
const name = segments[segments.length - 1]
|
|
69
|
+
if (!kind || !name || !visible(name, kind === 'folder')) return undefined
|
|
70
|
+
const parent = segments.slice(0, -1)
|
|
71
|
+
return {
|
|
72
|
+
path: toPath(segments),
|
|
73
|
+
name,
|
|
74
|
+
parentPath: parent.length > 0 ? toPath(parent) : null,
|
|
75
|
+
isFolder: kind === 'folder',
|
|
76
|
+
hasChildren:
|
|
77
|
+
kind === 'folder' &&
|
|
78
|
+
(await readdir(file, { withFileTypes: true })).some((d) =>
|
|
79
|
+
visible(d.name, d.isDirectory()),
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const parentFolder = async (parentPath: string | null) => {
|
|
85
|
+
const parent = locate(parentPath)
|
|
86
|
+
if (!parent) return undefined
|
|
87
|
+
if (parent.segments.length === 0) {
|
|
88
|
+
await mkdir(root, { recursive: true })
|
|
89
|
+
return parent
|
|
90
|
+
}
|
|
91
|
+
return (await kindOf(parent.file)) === 'folder' ? parent : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async children(parentPath) {
|
|
96
|
+
const parent = locate(parentPath)
|
|
97
|
+
if (!parent) return undefined
|
|
98
|
+
if (parent.segments.length === 0 && !existsSync(root)) return []
|
|
99
|
+
if ((await kindOf(parent.file)) !== 'folder') return undefined
|
|
100
|
+
const entries: FileEntry[] = []
|
|
101
|
+
for (const dirent of await readdir(parent.file, { withFileTypes: true })) {
|
|
102
|
+
const entry = await entryAt([...parent.segments, dirent.name])
|
|
103
|
+
if (entry) entries.push(entry)
|
|
104
|
+
}
|
|
105
|
+
return entries.sort(
|
|
106
|
+
(a, b) =>
|
|
107
|
+
Number(b.isFolder) - Number(a.isFolder) ||
|
|
108
|
+
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
|
109
|
+
)
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async entry(path) {
|
|
113
|
+
const found = locate(path)
|
|
114
|
+
return found && found.segments.length > 0 ? entryAt(found.segments) : undefined
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async read(path) {
|
|
118
|
+
const found = locate(path)
|
|
119
|
+
if (!found || found.segments.length === 0) return undefined
|
|
120
|
+
const entry = await entryAt(found.segments)
|
|
121
|
+
if (!entry || entry.isFolder) return undefined
|
|
122
|
+
return {
|
|
123
|
+
path: entry.path,
|
|
124
|
+
name: entry.name,
|
|
125
|
+
parentPath: entry.parentPath,
|
|
126
|
+
content: await readFile(found.file, 'utf8'),
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async create(parentPath, name, content): Promise<FileResult> {
|
|
131
|
+
const stored = fileName(name)
|
|
132
|
+
if (stored === 'InvalidName' || stored === 'InvalidFileExtension')
|
|
133
|
+
return { ok: false, status: stored }
|
|
134
|
+
const parent = await parentFolder(parentPath)
|
|
135
|
+
if (!parent) return { ok: false, status: 'ParentNotFound' }
|
|
136
|
+
const segments = [...parent.segments, stored]
|
|
137
|
+
const file = join(root, ...segments)
|
|
138
|
+
if (existsSync(file)) return { ok: false, status: 'AlreadyExists' }
|
|
139
|
+
await writeFile(file, content, 'utf8')
|
|
140
|
+
return { ok: true, path: toPath(segments) }
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
async update(path, content): Promise<FileResult> {
|
|
144
|
+
const found = locate(path)
|
|
145
|
+
const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
|
|
146
|
+
if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
|
|
147
|
+
await writeFile(found.file, content, 'utf8')
|
|
148
|
+
return { ok: true, path: entry.path }
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async rename(path, name): Promise<FileResult> {
|
|
152
|
+
const found = locate(path)
|
|
153
|
+
const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
|
|
154
|
+
if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
|
|
155
|
+
const stored = fileName(name)
|
|
156
|
+
if (stored === 'InvalidName' || stored === 'InvalidFileExtension')
|
|
157
|
+
return { ok: false, status: stored }
|
|
158
|
+
const segments = [...found.segments.slice(0, -1), stored]
|
|
159
|
+
const target = join(root, ...segments)
|
|
160
|
+
if (target !== found.file && existsSync(target)) return { ok: false, status: 'AlreadyExists' }
|
|
161
|
+
await rename(found.file, target)
|
|
162
|
+
return { ok: true, path: toPath(segments) }
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async delete(path): Promise<FileResult> {
|
|
166
|
+
const found = locate(path)
|
|
167
|
+
const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
|
|
168
|
+
if (!found || !entry || entry.isFolder) return { ok: false, status: 'NotFound' }
|
|
169
|
+
await rm(found.file)
|
|
170
|
+
return { ok: true, path: entry.path }
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async createFolder(parentPath, name): Promise<FileResult> {
|
|
174
|
+
const trimmed = name.trim()
|
|
175
|
+
if (!trimmed || trimmed.startsWith('.') || invalidName(trimmed))
|
|
176
|
+
return { ok: false, status: 'InvalidName' }
|
|
177
|
+
const parent = await parentFolder(parentPath)
|
|
178
|
+
if (!parent) return { ok: false, status: 'ParentNotFound' }
|
|
179
|
+
const segments = [...parent.segments, trimmed]
|
|
180
|
+
const folder = join(root, ...segments)
|
|
181
|
+
if (existsSync(folder)) return { ok: false, status: 'AlreadyExists' }
|
|
182
|
+
await mkdir(folder)
|
|
183
|
+
return { ok: true, path: toPath(segments) }
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async deleteFolder(path): Promise<FileResult> {
|
|
187
|
+
const found = locate(path)
|
|
188
|
+
const entry = found && found.segments.length > 0 ? await entryAt(found.segments) : undefined
|
|
189
|
+
if (!found || !entry?.isFolder) return { ok: false, status: 'NotFound' }
|
|
190
|
+
if ((await readdir(found.file)).length > 0) return { ok: false, status: 'NotEmpty' }
|
|
191
|
+
await rmdir(found.file)
|
|
192
|
+
return { ok: true, path: entry.path }
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|