@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
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
|
+
}
|
package/src/redirects.ts
ADDED
|
@@ -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
|
+
}
|