@crombolo/server 0.0.0-stage → 0.9.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 +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 +432 -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/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -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 +62 -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 +297 -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 +1579 -0
- package/src/status.ts +246 -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,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Media section: items stored as documents of the media object type (the
|
|
3
|
+
* repository takes a kind), never published. An item's file is the path its
|
|
4
|
+
* `umbracoFile` holds, served from the media root; deleting an item for good
|
|
5
|
+
* deletes its file too.
|
|
6
|
+
*/
|
|
7
|
+
import type { MediaPort, Principal } from '@crombolo/api-management'
|
|
8
|
+
import {
|
|
9
|
+
type ContentTypeAggregate,
|
|
10
|
+
DEFAULT_UPLOAD_SETTINGS,
|
|
11
|
+
type DocumentAggregate,
|
|
12
|
+
IMAGE_CROPPER_EDITOR_ALIAS,
|
|
13
|
+
normaliseUuid,
|
|
14
|
+
ObjectTypes,
|
|
15
|
+
} from '@crombolo/core'
|
|
16
|
+
import {
|
|
17
|
+
appendCacheInstruction,
|
|
18
|
+
ContentTypeRepository,
|
|
19
|
+
DataTypeRepository,
|
|
20
|
+
type Db,
|
|
21
|
+
DocumentRepository,
|
|
22
|
+
type NodeRow,
|
|
23
|
+
type NodeSchemaState,
|
|
24
|
+
type ValueIntake,
|
|
25
|
+
WriteRejectedError,
|
|
26
|
+
} from '@crombolo/data'
|
|
27
|
+
import { TemporaryFileMissingError } from '../file-intake.ts'
|
|
28
|
+
import type { MediaFileStore } from '../media-files.ts'
|
|
29
|
+
|
|
30
|
+
/** The file path an item's `umbracoFile` holds, whether an upload or a cropper value. */
|
|
31
|
+
export function mediaFileOf(media: DocumentAggregate): string | null {
|
|
32
|
+
const value = media.values.find((v) => v.alias === 'umbracoFile')?.value
|
|
33
|
+
if (typeof value === 'string') return value || null
|
|
34
|
+
const src = (value as { src?: unknown } | null | undefined)?.src
|
|
35
|
+
return typeof src === 'string' && src ? src : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMediaPort(
|
|
39
|
+
db: Db,
|
|
40
|
+
files: MediaFileStore,
|
|
41
|
+
options: {
|
|
42
|
+
nodeState?: NodeSchemaState
|
|
43
|
+
nodeId?: string
|
|
44
|
+
valueIntake?: ValueIntake
|
|
45
|
+
/** Told after every change, so pages that show media refresh. */
|
|
46
|
+
onChange?: () => void | Promise<void>
|
|
47
|
+
} = {},
|
|
48
|
+
): MediaPort {
|
|
49
|
+
const changed = async () => {
|
|
50
|
+
await appendCacheInstruction(db, {
|
|
51
|
+
kind: 'content',
|
|
52
|
+
payload: { media: true },
|
|
53
|
+
by: options.nodeId,
|
|
54
|
+
})
|
|
55
|
+
await options.onChange?.()
|
|
56
|
+
}
|
|
57
|
+
const repo = new DocumentRepository(db, { ...options, kind: 'media' })
|
|
58
|
+
|
|
59
|
+
const userIdOf = async (principal: Principal): Promise<number | undefined> => {
|
|
60
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
61
|
+
principal.id,
|
|
62
|
+
])
|
|
63
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
64
|
+
}
|
|
65
|
+
const failure = (error: unknown) => {
|
|
66
|
+
if (error instanceof WriteRejectedError)
|
|
67
|
+
return { ok: false as const, reason: error.message, status: 409 }
|
|
68
|
+
if (error instanceof TemporaryFileMissingError)
|
|
69
|
+
return { ok: false as const, reason: error.message, status: 400 }
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
const notFound = (what = 'The media item could not be found.') => ({
|
|
73
|
+
ok: false as const,
|
|
74
|
+
reason: what,
|
|
75
|
+
status: 404,
|
|
76
|
+
})
|
|
77
|
+
const tree = async (rows: readonly NodeRow[]) => repo.treeItems(rows)
|
|
78
|
+
const mediaTypes = new ContentTypeRepository(db, { kind: 'media' })
|
|
79
|
+
const dataTypes = new DataTypeRepository(db)
|
|
80
|
+
|
|
81
|
+
/** Every media type with the data type of its `umbracoFile`, compositions included. */
|
|
82
|
+
const mediaTypesWithFile = async () => {
|
|
83
|
+
const all = await mediaTypes.all()
|
|
84
|
+
const byKey = new Map(all.map((t) => [t.key, t]))
|
|
85
|
+
const fileOf = (type: ContentTypeAggregate, seen = new Set<string>()): string | undefined => {
|
|
86
|
+
if (seen.has(type.key)) return undefined
|
|
87
|
+
seen.add(type.key)
|
|
88
|
+
const own = type.properties.find((p) => p.alias === 'umbracoFile')?.dataTypeKey
|
|
89
|
+
if (own) return own
|
|
90
|
+
for (const c of type.compositions) {
|
|
91
|
+
const composed = byKey.get(c.contentTypeKey)
|
|
92
|
+
const found = composed ? fileOf(composed, seen) : undefined
|
|
93
|
+
if (found) return found
|
|
94
|
+
}
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
const result = []
|
|
98
|
+
for (const type of all) {
|
|
99
|
+
const dataTypeKey = fileOf(type)
|
|
100
|
+
result.push({ type, file: dataTypeKey ? await dataTypes.byKey(dataTypeKey) : undefined })
|
|
101
|
+
}
|
|
102
|
+
return result
|
|
103
|
+
}
|
|
104
|
+
const allowedExtensions = (values: ReadonlyArray<{ alias: string; value: unknown }>) => {
|
|
105
|
+
const raw = values.find((v) => v.alias === 'fileExtensions')?.value
|
|
106
|
+
if (!Array.isArray(raw)) return []
|
|
107
|
+
return raw
|
|
108
|
+
.map((e) => (typeof e === 'string' ? e : (e as { value?: unknown })?.value))
|
|
109
|
+
.filter((e): e is string => typeof e === 'string')
|
|
110
|
+
.map((e) => e.replace(/^\./, '').toLowerCase())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every file in a branch, gathered before the branch is deleted. */
|
|
114
|
+
const filesIn = async (key: string): Promise<string[]> => {
|
|
115
|
+
const root = await repo.nodes.byKey(key)
|
|
116
|
+
if (!root) return []
|
|
117
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
118
|
+
'SELECT unique_id FROM node WHERE (id = ? OR path LIKE ?) AND node_object_type = ?',
|
|
119
|
+
[root.id, `${root.path},%`, ObjectTypes.Media],
|
|
120
|
+
)
|
|
121
|
+
const found: string[] = []
|
|
122
|
+
for (const row of rows) {
|
|
123
|
+
const media = await repo.byKey(normaliseUuid(String(row.unique_id)))
|
|
124
|
+
const file = media ? mediaFileOf(media) : null
|
|
125
|
+
if (file) found.push(file)
|
|
126
|
+
}
|
|
127
|
+
return found
|
|
128
|
+
}
|
|
129
|
+
const removeBranch = async (key: string) => {
|
|
130
|
+
const paths = await filesIn(key)
|
|
131
|
+
const removed = await repo.delete(key)
|
|
132
|
+
if (removed) {
|
|
133
|
+
for (const path of paths) await files.remove(path)
|
|
134
|
+
await changed()
|
|
135
|
+
}
|
|
136
|
+
return removed
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
byKey: (key) => repo.byKey(key),
|
|
141
|
+
validate: (input) => repo.validate(input, null),
|
|
142
|
+
|
|
143
|
+
async create(input, principal) {
|
|
144
|
+
try {
|
|
145
|
+
const created = await repo.create({
|
|
146
|
+
...input,
|
|
147
|
+
componentKey: null,
|
|
148
|
+
userId: await userIdOf(principal),
|
|
149
|
+
})
|
|
150
|
+
await changed()
|
|
151
|
+
return { ok: true as const, key: created.key }
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return failure(error)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async update(key, input, principal) {
|
|
158
|
+
try {
|
|
159
|
+
const before = await repo.byKey(key)
|
|
160
|
+
const updated = await repo.update({
|
|
161
|
+
...input,
|
|
162
|
+
key,
|
|
163
|
+
componentKey: null,
|
|
164
|
+
userId: await userIdOf(principal),
|
|
165
|
+
})
|
|
166
|
+
if (!updated) return notFound()
|
|
167
|
+
// A replaced file is no longer anyone's: Umbraco deletes it too.
|
|
168
|
+
const was = before ? mediaFileOf(before) : null
|
|
169
|
+
if (was && was !== mediaFileOf(updated)) await files.remove(was)
|
|
170
|
+
await changed()
|
|
171
|
+
return { ok: true as const }
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return failure(error)
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
remove: removeBranch,
|
|
178
|
+
async moveToRecycleBin(key) {
|
|
179
|
+
const moved = await repo.moveToRecycleBin(key)
|
|
180
|
+
if (moved) await changed()
|
|
181
|
+
return moved
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async move(key, targetKey) {
|
|
185
|
+
const result = await repo.move(key, targetKey)
|
|
186
|
+
if (result === 'moved') {
|
|
187
|
+
await changed()
|
|
188
|
+
return { ok: true as const }
|
|
189
|
+
}
|
|
190
|
+
return result === 'notFound'
|
|
191
|
+
? notFound('The media item or target could not be found.')
|
|
192
|
+
: { ok: false as const, reason: 'A media item cannot move below itself.', status: 400 }
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
async sort(parentKey, sorting) {
|
|
196
|
+
return (await repo.sort(parentKey, sorting)) === 'sorted'
|
|
197
|
+
? { ok: true as const }
|
|
198
|
+
: notFound('The parent, or one of the items, could not be found under it.')
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async sortChildrenBy(parentKey, field, direction) {
|
|
202
|
+
return (await repo.sortChildrenBy(parentKey, field, direction)) === 'sorted'
|
|
203
|
+
? { ok: true as const }
|
|
204
|
+
: notFound()
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
async restore(key, targetKey) {
|
|
208
|
+
const result = await repo.restore(key, targetKey)
|
|
209
|
+
if (result === 'restored') {
|
|
210
|
+
await changed()
|
|
211
|
+
return { ok: true as const }
|
|
212
|
+
}
|
|
213
|
+
return result === 'notFound'
|
|
214
|
+
? notFound('The media item is not in the recycle bin.')
|
|
215
|
+
: {
|
|
216
|
+
ok: false as const,
|
|
217
|
+
reason: 'The restore target does not exist or is in the recycle bin.',
|
|
218
|
+
status: 400,
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async emptyRecycleBin() {
|
|
223
|
+
for (const node of await repo.trashedRoots()) await removeBranch(node.key)
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
originalParent: (key) => repo.originalParent(key),
|
|
227
|
+
versions: (key) => repo.versions(key),
|
|
228
|
+
async systemUserKey() {
|
|
229
|
+
const rows = await db.query<{ key: string }>(
|
|
230
|
+
'SELECT key FROM user_account ORDER BY id LIMIT 1',
|
|
231
|
+
)
|
|
232
|
+
return rows[0] ? normaliseUuid(String(rows[0].key)) : null
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
async treeRoot(paging) {
|
|
236
|
+
const page = await repo.children(null, paging.skip, paging.take)
|
|
237
|
+
return { total: page.total, items: await tree(page.items) }
|
|
238
|
+
},
|
|
239
|
+
async treeChildren(parentKey, paging) {
|
|
240
|
+
const page = await repo.children(parentKey, paging.skip, paging.take)
|
|
241
|
+
return { total: page.total, items: await tree(page.items) }
|
|
242
|
+
},
|
|
243
|
+
treeAncestors: async (key) =>
|
|
244
|
+
tree((await repo.nodes.ancestors(key)).filter((row) => row.objectType === ObjectTypes.Media)),
|
|
245
|
+
async treeSiblings(target, before, after) {
|
|
246
|
+
const window = await repo.siblings(target, before, after)
|
|
247
|
+
if (!window) return undefined
|
|
248
|
+
return { ...window, items: await tree(window.items) }
|
|
249
|
+
},
|
|
250
|
+
async recycleBin(parentKey, paging) {
|
|
251
|
+
const page = await repo.trashed(parentKey, paging.skip, paging.take)
|
|
252
|
+
return { total: page.total, items: await tree(page.items) }
|
|
253
|
+
},
|
|
254
|
+
async items(keys) {
|
|
255
|
+
const rows = await Promise.all(keys.map((key) => repo.nodes.byKey(key)))
|
|
256
|
+
return tree(rows.filter((row): row is NodeRow => row?.objectType === ObjectTypes.Media))
|
|
257
|
+
},
|
|
258
|
+
async search(query, options) {
|
|
259
|
+
const page = await repo.search(query, options)
|
|
260
|
+
return { total: page.total, items: await tree(page.items) }
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async collection(parentKey, options) {
|
|
264
|
+
const children = await repo.children(parentKey, 0, Number.MAX_SAFE_INTEGER)
|
|
265
|
+
const filter = options.filter?.trim().toLowerCase()
|
|
266
|
+
const loaded: Array<DocumentAggregate & { sortOrder: number; creator: string | null }> = []
|
|
267
|
+
for (const node of children.items) {
|
|
268
|
+
const media = await repo.load(node)
|
|
269
|
+
if (!media) continue
|
|
270
|
+
const name = media.variants[0]?.name ?? node.text ?? ''
|
|
271
|
+
if (filter && !name.toLowerCase().includes(filter)) continue
|
|
272
|
+
const latest = (await repo.versions(node.key))[0]
|
|
273
|
+
const creator = latest?.userKey
|
|
274
|
+
? ((
|
|
275
|
+
await db.query<{ user_name: string }>(
|
|
276
|
+
'SELECT user_name FROM user_account WHERE key = ?',
|
|
277
|
+
[latest.userKey],
|
|
278
|
+
)
|
|
279
|
+
)[0]?.user_name ?? null)
|
|
280
|
+
: null
|
|
281
|
+
loaded.push({ ...media, sortOrder: node.sortOrder, creator })
|
|
282
|
+
}
|
|
283
|
+
const keyOf = (m: (typeof loaded)[number]): string | number => {
|
|
284
|
+
const variant = m.variants[0]
|
|
285
|
+
switch (options.orderBy) {
|
|
286
|
+
case 'name':
|
|
287
|
+
return (variant?.name ?? '').toLowerCase()
|
|
288
|
+
case 'createDate':
|
|
289
|
+
return variant?.createDate.getTime() ?? 0
|
|
290
|
+
case 'updateDate':
|
|
291
|
+
return variant?.updateDate.getTime() ?? 0
|
|
292
|
+
default:
|
|
293
|
+
return m.sortOrder
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
loaded.sort((a, b) => {
|
|
297
|
+
const x = keyOf(a)
|
|
298
|
+
const y = keyOf(b)
|
|
299
|
+
const order = x < y ? -1 : x > y ? 1 : 0
|
|
300
|
+
return options.orderDirection === 'Descending' ? -order : order
|
|
301
|
+
})
|
|
302
|
+
return {
|
|
303
|
+
total: loaded.length,
|
|
304
|
+
items: loaded.slice(options.skip, options.skip + options.take),
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
|
|
308
|
+
async typesForExtension(raw) {
|
|
309
|
+
const extension = raw.replace(/^\./, '').toLowerCase()
|
|
310
|
+
const results: Array<{ key: string; name: string; icon: string; matched: boolean }> = []
|
|
311
|
+
const catchAll: typeof results = []
|
|
312
|
+
for (const { type, file } of await mediaTypesWithFile()) {
|
|
313
|
+
if (!file) continue
|
|
314
|
+
const allowed = allowedExtensions(file.values)
|
|
315
|
+
const entry = { key: type.key, name: type.name, icon: type.icon }
|
|
316
|
+
if (file.editorAlias === IMAGE_CROPPER_EDITOR_ALIAS) {
|
|
317
|
+
if (DEFAULT_UPLOAD_SETTINGS.imageFileTypes.includes(extension))
|
|
318
|
+
results.push({ ...entry, matched: true })
|
|
319
|
+
} else if (allowed.includes(extension)) results.push({ ...entry, matched: true })
|
|
320
|
+
else if (allowed.length === 0) catchAll.push({ ...entry, matched: false })
|
|
321
|
+
}
|
|
322
|
+
const seen = new Set(results.map((r) => r.key))
|
|
323
|
+
return [...results, ...catchAll.filter((c) => !seen.has(c.key))]
|
|
324
|
+
},
|
|
325
|
+
|
|
326
|
+
async folderTypes() {
|
|
327
|
+
const all = await mediaTypesWithFile()
|
|
328
|
+
const folders = all
|
|
329
|
+
.filter(({ type, file }) => !file && type.allowedContentTypes.length > 0)
|
|
330
|
+
.map(({ type }) => type)
|
|
331
|
+
if (!folders.some((t) => t.alias === 'Folder')) {
|
|
332
|
+
const folder = all.find(({ type }) => type.alias === 'Folder')?.type
|
|
333
|
+
if (folder) folders.push(folder)
|
|
334
|
+
}
|
|
335
|
+
return folders.map((t) => ({ key: t.key, name: t.name, icon: t.icon }))
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
async urls(keys) {
|
|
339
|
+
const result = []
|
|
340
|
+
for (const key of keys) {
|
|
341
|
+
const media = await repo.byKey(key)
|
|
342
|
+
if (media) result.push({ key: media.key, url: mediaFileOf(media) })
|
|
343
|
+
}
|
|
344
|
+
return result
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Member groups are plain `node` rows with the MemberGroup object type — no
|
|
3
|
+
* table of their own, so this is the node repository with a name-uniqueness
|
|
4
|
+
* rule on top, as Umbraco's `MemberGroupService` has.
|
|
5
|
+
*/
|
|
6
|
+
import type { MemberGroupPort, MemberGroupStatus } from '@crombolo/api-management'
|
|
7
|
+
import { ObjectTypes, type Page, SystemNodes } from '@crombolo/core'
|
|
8
|
+
import { type Db, NodeRepository } from '@crombolo/data'
|
|
9
|
+
|
|
10
|
+
const TYPE = ObjectTypes.MemberGroup
|
|
11
|
+
|
|
12
|
+
export function createMemberGroupPort(db: Db): MemberGroupPort {
|
|
13
|
+
const nodes = new NodeRepository(db)
|
|
14
|
+
|
|
15
|
+
const named = async (name: string): Promise<{ key: string } | undefined> => {
|
|
16
|
+
const rows = await db.query<{ unique_id: string }>(
|
|
17
|
+
'SELECT n.unique_id FROM node n WHERE n.node_object_type = ? AND n.text = ?',
|
|
18
|
+
[TYPE, name],
|
|
19
|
+
)
|
|
20
|
+
const key = rows[0]?.unique_id
|
|
21
|
+
return key ? { key: String(key) } : undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const page = async (skip: number, take: number): Promise<Page<{ id: string; name: string }>> => {
|
|
25
|
+
const result = await nodes.roots(TYPE, skip, take)
|
|
26
|
+
return {
|
|
27
|
+
total: result.total,
|
|
28
|
+
items: result.items.map((row) => ({ id: row.key, name: row.text ?? '' })),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
list: (paging) => page(paging.skip, paging.take),
|
|
34
|
+
|
|
35
|
+
async byKey(key) {
|
|
36
|
+
const node = await nodes.byKey(key)
|
|
37
|
+
if (!node || node.objectType !== TYPE) return undefined
|
|
38
|
+
return { id: node.key, name: node.text ?? '' }
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async items(keys) {
|
|
42
|
+
const rows = await nodes.byKeys(keys)
|
|
43
|
+
return rows
|
|
44
|
+
.filter((row) => row.objectType === TYPE)
|
|
45
|
+
.map((row) => ({ id: row.key, name: row.text ?? '', flags: [] }))
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
async tree(paging) {
|
|
49
|
+
const result = await page(paging.skip, paging.take)
|
|
50
|
+
return {
|
|
51
|
+
total: result.total,
|
|
52
|
+
// A flat list: no group ever has children, and none has a parent.
|
|
53
|
+
items: result.items.map((item) => ({
|
|
54
|
+
...item,
|
|
55
|
+
parent: null,
|
|
56
|
+
hasChildren: false,
|
|
57
|
+
flags: [],
|
|
58
|
+
})),
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
async create(input) {
|
|
63
|
+
if (await named(input.name)) return { status: 'duplicate-name' }
|
|
64
|
+
const node = await nodes.create({
|
|
65
|
+
key: input.key,
|
|
66
|
+
parentId: SystemNodes.Root,
|
|
67
|
+
objectType: TYPE,
|
|
68
|
+
text: input.name,
|
|
69
|
+
})
|
|
70
|
+
return { status: 'ok', key: node.key }
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async rename(key, name): Promise<MemberGroupStatus> {
|
|
74
|
+
const node = await nodes.byKey(key)
|
|
75
|
+
if (!node || node.objectType !== TYPE) return 'not-found'
|
|
76
|
+
const clash = await named(name)
|
|
77
|
+
if (clash && clash.key !== node.key) return 'duplicate-name'
|
|
78
|
+
await nodes.rename(node.id, name)
|
|
79
|
+
return 'ok'
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async remove(key): Promise<MemberGroupStatus> {
|
|
83
|
+
const node = await nodes.byKey(key)
|
|
84
|
+
if (!node || node.objectType !== TYPE) return 'not-found'
|
|
85
|
+
await db.exec('DELETE FROM member_group_member WHERE member_group_id = ?', [node.id])
|
|
86
|
+
await nodes.delete(node.id)
|
|
87
|
+
return 'ok'
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Members section.
|
|
3
|
+
*
|
|
4
|
+
* A member is a content node with a sign-in facet, so the content half of a save
|
|
5
|
+
* is the same machinery documents use and the facet half is the `member` row.
|
|
6
|
+
* Three things are ours to enforce here rather than in the repository:
|
|
7
|
+
*
|
|
8
|
+
* - **Uniqueness.** Umbraco refuses a duplicate username or e-mail; the columns
|
|
9
|
+
* are case-insensitive in both dialects, so "A@b.com" and "a@b.com" clash.
|
|
10
|
+
* - **Sensitive values.** A member property marked sensitive is withheld from a
|
|
11
|
+
* user outside the Sensitive data group, compositions included.
|
|
12
|
+
* - **Passwords.** Plaintext arrives here and nowhere deeper; a change of one
|
|
13
|
+
* rotates the security stamp, which ends the member's sessions.
|
|
14
|
+
*
|
|
15
|
+
* `kind` is always `Default`: Umbraco reports `Api` only for a Delivery API
|
|
16
|
+
* client-credentials member and `ExternalOnly` only for one in its lightweight
|
|
17
|
+
* external-member table, and we have neither.
|
|
18
|
+
*/
|
|
19
|
+
import type {
|
|
20
|
+
MemberFilterCriteria,
|
|
21
|
+
MemberItem,
|
|
22
|
+
MemberPort,
|
|
23
|
+
MemberResponse,
|
|
24
|
+
Principal,
|
|
25
|
+
SaveMember,
|
|
26
|
+
} from '@crombolo/api-management'
|
|
27
|
+
import { hashPassword, passwordConfigJson, verifyPassword } from '@crombolo/auth'
|
|
28
|
+
import {
|
|
29
|
+
type ContentTypeAggregate,
|
|
30
|
+
hasAccessToSensitiveData,
|
|
31
|
+
normaliseUuid,
|
|
32
|
+
type Page,
|
|
33
|
+
} from '@crombolo/core'
|
|
34
|
+
import {
|
|
35
|
+
ContentTypeRepository,
|
|
36
|
+
type Db,
|
|
37
|
+
type DocumentRepositoryOptions,
|
|
38
|
+
type MemberCredentials,
|
|
39
|
+
MemberRepository,
|
|
40
|
+
type ValueIntake,
|
|
41
|
+
WriteRejectedError,
|
|
42
|
+
} from '@crombolo/data'
|
|
43
|
+
|
|
44
|
+
export interface MemberPortOptions extends DocumentRepositoryOptions {
|
|
45
|
+
valueIntake?: ValueIntake
|
|
46
|
+
/** How many failed sign-ins lock an account out; Umbraco's default is 5. */
|
|
47
|
+
maxFailedPasswordAttempts?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const notFound = () => ({
|
|
51
|
+
ok: false as const,
|
|
52
|
+
reason: 'The member could not be found.',
|
|
53
|
+
status: 404,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
|
|
57
|
+
const members = new MemberRepository(db, options)
|
|
58
|
+
const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
|
|
59
|
+
|
|
60
|
+
const userIdOf = async (principal: Principal): Promise<number | undefined> => {
|
|
61
|
+
const rows = await db.query<{ id: number }>('SELECT id FROM user_account WHERE key = ?', [
|
|
62
|
+
principal.id,
|
|
63
|
+
])
|
|
64
|
+
return rows[0] ? Number(rows[0].id) : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Sensitive aliases of a member type, following its compositions. */
|
|
68
|
+
const sensitiveAliases = async (typeKey: string): Promise<Set<string>> => {
|
|
69
|
+
const all = await memberTypes.all()
|
|
70
|
+
const byKey = new Map(all.map((type) => [normaliseUuid(type.key), type]))
|
|
71
|
+
const aliases = new Set<string>()
|
|
72
|
+
const walk = (type: ContentTypeAggregate | undefined, seen = new Set<string>()) => {
|
|
73
|
+
if (!type || seen.has(type.key)) return
|
|
74
|
+
seen.add(type.key)
|
|
75
|
+
for (const property of type.properties)
|
|
76
|
+
if (property.isSensitive === true) aliases.add(property.alias)
|
|
77
|
+
for (const composition of type.compositions)
|
|
78
|
+
walk(byKey.get(normaliseUuid(composition.contentTypeKey)), seen)
|
|
79
|
+
}
|
|
80
|
+
walk(byKey.get(normaliseUuid(typeKey)))
|
|
81
|
+
return aliases
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const toResponse = async (
|
|
85
|
+
member: MemberCredentials,
|
|
86
|
+
viewer: Principal,
|
|
87
|
+
): Promise<MemberResponse | undefined> => {
|
|
88
|
+
const content = await members.content.byKey(member.key)
|
|
89
|
+
if (!content) return undefined
|
|
90
|
+
const withheld = hasAccessToSensitiveData(viewer.groups)
|
|
91
|
+
? new Set<string>()
|
|
92
|
+
: await sensitiveAliases(member.contentTypeKey)
|
|
93
|
+
return {
|
|
94
|
+
id: member.key,
|
|
95
|
+
email: member.email,
|
|
96
|
+
username: member.username,
|
|
97
|
+
memberType: {
|
|
98
|
+
id: member.contentTypeKey,
|
|
99
|
+
icon: member.contentTypeIcon ?? 'icon-user',
|
|
100
|
+
collection: content.contentTypeCollectionKey
|
|
101
|
+
? { id: content.contentTypeCollectionKey }
|
|
102
|
+
: null,
|
|
103
|
+
},
|
|
104
|
+
isApproved: member.isApproved,
|
|
105
|
+
isLockedOut: member.isLockedOut,
|
|
106
|
+
// No second factor is offered yet, so no member has one enabled.
|
|
107
|
+
isTwoFactorEnabled: false,
|
|
108
|
+
failedPasswordAttempts: member.failedPasswordAttempts,
|
|
109
|
+
lastLoginDate: member.lastLoginDate?.toISOString() ?? null,
|
|
110
|
+
lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
|
|
111
|
+
lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
|
|
112
|
+
groups: await members.groupKeys(member.key),
|
|
113
|
+
kind: 'Default',
|
|
114
|
+
profileData: null,
|
|
115
|
+
flags: [],
|
|
116
|
+
values: content.values
|
|
117
|
+
.filter((value) => !withheld.has(value.alias))
|
|
118
|
+
.map((value) => ({
|
|
119
|
+
alias: value.alias,
|
|
120
|
+
culture: value.culture,
|
|
121
|
+
segment: value.segment,
|
|
122
|
+
value: value.value,
|
|
123
|
+
editorAlias: value.editorAlias ?? '',
|
|
124
|
+
})),
|
|
125
|
+
variants: content.variants.map((variant) => ({
|
|
126
|
+
culture: variant.culture,
|
|
127
|
+
segment: variant.segment,
|
|
128
|
+
name: variant.name,
|
|
129
|
+
createDate: variant.createDate.toISOString(),
|
|
130
|
+
updateDate: variant.updateDate.toISOString(),
|
|
131
|
+
})),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toItem = (member: MemberCredentials): MemberItem => ({
|
|
136
|
+
id: member.key,
|
|
137
|
+
memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
|
|
138
|
+
variants: [{ name: member.name, culture: null }],
|
|
139
|
+
kind: 'Default',
|
|
140
|
+
flags: [],
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const contentOf = (input: SaveMember, contentTypeKey: string) => ({
|
|
144
|
+
key: input.key,
|
|
145
|
+
contentTypeKey,
|
|
146
|
+
componentKey: null,
|
|
147
|
+
parentKey: null,
|
|
148
|
+
values: input.values,
|
|
149
|
+
variants:
|
|
150
|
+
input.variants.length > 0
|
|
151
|
+
? input.variants
|
|
152
|
+
: [{ culture: null, segment: null, name: input.username }],
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** Umbraco's duplicate checks, which the columns' case-insensitivity decides. */
|
|
156
|
+
const duplicate = async (input: SaveMember, exceptKey?: string) => {
|
|
157
|
+
if (await members.taken('username', input.username, exceptKey))
|
|
158
|
+
return {
|
|
159
|
+
ok: false as const,
|
|
160
|
+
reason: 'Another member already uses this username.',
|
|
161
|
+
status: 400,
|
|
162
|
+
}
|
|
163
|
+
if (await members.taken('email', input.email, exceptKey))
|
|
164
|
+
return {
|
|
165
|
+
ok: false as const,
|
|
166
|
+
reason: 'Another member already uses this e-mail address.',
|
|
167
|
+
status: 400,
|
|
168
|
+
}
|
|
169
|
+
return undefined
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const failure = (error: unknown) => {
|
|
173
|
+
if (error instanceof WriteRejectedError)
|
|
174
|
+
return { ok: false as const, reason: error.message, status: 409 }
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
async byKey(key, viewer) {
|
|
180
|
+
const member = await members.byKey(key)
|
|
181
|
+
return member ? toResponse(member, viewer) : undefined
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
validate: (input) => members.content.validate(contentOf(input, input.memberTypeKey), null),
|
|
185
|
+
|
|
186
|
+
async create(input, principal) {
|
|
187
|
+
const type = await memberTypes.byKey(input.memberTypeKey)
|
|
188
|
+
if (!type)
|
|
189
|
+
return { ok: false as const, reason: 'The member type could not be found.', status: 404 }
|
|
190
|
+
const clash = await duplicate(input)
|
|
191
|
+
if (clash) return clash
|
|
192
|
+
try {
|
|
193
|
+
const created = await members.create(
|
|
194
|
+
{ ...contentOf(input, type.key), userId: await userIdOf(principal) },
|
|
195
|
+
{
|
|
196
|
+
email: input.email,
|
|
197
|
+
username: input.username,
|
|
198
|
+
isApproved: input.isApproved,
|
|
199
|
+
isLockedOut: input.isLockedOut,
|
|
200
|
+
passwordHash: input.password ? await hashPassword(input.password) : null,
|
|
201
|
+
passwordConfig: input.password ? passwordConfigJson() : null,
|
|
202
|
+
groupKeys: input.groupKeys ?? [],
|
|
203
|
+
},
|
|
204
|
+
)
|
|
205
|
+
return { ok: true as const, key: created.key }
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return failure(error)
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async update(key, input, principal) {
|
|
212
|
+
const existing = await members.byKey(key)
|
|
213
|
+
if (!existing) return notFound()
|
|
214
|
+
const clash = await duplicate(input, key)
|
|
215
|
+
if (clash) return clash
|
|
216
|
+
// Umbraco requires the current password only when the editor supplies one;
|
|
217
|
+
// an administrator changing another member's password does not.
|
|
218
|
+
if (input.oldPassword && !(await verifyPassword(input.oldPassword, existing.passwordHash)))
|
|
219
|
+
return { ok: false as const, reason: 'The current password is not correct.', status: 400 }
|
|
220
|
+
try {
|
|
221
|
+
const updated = await members.update(
|
|
222
|
+
key,
|
|
223
|
+
{ ...contentOf(input, existing.contentTypeKey), userId: await userIdOf(principal) },
|
|
224
|
+
{
|
|
225
|
+
email: input.email,
|
|
226
|
+
username: input.username,
|
|
227
|
+
isApproved: input.isApproved,
|
|
228
|
+
isLockedOut: input.isLockedOut,
|
|
229
|
+
passwordHash: input.password ? await hashPassword(input.password) : undefined,
|
|
230
|
+
passwordConfig: input.password ? passwordConfigJson() : undefined,
|
|
231
|
+
groupKeys: input.groupKeys,
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
return updated ? { ok: true as const } : notFound()
|
|
235
|
+
} catch (error) {
|
|
236
|
+
return failure(error)
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
remove: (key) => members.delete(key),
|
|
241
|
+
|
|
242
|
+
async filter(criteria: MemberFilterCriteria, viewer): Promise<Page<MemberResponse>> {
|
|
243
|
+
const page = await members.filter(criteria)
|
|
244
|
+
const items: MemberResponse[] = []
|
|
245
|
+
for (const member of page.items) {
|
|
246
|
+
const response = await toResponse(member, viewer)
|
|
247
|
+
if (response) items.push(response)
|
|
248
|
+
}
|
|
249
|
+
return { total: page.total, items }
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
async items(keys) {
|
|
253
|
+
return (await members.items(keys)).map(toItem)
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async search(query, paging) {
|
|
257
|
+
const page = await members.search(query, paging)
|
|
258
|
+
return { total: page.total, items: page.items.map(toItem) }
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|