@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
package/src/ports.ts ADDED
@@ -0,0 +1,553 @@
1
+ /** Implementations of the Management API ports. */
2
+ import type {
3
+ CurrentUserPort,
4
+ LocalizationPort,
5
+ ManagementApiDeps,
6
+ ManifestPort,
7
+ NodeLookup,
8
+ Principal,
9
+ ServerPort,
10
+ TemporaryFilePort,
11
+ UserItemPort,
12
+ } from '@crombolo/api-management'
13
+ import { AuthStore } from '@crombolo/auth'
14
+ import {
15
+ ASSISTANT_MANIFEST_ID,
16
+ type BackOfficePaths,
17
+ collectManifests,
18
+ type ExtensionRegistry,
19
+ toResponseModels,
20
+ } from '@crombolo/backoffice-host'
21
+ import {
22
+ CORE_SECTION_ALIASES,
23
+ DEFAULT_UPLOAD_SETTINGS,
24
+ hasAccessToSensitiveData,
25
+ isUploadAllowed,
26
+ permissionsForPath,
27
+ } from '@crombolo/core'
28
+ import {
29
+ ContentTypeRepository,
30
+ type Db,
31
+ LanguageRepository,
32
+ RedirectRepository,
33
+ TagRepository,
34
+ UserRepository,
35
+ } from '@crombolo/data'
36
+ import type { PublishedCache } from '@crombolo/render'
37
+ import { createNodeLookup } from './access.ts'
38
+ import { createBlueprintPort } from './adapters/blueprints.ts'
39
+ import { createPackagePort } from './adapters/bundles.ts'
40
+ import {
41
+ createContentTypePort,
42
+ createDataTypePort,
43
+ createTemplatePort,
44
+ underSchemaLock,
45
+ } from './adapters/content.ts'
46
+ import { createDictionaryPort } from './adapters/dictionary.ts'
47
+ import { createDocumentPort } from './adapters/documents.ts'
48
+ import { createElementPort } from './adapters/elements.ts'
49
+ import { createFileSystemPort } from './adapters/file-system.ts'
50
+ import { createMediaPort } from './adapters/media.ts'
51
+ import { createMemberGroupPort } from './adapters/member-groups.ts'
52
+ import { createMemberPort } from './adapters/members.ts'
53
+ import { createPublicAccessPort } from './adapters/public-access.ts'
54
+ import { createRedirectPort } from './adapters/redirects.ts'
55
+ import { createReferencePort } from './adapters/references.ts'
56
+ import {
57
+ createSchemaFileWriter,
58
+ type SchemaFiles,
59
+ type SchemaFileWriter,
60
+ } from './adapters/schema-files.ts'
61
+ import { createTemplateFileStore } from './adapters/template-files.ts'
62
+ import { createTemplateQueryPort } from './adapters/template-query.ts'
63
+ import { avatarUrls, createUserPorts } from './adapters/users.ts'
64
+ import { COMPONENT_SNIPPETS } from './component-snippets.ts'
65
+ import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
66
+ import { type EmailPort, resolveEmailPort } from './email.ts'
67
+ import { createFileIntake } from './file-intake.ts'
68
+ import { createLogViewerPort } from './log-viewer.ts'
69
+ import { logger } from './logging.ts'
70
+ import { MediaFileStore } from './media-files.ts'
71
+ import { createModelsBuilderPort } from './models-builder.ts'
72
+ import { createOEmbedService } from './oembed.ts'
73
+ import { createRedirectTracker } from './redirects.ts'
74
+ import { componentAliasesIn, type SchemaBoot } from './schema.ts'
75
+ import { publishSchema } from './schema-store.ts'
76
+
77
+ /** Re-exported for tests; the canonical list lives in @crombolo/core. */
78
+ export const CORE_SECTIONS = CORE_SECTION_ALIASES
79
+
80
+ const CULTURES = [
81
+ { name: 'en-US', englishName: 'English (United States)' },
82
+ { name: 'en-GB', englishName: 'English (United Kingdom)' },
83
+ { name: 'da-DK', englishName: 'Danish (Denmark)' },
84
+ { name: 'de-DE', englishName: 'German (Germany)' },
85
+ { name: 'fr-FR', englishName: 'French (France)' },
86
+ { name: 'nl-NL', englishName: 'Dutch (Netherlands)' },
87
+ { name: 'sv-SE', englishName: 'Swedish (Sweden)' },
88
+ ]
89
+
90
+ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): ServerPort {
91
+ return {
92
+ // Phase 6 derives this from migration state; until then the site is always up.
93
+ status: () => ({ serverStatus: 'Run' }),
94
+ configuration: () => ({
95
+ // Reported false when no link could be delivered, so the client does not
96
+ // offer a reset that goes nowhere. `email.ts` has the reason.
97
+ allowPasswordReset: config.allowPasswordReset && canSendUserLinks,
98
+ versionCheckPeriod: config.versionCheckPeriod,
99
+ allowLocalLogin: config.allowLocalLogin,
100
+ umbracoCssPath: config.umbracoCssPath,
101
+ signalR: { skipNegotiation: config.signalRSkipNegotiation },
102
+ }),
103
+ information: () => ({
104
+ version: VERSION,
105
+ assemblyVersion: VERSION,
106
+ baseUtcOffset: '00:00:00',
107
+ runtimeMode: config.development ? 'BackofficeDevelopment' : 'Production',
108
+ }),
109
+ }
110
+ }
111
+
112
+ function createManifestPort(
113
+ paths: BackOfficePaths,
114
+ extensions: ExtensionRegistry,
115
+ omit: readonly string[],
116
+ ): ManifestPort {
117
+ return {
118
+ list: (visibility) =>
119
+ toResponseModels(
120
+ collectManifests(paths, extensions.list(), { omit }).all,
121
+ paths.cacheBustHash,
122
+ visibility,
123
+ ),
124
+ }
125
+ }
126
+
127
+ function createLocalizationPort(db: Db | undefined, files?: SchemaFileWriter): LocalizationPort {
128
+ const repo = db ? new LanguageRepository(db) : undefined
129
+ const fallback = [
130
+ {
131
+ isoCode: 'en-US',
132
+ name: 'English (United States)',
133
+ isDefault: true,
134
+ isMandatory: true,
135
+ fallbackIsoCode: null,
136
+ },
137
+ ]
138
+ const port: LocalizationPort = {
139
+ cultures: () => CULTURES,
140
+ allLanguages: async () =>
141
+ repo
142
+ ? (await repo.all()).map((l) => ({
143
+ isoCode: l.isoCode,
144
+ name: l.cultureName,
145
+ isDefault: l.isDefault,
146
+ isMandatory: l.isMandatory,
147
+ fallbackIsoCode: l.fallbackIsoCode,
148
+ }))
149
+ : fallback,
150
+ async language(isoCode) {
151
+ const found = repo ? await repo.byIso(isoCode) : undefined
152
+ if (!found) return repo ? undefined : fallback.find((l) => l.isoCode === isoCode)
153
+ return {
154
+ isoCode: found.isoCode,
155
+ name: found.cultureName,
156
+ isDefault: found.isDefault,
157
+ isMandatory: found.isMandatory,
158
+ fallbackIsoCode: found.fallbackIsoCode,
159
+ }
160
+ },
161
+ async saveLanguage(language) {
162
+ if (!repo) return
163
+ await repo.save({
164
+ isoCode: language.isoCode,
165
+ cultureName: language.name,
166
+ isDefault: language.isDefault,
167
+ isMandatory: language.isMandatory,
168
+ fallbackIsoCode: language.fallbackIsoCode,
169
+ })
170
+ await files?.writeLanguages()
171
+ },
172
+ async deleteLanguage(isoCode) {
173
+ if (!repo) return 'not-found'
174
+ const result = await repo.delete(isoCode)
175
+ if (result === 'deleted') await files?.writeLanguages()
176
+ return result
177
+ },
178
+ }
179
+ return db ? underSchemaLock(db, port, ['saveLanguage', 'deleteLanguage']) : port
180
+ }
181
+
182
+ function createCurrentUserPort(
183
+ config: CromboloConfig,
184
+ db: Db | undefined,
185
+ nodes: NodeLookup | undefined,
186
+ ): CurrentUserPort {
187
+ const languages = db ? new LanguageRepository(db) : undefined
188
+ return {
189
+ async get(principal: Principal) {
190
+ // The verbs on every node a group sets explicitly, calculated as a check would
191
+ const explicit = [...new Set(principal.groups.flatMap((g) => [...g.granular.keys()]))]
192
+ const found = nodes && explicit.length > 0 ? await nodes.documents(explicit) : new Map()
193
+ const permissions = explicit.flatMap((key) => {
194
+ const node = found.get(key)
195
+ return node
196
+ ? [
197
+ {
198
+ $type: 'DocumentPermissionPresentationModel' as const,
199
+ document: { id: key },
200
+ verbs: [...permissionsForPath(principal.groups, node.chain)],
201
+ },
202
+ ]
203
+ : []
204
+ })
205
+ const { document, media, element } = principal.startNodes
206
+ return {
207
+ id: principal.id,
208
+ userName: principal.userName,
209
+ name: principal.name,
210
+ email: principal.email,
211
+ isAdmin: principal.isAdmin,
212
+ languageIsoCode: principal.languageIsoCode ?? config.defaultUiLanguage,
213
+ documentStartNodeIds: document.keys.map((id) => ({ id })),
214
+ hasDocumentRootAccess: document.root,
215
+ mediaStartNodeIds: media.keys.map((id) => ({ id })),
216
+ hasMediaRootAccess: media.root,
217
+ elementStartNodeIds: element.keys.map((id) => ({ id })),
218
+ hasElementRootAccess: element.root,
219
+ avatarUrls: principal.avatarUrls,
220
+ languages:
221
+ principal.hasAccessToAllLanguages && languages
222
+ ? (await languages.all()).map((l) => l.isoCode)
223
+ : principal.languages,
224
+ hasAccessToAllLanguages: principal.hasAccessToAllLanguages,
225
+ hasAccessToSensitiveData: hasAccessToSensitiveData(principal.groups),
226
+ fallbackPermissions: principal.permissions,
227
+ permissions,
228
+ allowedSections: principal.allowedSections,
229
+ userGroupIds: principal.groupKeys.map((id) => ({ id })),
230
+ }
231
+ },
232
+ }
233
+ }
234
+
235
+ function createUserItemPort(db: Db): UserItemPort {
236
+ const users = new UserRepository(db)
237
+ return {
238
+ async items(keys) {
239
+ return (await users.byKeys(keys)).map((user) => ({
240
+ key: user.key,
241
+ name: user.name,
242
+ avatarUrls: avatarUrls(user.avatar),
243
+ kind: user.kind,
244
+ }))
245
+ },
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Invitations and password resets through the e-mail port.
251
+ *
252
+ * Plain text on purpose: both messages are one sentence and a link, and an HTML
253
+ * template for them would be a thing to maintain for no gain.
254
+ *
255
+ * A delivery failure throws. `sendLink` turns that into `false`, which is how
256
+ * resending an invitation reports `CannotInvite` instead of claiming it went.
257
+ */
258
+ const userLinkViaEmail =
259
+ (port: EmailPort, siteName: string): UserLinkSender =>
260
+ async ({ kind, to, link, message }) => {
261
+ const invite = kind === 'invite'
262
+ const result = await port.send({
263
+ to: [{ email: to.email, name: to.name }],
264
+ subject: invite ? `You have been invited to ${siteName}` : `Reset your ${siteName} password`,
265
+ text: [
266
+ `Hello ${to.name},`,
267
+ '',
268
+ invite
269
+ ? `You have been invited to the ${siteName} backoffice.`
270
+ : `Someone asked to reset the password for your ${siteName} account.`,
271
+ ...(message ? ['', message] : []),
272
+ '',
273
+ invite ? 'Accept the invitation:' : 'Reset your password:',
274
+ link,
275
+ '',
276
+ invite
277
+ ? 'The link expires, so accept it soon.'
278
+ : 'If this was not you, nothing has changed and you can ignore this message.',
279
+ ].join('\n'),
280
+ })
281
+ if (!result.ok) {
282
+ logger('users').error('Could not send the {kind} e-mail to {email}: {detail}', {
283
+ kind,
284
+ email: to.email,
285
+ detail: result.error,
286
+ })
287
+ throw new Error(result.error)
288
+ }
289
+ }
290
+
291
+ /** Development's stand-in for e-mail: the link a person would have been sent, on the console. */
292
+ const logUserLink: UserLinkSender = async ({ kind, to, link }) => {
293
+ logger('users').info(
294
+ `${kind === 'invite' ? 'Invitation' : 'Password reset'} for {email}: {link}`,
295
+ {
296
+ email: to.email,
297
+ link,
298
+ },
299
+ )
300
+ }
301
+
302
+ /**
303
+ * How a user link is delivered, given the port that is available.
304
+ *
305
+ * The console keeps a developer's invitation flow working with nothing
306
+ * configured — `resolveEmailPort` hands back the log port in development — and
307
+ * reports as unavailable, so nobody mistakes it for delivery. In production with
308
+ * no provider there is no sender, and the features that need one say so.
309
+ */
310
+ export function resolveUserLinkSender(
311
+ config: Pick<CromboloConfig, 'sendUserLink' | 'siteName' | 'development' | 'email'>,
312
+ email: EmailPort | undefined,
313
+ ): UserLinkSender | undefined {
314
+ // An explicit sender wins, and `null` means nobody can be invited and no
315
+ // password can be reset — the site saying so outranks a configured provider.
316
+ if (config.sendUserLink !== undefined) return config.sendUserLink ?? undefined
317
+ // `email: null` is off outright, console included: a site that says it has no
318
+ // e-mail must behave that way in development too, or the difference between
319
+ // environments is only discovered in production.
320
+ if (config.email === null) return undefined
321
+ if (email?.provider === 'log') return logUserLink
322
+ if (email) return userLinkViaEmail(email, config.siteName)
323
+ return config.development ? logUserLink : undefined
324
+ }
325
+
326
+ /** Where the backoffice writes schema files, and whether it may. */
327
+ function schemaFiles(config: CromboloConfig): SchemaFiles {
328
+ const store = config.schemaStore
329
+ return {
330
+ schemaDir: config.schemaDir,
331
+ writable: config.schemaWritable,
332
+ componentAliases: () => componentAliasesIn(config.componentsDir),
333
+ // Only when a store is configured: a schema directory in a repository is
334
+ // already where the files durably live.
335
+ onChanged: store
336
+ ? async () => {
337
+ const report = await publishSchema(store, config.schemaDir)
338
+ if (report.written.length > 0 || report.removed.length > 0) {
339
+ logger('schema').info(
340
+ 'Published schema to {store}: {written} written, {removed} removed',
341
+ {
342
+ store: store.description,
343
+ written: report.written.length,
344
+ removed: report.removed.length,
345
+ },
346
+ )
347
+ }
348
+ }
349
+ : undefined,
350
+ }
351
+ }
352
+
353
+ export interface DepsOptions {
354
+ config: CromboloConfig
355
+ paths: BackOfficePaths
356
+ /** The site's installed npm extensions, which contribute their manifests. */
357
+ extensions: ExtensionRegistry
358
+ /** Omitted only by tests that exercise the contract without a database. */
359
+ db?: Db
360
+ /** Required alongside `db` to serve documents, which invalidate the cache. */
361
+ cache?: PublishedCache
362
+ schema?: SchemaBoot
363
+ /** Uploaded files; one is made on `config.mediaDir` when omitted. */
364
+ mediaFiles?: MediaFileStore
365
+ /** How oEmbed providers are reached; the global fetch unless a test supplies one. */
366
+ fetch?: typeof fetch
367
+ /**
368
+ * The resolved e-mail port. Passed in by `createServer`, which resolves it
369
+ * once, so a misconfigured provider warns on boot rather than on every call.
370
+ */
371
+ email?: EmailPort
372
+ /**
373
+ * A template was written or removed here. The server takes a new snapshot and
374
+ * tells the other nodes to look; without it they keep the view they imported.
375
+ */
376
+ onViewsChange?: (alias: string) => void | Promise<void>
377
+ }
378
+
379
+ function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
380
+ return {
381
+ async save(id, file) {
382
+ if (!isUploadAllowed(file.name, DEFAULT_UPLOAD_SETTINGS))
383
+ return { ok: false, reason: `Files of this type may not be uploaded: '${file.name}'.` }
384
+ if (
385
+ DEFAULT_UPLOAD_SETTINGS.maxFileSize !== null &&
386
+ file.size > DEFAULT_UPLOAD_SETTINGS.maxFileSize
387
+ )
388
+ return { ok: false, reason: 'The file is larger than uploads may be.' }
389
+ const saved = await files.saveTemporary(id, file)
390
+ return { ok: true, file: saved }
391
+ },
392
+ get: (id) => files.temporary(id),
393
+ read: (id) => files.readTemporary(id),
394
+ remove: async (id) => files.deleteTemporary(id),
395
+ }
396
+ }
397
+
398
+ export function createDeps(options: DepsOptions): ManagementApiDeps {
399
+ // `createServer` resolves the port and passes it in; a test calling this
400
+ // directly gets the same answer from the same config.
401
+ const email = options.email ?? resolveEmailPort(options.config)
402
+ const sendUserLink = resolveUserLinkSender(options.config, email)
403
+ const deps: ManagementApiDeps = {
404
+ server: createServerPort(options.config, Boolean(sendUserLink)),
405
+ // An unconfigured assistant contributes no extension, so the backoffice has
406
+ // no drawer, no button and nothing to load.
407
+ manifests: createManifestPort(
408
+ options.paths,
409
+ options.extensions,
410
+ options.config.assistant ? [] : [ASSISTANT_MANIFEST_ID],
411
+ ),
412
+ localization: createLocalizationPort(
413
+ options.db,
414
+ options.db ? createSchemaFileWriter(options.db, schemaFiles(options.config)) : undefined,
415
+ ),
416
+ currentUser: createCurrentUserPort(
417
+ options.config,
418
+ options.db,
419
+ options.db ? createNodeLookup(options.db) : undefined,
420
+ ),
421
+ }
422
+ const mediaFiles =
423
+ options.mediaFiles ?? new MediaFileStore(options.config.mediaStore ?? options.config.mediaDir)
424
+ deps.temporaryFiles = createTemporaryFilePort(mediaFiles)
425
+ const valueIntake = createFileIntake(mediaFiles)
426
+ deps.oembed = createOEmbedService(options.fetch)
427
+ // The whole components tree, which is one root with folders rather than
428
+ // Umbraco's split of ~/Views and ~/Views/Partials. The backoffice reaches it
429
+ // through the partial-view file API because that one is path-addressed and
430
+ // already understands folders; the template API is id-addressed and cannot
431
+ // express a folder at all.
432
+ deps.components = createFileSystemPort(options.config.componentsDir, {
433
+ extension: '.tsx',
434
+ rewrites: ['.cshtml'],
435
+ })
436
+ deps.componentSnippets = COMPONENT_SNIPPETS
437
+ deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
438
+ deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
439
+ if (options.db) {
440
+ const tags = new TagRepository(options.db)
441
+ deps.tags = { list: (filter) => tags.all(filter) }
442
+ deps.users = createUserItemPort(options.db)
443
+ const store = new AuthStore(options.db)
444
+ const userPorts = createUserPorts(options.db, {
445
+ config: {
446
+ ...options.config,
447
+ sendUserLink,
448
+ },
449
+ mediaFiles,
450
+ nodes: createNodeLookup(options.db),
451
+ endSessions: async (userId) => {
452
+ await store.endAllSessionsForUser(userId)
453
+ },
454
+ })
455
+ deps.userManagement = userPorts.users
456
+ deps.userGroups = userPorts.groups
457
+ deps.userData = userPorts.userData
458
+ deps.media = createMediaPort(options.db, mediaFiles, {
459
+ nodeState: options.schema?.nodeState,
460
+ nodeId: options.schema?.nodeId,
461
+ valueIntake,
462
+ onChange: () => options.cache?.invalidate(),
463
+ })
464
+ deps.blueprints = createBlueprintPort(options.db, {
465
+ nodeState: options.schema?.nodeState,
466
+ valueIntake,
467
+ })
468
+ deps.contentTypes = createContentTypePort(options.db, schemaFiles(options.config), {
469
+ nodeState: options.schema?.nodeState,
470
+ componentFiles: createTemplateFileStore(options.config.componentsDir, options.onViewsChange),
471
+ })
472
+ deps.mediaTypes = createContentTypePort(options.db, schemaFiles(options.config), {
473
+ nodeState: options.schema?.nodeState,
474
+ kind: 'media',
475
+ })
476
+ deps.memberTypes = createContentTypePort(options.db, schemaFiles(options.config), {
477
+ nodeState: options.schema?.nodeState,
478
+ kind: 'member',
479
+ })
480
+ deps.dataTypes = createDataTypePort(options.db, schemaFiles(options.config))
481
+ deps.templates = createTemplatePort(
482
+ options.db,
483
+ createTemplateFileStore(options.config.componentsDir, options.onViewsChange),
484
+ )
485
+ deps.logViewer = createLogViewerPort(options.db, options.config)
486
+ deps.modelsBuilder = createModelsBuilderPort(options.config)
487
+ deps.memberGroups = createMemberGroupPort(options.db)
488
+ deps.members = createMemberPort(options.db, {
489
+ nodeState: options.schema?.nodeState,
490
+ nodeId: options.schema?.nodeId,
491
+ valueIntake,
492
+ })
493
+ deps.publicAccess = createPublicAccessPort(options.db, {
494
+ onChange: () => options.cache?.invalidate(),
495
+ })
496
+ deps.elements = createElementPort(options.db, {
497
+ nodeState: options.schema?.nodeState,
498
+ nodeId: options.schema?.nodeId,
499
+ valueIntake,
500
+ onChange: () => options.cache?.invalidate(),
501
+ })
502
+ deps.references = createReferencePort(options.db)
503
+ deps.redirects = createRedirectPort(options.db, options.cache, {
504
+ isTracking: options.config.trackRedirects,
505
+ onChange: () => options.cache?.invalidate(),
506
+ })
507
+ deps.packages = createPackagePort(options.db, {
508
+ siteName: options.config.siteName,
509
+ nodeId: options.config.nodeId,
510
+ marketplaceUrl: options.config.marketplaceUrl,
511
+ componentFiles: createTemplateFileStore(options.config.componentsDir, options.onViewsChange),
512
+ mediaStore: mediaFiles.store,
513
+ components: deps.components,
514
+ stylesheets: deps.stylesheets,
515
+ scripts: deps.scripts,
516
+ })
517
+ if (options.cache) {
518
+ const cache = options.cache
519
+ deps.templateQuery = createTemplateQueryPort({
520
+ cache,
521
+ types: new ContentTypeRepository(options.db),
522
+ })
523
+ deps.publishedCache = {
524
+ reload: () => cache.invalidate(),
525
+ rebuild: async () => {
526
+ cache.invalidate()
527
+ await cache.snapshot()
528
+ },
529
+ isRebuilding: () => false,
530
+ }
531
+ }
532
+ deps.dictionary = createDictionaryPort(options.db, mediaFiles, {
533
+ onChange: () => options.cache?.invalidate(),
534
+ })
535
+ if (options.cache)
536
+ deps.documents = createDocumentPort(
537
+ options.db,
538
+ options.cache,
539
+ {
540
+ nodeState: options.schema?.nodeState,
541
+ nodeId: options.schema?.nodeId,
542
+ valueIntake,
543
+ },
544
+ createRedirectTracker({
545
+ cache: options.cache,
546
+ redirects: new RedirectRepository(options.db),
547
+ enabled: options.config.trackRedirects,
548
+ }),
549
+ options.config.siteDir,
550
+ )
551
+ }
552
+ return deps
553
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The URL tracker: a page that is renamed or moved keeps answering on the URL it
3
+ * had.
4
+ *
5
+ * Umbraco does this with a pair of notifications — capture the routes before the
6
+ * write, compare after it — and the same shape applies here, because a route is
7
+ * derived from names and tree position and so cannot be known after the fact.
8
+ * `capture` is called before the operation and `commit` after the published cache
9
+ * has been dropped; a route that changed becomes a 301 to wherever the document
10
+ * now lives.
11
+ *
12
+ * Recycle-bin moves are deliberately not tracked, as in Umbraco: a trashed page
13
+ * has no new URL to point at, and restoring it brings the old one back anyway.
14
+ */
15
+ import type { RedirectInput, RedirectRepository } from '@crombolo/data'
16
+ import type { DocumentRoute, PublishedCache, RedirectRule } from '@crombolo/render'
17
+
18
+ export interface RedirectTracker {
19
+ /** The routes the branch answers on now; empty when tracking is off. */
20
+ capture(key: string): Promise<DocumentRoute[]>
21
+ /**
22
+ * The routes of every child of `parentKey`, and their branches — what a sort
23
+ * needs, since the node being written is the parent and the URLs that move
24
+ * belong to its children. `null` is the tree root.
25
+ */
26
+ captureChildren(parentKey: string | null): Promise<DocumentRoute[]>
27
+ /** Records a redirect for every captured route the branch no longer answers on. */
28
+ commit(captured: readonly DocumentRoute[]): Promise<void>
29
+ }
30
+
31
+ /** What a port built without tracking, or with it turned off, uses. */
32
+ export const NO_REDIRECT_TRACKING: RedirectTracker = {
33
+ capture: async () => [],
34
+ captureChildren: async () => [],
35
+ commit: async () => {},
36
+ }
37
+
38
+ export function createRedirectTracker(options: {
39
+ cache: PublishedCache
40
+ redirects: RedirectRepository
41
+ enabled: boolean
42
+ }): RedirectTracker {
43
+ if (!options.enabled) return NO_REDIRECT_TRACKING
44
+ const { cache, redirects } = options
45
+
46
+ return {
47
+ capture: (key) => cache.routes(key, true),
48
+
49
+ /**
50
+ * Sorting normally changes no URL, because a segment comes from the name. The
51
+ * exception is the tree root: under Umbraco's `HideTopLevelNodeFromPath` the
52
+ * *first* root page is `/` and the others are `/<segment>`, so re-ordering
53
+ * roots moves them. Umbraco does not track that — its handler listens to
54
+ * publish and move only — and this does, since it is the same silent breakage
55
+ * for a site with more than one root.
56
+ */
57
+ async captureChildren(parentKey) {
58
+ const children = await cache.childKeys(parentKey)
59
+ const routes: DocumentRoute[] = []
60
+ for (const key of children) routes.push(...(await cache.routes(key, true)))
61
+ return routes
62
+ },
63
+
64
+ async commit(captured) {
65
+ if (captured.length === 0) return
66
+ let wrote = false
67
+ const seen = new Map<string, DocumentRoute[]>()
68
+ for (const route of captured) {
69
+ const existing = seen.get(route.key.toLowerCase())
70
+ if (existing) existing.push(route)
71
+ else seen.set(route.key.toLowerCase(), [route])
72
+ }
73
+ for (const [documentKey, before] of seen) {
74
+ const after = await cache.routes(documentKey)
75
+ for (const old of before) {
76
+ const now = after.find((route) => route.culture === old.culture)
77
+ // A culture that is no longer published has no new URL to point at, so
78
+ // the old one stops working rather than redirecting into nothing.
79
+ if (!now) continue
80
+ if (now.rootKey === old.rootKey && now.path === old.path) continue
81
+ // A rename that was reverted would otherwise leave the live URL
82
+ // redirecting to itself.
83
+ await redirects.removeSelfReferencing(documentKey, now.rootKey, now.path)
84
+ const rule: RedirectInput = {
85
+ source: 'tracked',
86
+ matchKind: 'exact',
87
+ pattern: old.path,
88
+ rootKey: old.rootKey,
89
+ culture: old.culture,
90
+ targetKind: 'document',
91
+ target: documentKey,
92
+ statusCode: 301,
93
+ sortOrder: 0,
94
+ }
95
+ await redirects.save(rule)
96
+ wrote = true
97
+ }
98
+ }
99
+ // The rules are part of the cache's snapshot, and reading the new routes
100
+ // above has just rebuilt it — so without this the redirect just written
101
+ // would not answer until something else dropped the cache.
102
+ if (wrote) cache.invalidate()
103
+ },
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Writes the site's configured redirects into the database, removing any left
109
+ * over from a previous boot — which is what makes deleting one from the config
110
+ * file take effect.
111
+ *
112
+ * Run at boot, per node, and not under a lock: two nodes mid-deploy hold different
113
+ * config, so the rows follow whichever booted last until the deploy finishes.
114
+ * A lock would make each sync atomic without resolving that disagreement, so it
115
+ * would buy nothing but the appearance of safety. Tracked rules are never touched.
116
+ */
117
+ export async function syncConfiguredRedirects(
118
+ redirects: RedirectRepository,
119
+ configured: readonly RedirectRule[],
120
+ ): Promise<{ stored: number; removed: number }> {
121
+ return redirects.syncConfigured(
122
+ configured.map((rule) => ({
123
+ matchKind: rule.matchKind,
124
+ pattern: rule.pattern,
125
+ rootKey: rule.rootKey,
126
+ culture: rule.culture,
127
+ targetKind: rule.targetKind,
128
+ target: rule.target,
129
+ statusCode: rule.statusCode,
130
+ })),
131
+ )
132
+ }