@crombolo/server 0.0.0-stage → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +507 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +609 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +556 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +148 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +65 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +382 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1642 -0
- package/src/status.ts +275 -0
- package/src/tar.ts +85 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
package/src/server.ts
ADDED
|
@@ -0,0 +1,1642 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Crombolo server.
|
|
3
|
+
*
|
|
4
|
+
* Route order matters and mirrors docs/architecture.md: static assets, the
|
|
5
|
+
* non-contract security and branding endpoints, the contract-first Management
|
|
6
|
+
* API, the SPA shells, then the front-end site.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { createManagementApiRouter, hasSection, type Principal } from '@crombolo/api-management'
|
|
11
|
+
import {
|
|
12
|
+
AuthService,
|
|
13
|
+
AuthStore,
|
|
14
|
+
cookieNames,
|
|
15
|
+
createAuthRoutes,
|
|
16
|
+
expireCookie,
|
|
17
|
+
REDACTED,
|
|
18
|
+
readCookie,
|
|
19
|
+
serializeCookie,
|
|
20
|
+
} from '@crombolo/auth'
|
|
21
|
+
import {
|
|
22
|
+
collectManifests,
|
|
23
|
+
createBackOfficePaths,
|
|
24
|
+
createExtensionRegistry,
|
|
25
|
+
isSpaRoute,
|
|
26
|
+
renderBackOfficeShell,
|
|
27
|
+
renderLoginShell,
|
|
28
|
+
resolveGraphic,
|
|
29
|
+
resolveLoginAsset,
|
|
30
|
+
resolveStaticFile,
|
|
31
|
+
serveStaticFile,
|
|
32
|
+
} from '@crombolo/backoffice-host'
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_UPLOAD_SETTINGS,
|
|
35
|
+
FormPermissions,
|
|
36
|
+
hasAccessToSensitiveData,
|
|
37
|
+
localReturnUrl,
|
|
38
|
+
MANAGEMENT_API_PREFIX,
|
|
39
|
+
PREVIEW_HUB_PATH,
|
|
40
|
+
SERVER_EVENT_HUB_PATH,
|
|
41
|
+
} from '@crombolo/core'
|
|
42
|
+
import {
|
|
43
|
+
appendCacheInstruction,
|
|
44
|
+
createTablesCapability,
|
|
45
|
+
type Db,
|
|
46
|
+
FormEntryRepository,
|
|
47
|
+
FormWorkflowRepository,
|
|
48
|
+
RedirectRepository,
|
|
49
|
+
readReport,
|
|
50
|
+
TransferRunRepository,
|
|
51
|
+
type WritesPaused,
|
|
52
|
+
} from '@crombolo/data'
|
|
53
|
+
import {
|
|
54
|
+
contentForNode,
|
|
55
|
+
DEVELOPMENT_LIMITS,
|
|
56
|
+
FORM_SCRIPT,
|
|
57
|
+
FORM_SCRIPT_PATH,
|
|
58
|
+
FORM_SUBMIT_PATH,
|
|
59
|
+
PublishedCache,
|
|
60
|
+
Renderer,
|
|
61
|
+
type SnapshotStatus,
|
|
62
|
+
setFormTokenSigner,
|
|
63
|
+
ViewSnapshots,
|
|
64
|
+
} from '@crombolo/render'
|
|
65
|
+
import type { SyncReport } from '@crombolo/schema'
|
|
66
|
+
import type { Server } from 'bun'
|
|
67
|
+
import { createNodeLookup, loadAccess } from './access.ts'
|
|
68
|
+
import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
|
|
69
|
+
import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
|
|
70
|
+
import { createSchemaProposals } from './adapters/schema-proposals.ts'
|
|
71
|
+
import { installBundleContent } from './bundle-install.ts'
|
|
72
|
+
import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
|
|
73
|
+
import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
|
|
74
|
+
import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
|
|
75
|
+
import { attachDatabase, bootstrapDatabase } from './database.ts'
|
|
76
|
+
import { createDiagnostics } from './diagnostics.ts'
|
|
77
|
+
import { syncDomainsFile } from './domains.ts'
|
|
78
|
+
import { editorTypeLibs, siteJsxImportSource } from './editor-types.ts'
|
|
79
|
+
import { emailAvailability, resolveEmailPort } from './email.ts'
|
|
80
|
+
import { noticeAboutEmail } from './email-notice.ts'
|
|
81
|
+
import { warnAboutAppPlugins } from './extensions-notice.ts'
|
|
82
|
+
import { coerceForm, deleteForm, saveForm } from './form-files.ts'
|
|
83
|
+
import {
|
|
84
|
+
createFormRegistry,
|
|
85
|
+
FORM_FLASH_COOKIE,
|
|
86
|
+
formSigningSecret,
|
|
87
|
+
readFlash,
|
|
88
|
+
signFormToken,
|
|
89
|
+
submitForm,
|
|
90
|
+
wantsJson,
|
|
91
|
+
writeFlash,
|
|
92
|
+
} from './form-submit.ts'
|
|
93
|
+
import { createWorkflowRunner } from './form-workflows.ts'
|
|
94
|
+
import {
|
|
95
|
+
entriesCsv,
|
|
96
|
+
formDetail,
|
|
97
|
+
formSummary,
|
|
98
|
+
formsByKey,
|
|
99
|
+
parseEntryState,
|
|
100
|
+
redactSensitive,
|
|
101
|
+
} from './forms.ts'
|
|
102
|
+
import { ImageProcessor, parseImagingQuery } from './imaging.ts'
|
|
103
|
+
import { type BackgroundJobs, createBackgroundJobs } from './jobs.ts'
|
|
104
|
+
import { configureLogging, logger } from './logging.ts'
|
|
105
|
+
import { createMarketplace } from './marketplace.ts'
|
|
106
|
+
import { MediaFileStore } from './media-files.ts'
|
|
107
|
+
import { mediaStoreFromEnvironment } from './media-store.ts'
|
|
108
|
+
import { createMemberAuth, MEMBER_ROUTE_PREFIX } from './member-auth.ts'
|
|
109
|
+
import { noNodesPage } from './no-nodes.ts'
|
|
110
|
+
import { NotImplementedLog } from './not-implemented.ts'
|
|
111
|
+
import { createDeps, resolveUserLinkSender } from './ports.ts'
|
|
112
|
+
import { syncConfiguredRedirects } from './redirects.ts'
|
|
113
|
+
import {
|
|
114
|
+
bootSchema,
|
|
115
|
+
CacheInstructionPoller,
|
|
116
|
+
importSchema,
|
|
117
|
+
type SchemaBoot,
|
|
118
|
+
watchSchema,
|
|
119
|
+
} from './schema.ts'
|
|
120
|
+
import { materialiseSchema, publishSchema, type SchemaStore } from './schema-store.ts'
|
|
121
|
+
import { createSeoHost, originOf } from './seo.ts'
|
|
122
|
+
import {
|
|
123
|
+
combineHubs,
|
|
124
|
+
createServerEventHub,
|
|
125
|
+
type HubSocketData,
|
|
126
|
+
type ServerEventHub,
|
|
127
|
+
} from './server-events.ts'
|
|
128
|
+
import { assertViewRuntime } from './view-runtime.ts'
|
|
129
|
+
|
|
130
|
+
const CONTRACT_PATH = Bun.resolveSync('@crombolo/contracts/OpenApi.json', import.meta.dir)
|
|
131
|
+
|
|
132
|
+
export interface ServerHandle {
|
|
133
|
+
config: CromboloConfig
|
|
134
|
+
fetch(request: Request): Promise<Response>
|
|
135
|
+
/** Everything `Bun.serve` needs, the live-update WebSocket included. */
|
|
136
|
+
serveOptions: {
|
|
137
|
+
port: number
|
|
138
|
+
maxRequestBodySize: number
|
|
139
|
+
fetch(request: Request, server: Server<HubSocketData>): Promise<Response | undefined>
|
|
140
|
+
websocket: ServerEventHub['websocket']
|
|
141
|
+
}
|
|
142
|
+
/** The live-update channel the backoffice holds open. */
|
|
143
|
+
events: ServerEventHub
|
|
144
|
+
paths: ReturnType<typeof createBackOfficePaths>
|
|
145
|
+
db: Db
|
|
146
|
+
auth: AuthService
|
|
147
|
+
cache: PublishedCache
|
|
148
|
+
renderer: Renderer
|
|
149
|
+
/** The views generations this node has taken; `status()` is what /health reports. */
|
|
150
|
+
snapshots: ViewSnapshots
|
|
151
|
+
seededAdminPassword: string | undefined
|
|
152
|
+
schema: SchemaBoot
|
|
153
|
+
/** Applies cache instructions other nodes appended; the interval calls this too. */
|
|
154
|
+
poll(): Promise<number>
|
|
155
|
+
/** Development only: every operation the client asked for that answered 501, in first-seen order. */
|
|
156
|
+
notImplemented: NotImplementedLog
|
|
157
|
+
/**
|
|
158
|
+
* What /health reports. Not ok once the database is ahead of a node that
|
|
159
|
+
* serves the editor; a `web` node stays ok while behind until a contract has
|
|
160
|
+
* run past it, because its reads stay correct until then.
|
|
161
|
+
*/
|
|
162
|
+
health(): {
|
|
163
|
+
ok: boolean
|
|
164
|
+
readOnly: boolean
|
|
165
|
+
/** Set while an operator has paused editing cluster-wide; drains nothing. */
|
|
166
|
+
paused: WritesPaused | null
|
|
167
|
+
version: string
|
|
168
|
+
revision: string
|
|
169
|
+
nodeId: string
|
|
170
|
+
role: ServerRole
|
|
171
|
+
/** The views generation in use, and whether newer ones are being refused. */
|
|
172
|
+
components: SnapshotStatus
|
|
173
|
+
}
|
|
174
|
+
/** Scheduled publishing and version cleanup; each run can be called directly. */
|
|
175
|
+
jobs: BackgroundJobs
|
|
176
|
+
/** Stops the watcher and the poll, then closes the database. */
|
|
177
|
+
close(): Promise<void>
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
|
|
181
|
+
export const PREVIEW_COOKIE = 'UMB_PREVIEW'
|
|
182
|
+
|
|
183
|
+
export async function createServer(config: CromboloConfig = loadConfig()): Promise<ServerHandle> {
|
|
184
|
+
configureLogging({
|
|
185
|
+
logsDir: config.logsDir,
|
|
186
|
+
level: config.logLevel,
|
|
187
|
+
console: config.logToConsole,
|
|
188
|
+
})
|
|
189
|
+
const diagnostics = createDiagnostics(config.diagnostics)
|
|
190
|
+
const log = logger('server')
|
|
191
|
+
const schemaLog = logger('schema')
|
|
192
|
+
const renderLog = logger('render')
|
|
193
|
+
const redirectLog = logger('redirects')
|
|
194
|
+
const bundleLog = (id: string) => {
|
|
195
|
+
const named = logger('bundle', id)
|
|
196
|
+
return {
|
|
197
|
+
info: (message: string, properties?: Record<string, unknown>) =>
|
|
198
|
+
named.info(message, properties),
|
|
199
|
+
warn: (message: string, properties?: Record<string, unknown>) =>
|
|
200
|
+
named.warn(message, properties),
|
|
201
|
+
error: (message: string, properties?: Record<string, unknown>) =>
|
|
202
|
+
named.error(message, properties),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const domainLog = logger('domains')
|
|
206
|
+
// Extensions are the site's own npm dependencies (`docs/bundles.md`).
|
|
207
|
+
// Uncached in development so an author editing one sees a reload pick it up.
|
|
208
|
+
const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
|
|
209
|
+
const marketplace = createMarketplace({
|
|
210
|
+
registry: config.npmRegistry,
|
|
211
|
+
keyword: config.packageKeyword,
|
|
212
|
+
marketplaceUrl: config.marketplaceUrl,
|
|
213
|
+
extensions,
|
|
214
|
+
siteDir: config.siteDir,
|
|
215
|
+
wired: () => bundles?.mounted.map((bundle) => bundle.id) ?? [],
|
|
216
|
+
contentApplied: async (packageName) =>
|
|
217
|
+
(await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
|
|
218
|
+
applyContentOnInstall: config.applyBundleContentOnInstall,
|
|
219
|
+
applyContent: async (packageName) => {
|
|
220
|
+
const result = await installBundleContent(db, config, { bundle: packageName })
|
|
221
|
+
if (!result.runId) {
|
|
222
|
+
const reasons = [
|
|
223
|
+
...result.problems,
|
|
224
|
+
...result.check.outstanding.map((finding) => finding.message),
|
|
225
|
+
]
|
|
226
|
+
return {
|
|
227
|
+
applied: false,
|
|
228
|
+
message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// The content landed under a cache this process had already constructed.
|
|
232
|
+
cache.invalidate()
|
|
233
|
+
return { applied: true, runId: result.runId, message: 'Its content was applied.' }
|
|
234
|
+
},
|
|
235
|
+
})
|
|
236
|
+
const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
|
|
237
|
+
// Two questions, not one: what this process answers, and what it is
|
|
238
|
+
// responsible for converging at boot. `all` and `api` are both owners; only
|
|
239
|
+
// `all` and `web` serve the public site.
|
|
240
|
+
const servesBackOffice = config.role !== 'web'
|
|
241
|
+
const owns = config.role !== 'web'
|
|
242
|
+
// Bundles are checked before the database is touched, because a bundle that
|
|
243
|
+
// owns tables is migrated at boot, and a bad declaration must not get that far.
|
|
244
|
+
const bundlePackages = servesBackOffice ? extensions.bundles().bundles : []
|
|
245
|
+
if (servesBackOffice) {
|
|
246
|
+
const problems = validateServerBundles(config.bundles, bundlePackages, VERSION)
|
|
247
|
+
if (problems.length > 0)
|
|
248
|
+
throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
|
|
249
|
+
}
|
|
250
|
+
const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
|
|
251
|
+
const { db, seededAdminPassword } = owns
|
|
252
|
+
? await bootstrapDatabase(config, chains)
|
|
253
|
+
: await attachDatabase(config)
|
|
254
|
+
// A configured schema store is copied down before anything reads `schema/`, and
|
|
255
|
+
// becomes the schema directory for this boot. Everything after here — loading,
|
|
256
|
+
// validation, hashing, sync, the writer — works on files exactly as it always has.
|
|
257
|
+
if (config.schemaStore) {
|
|
258
|
+
const copied = await materialiseSchema(config.schemaStore, config.schemaCacheDir)
|
|
259
|
+
config.schemaDir = config.schemaCacheDir
|
|
260
|
+
schemaLog.info('Schema read from {store}: {count} files in {dir}', {
|
|
261
|
+
store: config.schemaStore.description,
|
|
262
|
+
count: copied.length,
|
|
263
|
+
dir: config.schemaCacheDir,
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
// A node that does not own the schema still loads it — the renderer needs the
|
|
267
|
+
// property definitions — but writes none of it.
|
|
268
|
+
const schema = await bootSchema(db, owns ? config : { ...config, syncSchemaAtBoot: false })
|
|
269
|
+
|
|
270
|
+
const auth = new AuthService(new AuthStore(db))
|
|
271
|
+
const cookies = { siteName: config.siteName, securePrefix: config.secureCookies }
|
|
272
|
+
const names = cookieNames(cookies)
|
|
273
|
+
const authRoutes = createAuthRoutes({
|
|
274
|
+
backOfficePath: paths.backOfficePath,
|
|
275
|
+
service: auth,
|
|
276
|
+
cookies,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Resolves the caller from the reference access token. The client sends
|
|
281
|
+
* `Authorization: Bearer [redacted]`, so the cookie is the real source; a
|
|
282
|
+
* non-browser client may still present a real bearer token.
|
|
283
|
+
*/
|
|
284
|
+
async function authenticate(request: Request): Promise<Principal | undefined> {
|
|
285
|
+
const header = request.headers.get('authorization')
|
|
286
|
+
const bearer = header?.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : undefined
|
|
287
|
+
const token = bearer && bearer !== REDACTED ? bearer : readCookie(request, names.accessToken)
|
|
288
|
+
if (!token) return undefined
|
|
289
|
+
|
|
290
|
+
const identity = await auth.resolveAccessToken(token)
|
|
291
|
+
if (!identity) return undefined
|
|
292
|
+
return {
|
|
293
|
+
id: identity.user.key,
|
|
294
|
+
userName: identity.user.login,
|
|
295
|
+
name: identity.user.userName,
|
|
296
|
+
email: identity.user.email,
|
|
297
|
+
isAdmin: identity.isAdmin,
|
|
298
|
+
languageIsoCode: identity.user.language ?? undefined,
|
|
299
|
+
avatarUrls: identity.user.avatar ? [identity.user.avatar] : [],
|
|
300
|
+
allowedSections: identity.allowedSections,
|
|
301
|
+
permissions: identity.permissions,
|
|
302
|
+
groupKeys: identity.groupKeys,
|
|
303
|
+
hasAccessToAllLanguages: identity.hasAccessToAllLanguages,
|
|
304
|
+
...(await loadAccess(db, identity.user.id)),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Form definitions are files, so they are re-read rather than cached with the
|
|
309
|
+
// content: nothing about a publish tells this node that a form changed.
|
|
310
|
+
const forms = createFormRegistry(config.schemaDir)
|
|
311
|
+
// Every form this node renders carries a token signed with the site's key, so
|
|
312
|
+
// the timing guard cannot be bypassed by posting a made-up render time.
|
|
313
|
+
const formSecret = await formSigningSecret(db)
|
|
314
|
+
setFormTokenSigner((formKey, renderedAt) => signFormToken(formSecret, formKey, renderedAt))
|
|
315
|
+
const cache = new PublishedCache(
|
|
316
|
+
createPublishedContentSource(db, { nodeState: schema.nodeState, nodeId: schema.nodeId }),
|
|
317
|
+
{ form: (key) => forms.byKey(key) },
|
|
318
|
+
)
|
|
319
|
+
// A snapshot of the views, so a template edited anywhere — the backoffice, a
|
|
320
|
+
// deploy, a bucket synced underneath — is picked up without restarting this
|
|
321
|
+
// node. `assertViewRuntime` first, because a cache directory the JSX runtime
|
|
322
|
+
// cannot be resolved from fails every render rather than the boot.
|
|
323
|
+
assertViewRuntime(config.componentsCacheDir)
|
|
324
|
+
const snapshots = new ViewSnapshots({
|
|
325
|
+
sourceDir: config.componentsDir,
|
|
326
|
+
cacheDir: config.componentsCacheDir,
|
|
327
|
+
limits: { ...(config.development ? DEVELOPMENT_LIMITS : {}), ...config.componentsSnapshot },
|
|
328
|
+
onProblem: (message) => renderLog.error('{message}', { message }),
|
|
329
|
+
// Only a change: every boot takes a generation, and saying so each time is
|
|
330
|
+
// noise. The start banner names the one a node came up on.
|
|
331
|
+
onSwap: (hash, previous) => {
|
|
332
|
+
if (previous)
|
|
333
|
+
renderLog.info('Views changed: generation {previous} to {hash}', {
|
|
334
|
+
previous: previous.slice(0, 8),
|
|
335
|
+
hash: hash.slice(0, 8),
|
|
336
|
+
})
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
await snapshots.prepare()
|
|
340
|
+
// Before the poller, which tells it to look again on a `views` instruction
|
|
341
|
+
// from another node: a closure over a `const` declared later would be a
|
|
342
|
+
// temporal dead zone waiting for the first instruction to land.
|
|
343
|
+
const renderer = new Renderer({
|
|
344
|
+
cache,
|
|
345
|
+
componentsDir: config.componentsDir,
|
|
346
|
+
snapshots,
|
|
347
|
+
renderTimeoutMs: config.renderTimeoutMs,
|
|
348
|
+
})
|
|
349
|
+
// The site's own redirects are code, so the database follows the file: rules it
|
|
350
|
+
// no longer declares are removed, which is what makes deleting one from config
|
|
351
|
+
// actually take effect.
|
|
352
|
+
//
|
|
353
|
+
// File-to-database convergence belongs to one node. Two of them racing would
|
|
354
|
+
// each remove what the other had just written, so a `web` node reads these
|
|
355
|
+
// rules through the cache and converges nothing.
|
|
356
|
+
if (owns) {
|
|
357
|
+
const configuredRedirects = await syncConfiguredRedirects(
|
|
358
|
+
new RedirectRepository(db),
|
|
359
|
+
config.redirects,
|
|
360
|
+
)
|
|
361
|
+
if (configuredRedirects.stored > 0 || configuredRedirects.removed > 0)
|
|
362
|
+
redirectLog.info('Configured redirects: {stored} in force, {removed} removed', {
|
|
363
|
+
stored: configuredRedirects.stored,
|
|
364
|
+
removed: configuredRedirects.removed,
|
|
365
|
+
})
|
|
366
|
+
// The same bargain for hostnames, from `domains.toml`: the file is the truth,
|
|
367
|
+
// so an entry deleted from it is unbound here. A site without the file is left
|
|
368
|
+
// alone, which is every site that has never written one.
|
|
369
|
+
const domains = await syncDomainsFile(db, config.siteDir)
|
|
370
|
+
for (const problem of domains.problems) domainLog.warn('{problem}', { problem })
|
|
371
|
+
if (domains.action === 'applied')
|
|
372
|
+
domainLog.info('Domains from domains.toml: {applied} bound, {removed} removed', {
|
|
373
|
+
applied: domains.applied.length,
|
|
374
|
+
removed: domains.removed,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The server halves of the site's bundles, mounted only where the backoffice
|
|
379
|
+
* is: these are backoffice endpoints, and a `web` node serves none of them.
|
|
380
|
+
*
|
|
381
|
+
* Capabilities are built here, from what this node already holds, so a bundle
|
|
382
|
+
* is handed a narrow interface rather than the database. A bad bundle fails the
|
|
383
|
+
* boot — see `createServerBundles`.
|
|
384
|
+
*/
|
|
385
|
+
const bundles = servesBackOffice
|
|
386
|
+
? createServerBundles({
|
|
387
|
+
bundles: config.bundles,
|
|
388
|
+
packages: bundlePackages,
|
|
389
|
+
coreVersion: VERSION,
|
|
390
|
+
tables: (bundle) => {
|
|
391
|
+
const chain = chains.find((candidate) => candidate.id === bundle.id)
|
|
392
|
+
return chain ? createTablesCapability(db, chain.compiled.schema) : undefined
|
|
393
|
+
},
|
|
394
|
+
pluginApiPath: `${paths.pluginPath}/api`,
|
|
395
|
+
host: {
|
|
396
|
+
redirects: createBundleRedirects({ db, cache }),
|
|
397
|
+
documents: {
|
|
398
|
+
url: async (key, culture) => {
|
|
399
|
+
const content = await cache.byKey(key, culture ?? null)
|
|
400
|
+
return content && content.url !== '#' ? content.url : undefined
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
log: bundleLog('bundle'),
|
|
404
|
+
},
|
|
405
|
+
log: (id) => bundleLog(id),
|
|
406
|
+
})
|
|
407
|
+
: undefined
|
|
408
|
+
for (const bundle of bundles?.mounted ?? [])
|
|
409
|
+
log.info('Bundle {name} answering below {prefix} for the {section} section', {
|
|
410
|
+
name: bundle.name,
|
|
411
|
+
prefix: bundle.prefix,
|
|
412
|
+
section: bundle.section,
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const poller = new CacheInstructionPoller({
|
|
416
|
+
db,
|
|
417
|
+
nodeId: schema.nodeId,
|
|
418
|
+
nodeState: schema.nodeState,
|
|
419
|
+
role: config.role,
|
|
420
|
+
// `schema` and `content` change what renders: schema alters property
|
|
421
|
+
// definitions. `views` changes the code that renders it, which is a new
|
|
422
|
+
// snapshot rather than anything in the published cache.
|
|
423
|
+
onInstruction: (kind, payload) => {
|
|
424
|
+
if (kind !== 'components') return cache.invalidate()
|
|
425
|
+
// The hash only says when to stop looking eagerly; the snapshot is always
|
|
426
|
+
// of the bytes this node reads, so a superseded one is harmless.
|
|
427
|
+
snapshots.announce(typeof payload.hash === 'string' ? payload.hash : undefined)
|
|
428
|
+
},
|
|
429
|
+
})
|
|
430
|
+
await poller.start(config.development ? 2_000 : 5_000)
|
|
431
|
+
const watcher =
|
|
432
|
+
config.development && owns
|
|
433
|
+
? watchSchema(
|
|
434
|
+
db,
|
|
435
|
+
config,
|
|
436
|
+
{ nodeId: schema.nodeId, revision: config.schemaRevision },
|
|
437
|
+
(report: SyncReport) => {
|
|
438
|
+
cache.invalidate()
|
|
439
|
+
if (report.action === 'refused')
|
|
440
|
+
schemaLog.error('Schema change refused: {reason}', {
|
|
441
|
+
reason:
|
|
442
|
+
report.reason ?? report.problems.map((p) => `${p.file}: ${p.message}`).join('; '),
|
|
443
|
+
})
|
|
444
|
+
else if (report.action === 'applied') {
|
|
445
|
+
// Move this node on with the database it has just synced. The
|
|
446
|
+
// repositories hold `schema.nodeState`, and `assertNodeMayWrite`
|
|
447
|
+
// refuses a write from a node behind the database — so without
|
|
448
|
+
// this, a schema change made through this server's own API (the
|
|
449
|
+
// backoffice creating a type, which bumps `schema.toml` and lands
|
|
450
|
+
// here through the watcher) leaves every later write refused until
|
|
451
|
+
// the process restarts. `importSchema` already does the same for
|
|
452
|
+
// the path it owns.
|
|
453
|
+
if (report.state?.version) schema.nodeState.version = report.state.version
|
|
454
|
+
schemaLog.info('Schema {version} applied', { version: report.state?.version ?? '' })
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
)
|
|
458
|
+
: undefined
|
|
459
|
+
const members = createMemberAuth(db, config, {
|
|
460
|
+
nodeState: schema.nodeState,
|
|
461
|
+
nodeId: schema.nodeId,
|
|
462
|
+
})
|
|
463
|
+
|
|
464
|
+
/** A document's latest saved version, as preview shows it, in the published tree's context. */
|
|
465
|
+
async function draftModel(key: string, requested: string | null) {
|
|
466
|
+
const draft = await loadDraftNode(db, key, {
|
|
467
|
+
nodeState: schema.nodeState,
|
|
468
|
+
nodeId: schema.nodeId,
|
|
469
|
+
})
|
|
470
|
+
if (!draft) return undefined
|
|
471
|
+
const snapshot = await cache.snapshot()
|
|
472
|
+
const culture =
|
|
473
|
+
requested ||
|
|
474
|
+
(draft.cultures.length > 0 ? (snapshot.defaultCulture ?? draft.cultures[0]) : null) ||
|
|
475
|
+
null
|
|
476
|
+
const published = await cache.byKey(key, culture)
|
|
477
|
+
const content = contentForNode(
|
|
478
|
+
draft,
|
|
479
|
+
published?.url ?? `/${draft.key}`,
|
|
480
|
+
culture,
|
|
481
|
+
snapshot.languages,
|
|
482
|
+
).withValueContext(await cache.valueContext(culture))
|
|
483
|
+
return {
|
|
484
|
+
content,
|
|
485
|
+
contentTypeAlias: draft.contentTypeAlias,
|
|
486
|
+
published: Boolean(published),
|
|
487
|
+
culture,
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const seo = createSeoHost({
|
|
492
|
+
db,
|
|
493
|
+
config,
|
|
494
|
+
cache,
|
|
495
|
+
isProtected: async (content) => (await members.decide(content, undefined)).status !== 'allowed',
|
|
496
|
+
draft: draftModel,
|
|
497
|
+
})
|
|
498
|
+
const seoPath = `${paths.pluginPath}/api/seo`
|
|
499
|
+
|
|
500
|
+
const notImplementedLog = logger('not-implemented')
|
|
501
|
+
const notImplemented = new NotImplementedLog((line, entry) => {
|
|
502
|
+
// The console line is the development aid; the file keeps it for the log viewer
|
|
503
|
+
console.warn(line)
|
|
504
|
+
notImplementedLog.warning('Operation {operationId} ({method} {path}) is not implemented', {
|
|
505
|
+
...entry,
|
|
506
|
+
})
|
|
507
|
+
})
|
|
508
|
+
const previewPort = {
|
|
509
|
+
enter: () => [
|
|
510
|
+
serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
|
|
511
|
+
],
|
|
512
|
+
exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
|
|
513
|
+
}
|
|
514
|
+
// The site's config may name a store; without one the environment decides,
|
|
515
|
+
// and that falls back to the media directory on this disk.
|
|
516
|
+
const mediaStore = config.mediaStore ?? (await mediaStoreFromEnvironment(config.mediaDir))
|
|
517
|
+
// Resolved once: constructing it twice would warn twice about a provider that
|
|
518
|
+
// is half-configured, and the adapters are cheap but not free of side effects.
|
|
519
|
+
const email = resolveEmailPort(config)
|
|
520
|
+
const emailStatus = emailAvailability(email)
|
|
521
|
+
// Whether an invitation or a reset link can reach anyone at all, which is what
|
|
522
|
+
// gates the options rather than `emailStatus.available`: development delivers
|
|
523
|
+
// to the console, and that is a working flow for the person using it.
|
|
524
|
+
const canSendUserLinks = Boolean(resolveUserLinkSender(config, email))
|
|
525
|
+
const mediaFiles = new MediaFileStore(mediaStore)
|
|
526
|
+
const images = new ImageProcessor(mediaStore)
|
|
527
|
+
const api = createManagementApiRouter({
|
|
528
|
+
deps: {
|
|
529
|
+
...createDeps({
|
|
530
|
+
config,
|
|
531
|
+
paths,
|
|
532
|
+
extensions,
|
|
533
|
+
db,
|
|
534
|
+
cache,
|
|
535
|
+
schema,
|
|
536
|
+
mediaFiles,
|
|
537
|
+
email,
|
|
538
|
+
// This node looks again at once; the others are told to. The write has
|
|
539
|
+
// already landed wherever `componentsDir` points, so there is nothing to
|
|
540
|
+
// distribute — only the fact that it happened.
|
|
541
|
+
onViewsChange: async (alias) => {
|
|
542
|
+
// The snapshot first, and awaited: the hash announced is then the one
|
|
543
|
+
// this write produced rather than the generation it replaced, and the
|
|
544
|
+
// editor's next request renders what they just saved.
|
|
545
|
+
const hash = await snapshots.refresh()
|
|
546
|
+
void appendCacheInstruction(db, {
|
|
547
|
+
kind: 'components',
|
|
548
|
+
payload: hash ? { alias, hash } : { alias },
|
|
549
|
+
by: schema.nodeId,
|
|
550
|
+
}).catch((error: unknown) => {
|
|
551
|
+
renderLog.error('Could not announce the template {alias}: {error}', { alias, error })
|
|
552
|
+
})
|
|
553
|
+
},
|
|
554
|
+
}),
|
|
555
|
+
preview: previewPort,
|
|
556
|
+
},
|
|
557
|
+
authenticate,
|
|
558
|
+
nodes: createNodeLookup(db),
|
|
559
|
+
allowLocalLogin: config.allowLocalLogin,
|
|
560
|
+
onNotImplemented: config.development
|
|
561
|
+
? (operation, request) => notImplemented.record(operation, request)
|
|
562
|
+
: undefined,
|
|
563
|
+
onServerEvent: (event) => {
|
|
564
|
+
events.broadcast('notify', [event])
|
|
565
|
+
// An open preview of the document reloads when it changes.
|
|
566
|
+
if (event.eventSource === 'Umbraco:CMS:Document' && event.eventType !== 'Deleted')
|
|
567
|
+
previewHub.broadcast('refreshed', [event.key])
|
|
568
|
+
},
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
// Fixed by the contract and the client's SDK, wherever the editor is mounted.
|
|
572
|
+
const apiPrefix = MANAGEMENT_API_PREFIX
|
|
573
|
+
|
|
574
|
+
const health = () => {
|
|
575
|
+
const paused = poller.paused
|
|
576
|
+
const readOnly = schema.compatibilityMode || poller.behind || paused !== undefined
|
|
577
|
+
// A `web` node behind the database still answers its readers correctly,
|
|
578
|
+
// as-of its own state, until a contract removes something it reads. Draining
|
|
579
|
+
// it any earlier would drain every renderer the moment an upgrade cuts over —
|
|
580
|
+
// before a single new node can boot — and readers would see the gap. A node
|
|
581
|
+
// that serves the editor drains as soon as it is behind, so editors land on
|
|
582
|
+
// a node that can save. A pause drains nothing: it is for editors to see.
|
|
583
|
+
const serving =
|
|
584
|
+
!schema.compatibilityMode && (config.role === 'web' ? !poller.readsUnsafe : !poller.behind)
|
|
585
|
+
return {
|
|
586
|
+
// A frozen views snapshot deliberately does not make this false. Draining
|
|
587
|
+
// every node because they are all serving a slightly old template would
|
|
588
|
+
// turn a stale view into an outage; the `views` block below is what says
|
|
589
|
+
// so, and restarting the node clears it.
|
|
590
|
+
ok: serving,
|
|
591
|
+
readOnly,
|
|
592
|
+
paused: paused ?? null,
|
|
593
|
+
version: schema.nodeState.version,
|
|
594
|
+
revision: schema.nodeState.revision,
|
|
595
|
+
nodeId: schema.nodeId,
|
|
596
|
+
role: config.role,
|
|
597
|
+
components: snapshots.status(),
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const events = createServerEventHub()
|
|
602
|
+
const previewHub = createServerEventHub({ name: 'PreviewHub' })
|
|
603
|
+
const jobs = createBackgroundJobs({
|
|
604
|
+
db,
|
|
605
|
+
cache,
|
|
606
|
+
nodeState: schema.nodeState,
|
|
607
|
+
nodeId: schema.nodeId,
|
|
608
|
+
versionCleanup: config.versionCleanup,
|
|
609
|
+
mediaFiles,
|
|
610
|
+
// The workflow queue drains here rather than in the request: a submission is
|
|
611
|
+
// stored first, and an e-mail that cannot be sent yet must not cost it.
|
|
612
|
+
formWorkflows: createWorkflowRunner({
|
|
613
|
+
db,
|
|
614
|
+
forms,
|
|
615
|
+
email,
|
|
616
|
+
mediaFiles,
|
|
617
|
+
nodeState: schema.nodeState,
|
|
618
|
+
nodeId: schema.nodeId,
|
|
619
|
+
onContentChanged: () => cache.invalidate(),
|
|
620
|
+
}),
|
|
621
|
+
onDocumentChanged: (key) =>
|
|
622
|
+
events.broadcast('notify', [
|
|
623
|
+
{ eventSource: 'Umbraco:CMS:Document', eventType: 'Updated', key },
|
|
624
|
+
]),
|
|
625
|
+
})
|
|
626
|
+
// Scheduled publishing and version cleanup are writes on a timer. Running them
|
|
627
|
+
// on every node would have two processes publishing the same document in the
|
|
628
|
+
// same second, so only an owner starts them.
|
|
629
|
+
if (owns) jobs.start()
|
|
630
|
+
log.info('Crombolo {version} started on node {nodeId} as {role}', {
|
|
631
|
+
version: VERSION,
|
|
632
|
+
nodeId: schema.nodeId,
|
|
633
|
+
role: config.role,
|
|
634
|
+
})
|
|
635
|
+
// App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
|
|
636
|
+
// extensions silently stopped loading after an upgrade; remove at 1.0.
|
|
637
|
+
warnAboutAppPlugins(config.siteDir, log)
|
|
638
|
+
noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
|
|
639
|
+
const hubPath = SERVER_EVENT_HUB_PATH
|
|
640
|
+
const previewHubPath = PREVIEW_HUB_PATH
|
|
641
|
+
|
|
642
|
+
// Imported only when the feature is configured, so an unconfigured site never
|
|
643
|
+
// loads the assistant, its provider or its tool definitions.
|
|
644
|
+
const assistantPath = `${paths.pluginPath}/api/assistant`
|
|
645
|
+
const assistant =
|
|
646
|
+
config.assistant && servesBackOffice
|
|
647
|
+
? await import('./assistant.ts').then((module) =>
|
|
648
|
+
module.createAssistantHost({
|
|
649
|
+
config: config.assistant as NonNullable<typeof config.assistant>,
|
|
650
|
+
db,
|
|
651
|
+
schema: createSchemaProposals({ db, config, boot: schema }),
|
|
652
|
+
siteName: config.siteName,
|
|
653
|
+
version: VERSION,
|
|
654
|
+
dispatch: (inner) => api.dispatch(inner),
|
|
655
|
+
}),
|
|
656
|
+
)
|
|
657
|
+
: undefined
|
|
658
|
+
if (assistant) {
|
|
659
|
+
log.info('Assistant enabled with {provider}{mcp}', {
|
|
660
|
+
provider: config.assistant?.provider.name,
|
|
661
|
+
mcp: config.assistant?.mcp === true ? ', MCP endpoint served' : '',
|
|
662
|
+
})
|
|
663
|
+
// Checked once at boot so an expired SSO session is reported here rather than
|
|
664
|
+
// as a failed message later. Never awaited into the boot path: the assistant
|
|
665
|
+
// is optional and the site must come up either way.
|
|
666
|
+
void assistant.status(true).then((status) => {
|
|
667
|
+
if (status.ok) {
|
|
668
|
+
log.info('Assistant credentials resolved: {detail}{expires}', {
|
|
669
|
+
detail: status.detail,
|
|
670
|
+
expires: status.expires ? `, expiring ${status.expires}` : '',
|
|
671
|
+
})
|
|
672
|
+
} else {
|
|
673
|
+
log.warning('Assistant cannot reach its model: {detail} {remedy}', {
|
|
674
|
+
detail: status.detail,
|
|
675
|
+
remedy: status.remedy ?? '',
|
|
676
|
+
})
|
|
677
|
+
}
|
|
678
|
+
})
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// Imported only when configured, like the assistant: an unconfigured site has no
|
|
682
|
+
// git route and no credentials anywhere near it.
|
|
683
|
+
const gitPath = `${paths.pluginPath}/api/git`
|
|
684
|
+
const git =
|
|
685
|
+
config.git && servesBackOffice
|
|
686
|
+
? await import('./git-routes.ts').then((module) =>
|
|
687
|
+
module.createGitHost({
|
|
688
|
+
config: config.git as NonNullable<typeof config.git>,
|
|
689
|
+
schemaDir: config.schemaDir,
|
|
690
|
+
siteName: config.siteName,
|
|
691
|
+
}),
|
|
692
|
+
)
|
|
693
|
+
: undefined
|
|
694
|
+
if (git) {
|
|
695
|
+
log.info('Source control enabled: {provider} on {branch}', {
|
|
696
|
+
provider: config.git?.provider.description,
|
|
697
|
+
branch: config.git?.provider.branch,
|
|
698
|
+
})
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
async function fetch(request: Request): Promise<Response> {
|
|
702
|
+
const url = new URL(request.url)
|
|
703
|
+
const { pathname } = url
|
|
704
|
+
|
|
705
|
+
// Non-contract, unauthenticated: a load balancer drains a node that has fallen behind.
|
|
706
|
+
if (pathname === '/health') {
|
|
707
|
+
const status = health()
|
|
708
|
+
return Response.json(status, { status: status.ok ? 200 : 503 })
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Everything the editor talks to, answered only by a node that serves it. A
|
|
712
|
+
// `web` node falls straight through to the public site, so the backoffice,
|
|
713
|
+
// the Management API and the sign-in routes are not merely unauthorised
|
|
714
|
+
// there — they are not routes at all, and 404 like any other unknown path.
|
|
715
|
+
if (servesBackOffice) {
|
|
716
|
+
const response = await serveBackOffice(request, url)
|
|
717
|
+
if (response) return response
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) {
|
|
721
|
+
const response = await members.handle(request, pathname)
|
|
722
|
+
if (response) return response
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// The progressive-enhancement script. A site adds it to a layout; a form
|
|
726
|
+
// works without it, so nothing emits it automatically.
|
|
727
|
+
if (pathname === FORM_SCRIPT_PATH)
|
|
728
|
+
return new Response(FORM_SCRIPT, {
|
|
729
|
+
headers: {
|
|
730
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
731
|
+
'cache-control': config.development ? 'no-cache' : 'public, max-age=3600',
|
|
732
|
+
},
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
// A visitor posting a form. Before the renderer, which would otherwise try
|
|
736
|
+
// to resolve `/crombolo/forms/...` as a page.
|
|
737
|
+
if (pathname.startsWith(`${FORM_SUBMIT_PATH}/`))
|
|
738
|
+
return request.method === 'POST'
|
|
739
|
+
? submitFormRoute(request, pathname)
|
|
740
|
+
: new Response('Method Not Allowed', { status: 405, headers: { allow: 'POST' } })
|
|
741
|
+
|
|
742
|
+
// Not on an `api` node, which serves no public site to describe.
|
|
743
|
+
if ((pathname === '/sitemap.xml' || pathname === '/robots.txt') && config.role !== 'api')
|
|
744
|
+
return pathname === '/sitemap.xml' ? seo.sitemap(request, url.host) : seo.robots(request)
|
|
745
|
+
|
|
746
|
+
if (pathname.startsWith('/media/')) return serveMedia(pathname, url.searchParams)
|
|
747
|
+
if (pathname.startsWith('/css/')) return serveSiteFile(config.stylesheetsDir, pathname, '.css')
|
|
748
|
+
if (pathname.startsWith('/scripts/')) return serveSiteFile(config.scriptsDir, pathname, '.js')
|
|
749
|
+
return renderSite(request, pathname, url.host)
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* The backoffice, the Management API and everything that serves an editor.
|
|
754
|
+
*
|
|
755
|
+
* Ordering within this function is load-bearing — `authRoutes` intercepts the
|
|
756
|
+
* security paths before the generic API dispatch — but its position relative
|
|
757
|
+
* to the member routes is not: `/umbraco/members` collides with neither the
|
|
758
|
+
* management prefix, the SPA route list, nor the auth prefix.
|
|
759
|
+
*/
|
|
760
|
+
async function serveBackOffice(request: Request, url: URL): Promise<Response | undefined> {
|
|
761
|
+
const { pathname } = url
|
|
762
|
+
|
|
763
|
+
// Non-contract, signed-in: the assistant's chat, review and MCP endpoints.
|
|
764
|
+
// Absent configuration there is no `assistant`, so these paths 404 like any
|
|
765
|
+
// other unknown route.
|
|
766
|
+
if (assistant && pathname.startsWith(`${assistantPath}/`)) {
|
|
767
|
+
const principal = await authenticate(request)
|
|
768
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
769
|
+
const response = await assistant.handle(
|
|
770
|
+
request,
|
|
771
|
+
pathname.slice(assistantPath.length),
|
|
772
|
+
principal,
|
|
773
|
+
)
|
|
774
|
+
if (response) return response
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Non-contract, Settings: what the repository has against what this site has.
|
|
778
|
+
// Settings and not merely signed in, because the diff is the schema's full
|
|
779
|
+
// text and a commit pushes it under this site's credentials — the same
|
|
780
|
+
// authority `authorization.ts` demands for changing a type in the first place.
|
|
781
|
+
// Non-contract: search and sharing settings (Settings) and a page's preview (any editor).
|
|
782
|
+
if (pathname.startsWith(`${seoPath}/`)) {
|
|
783
|
+
const principal = await authenticate(request)
|
|
784
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
785
|
+
const response = await seo.handle(request, pathname.slice(seoPath.length), principal)
|
|
786
|
+
if (response) return response
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (git && pathname.startsWith(`${gitPath}/`)) {
|
|
790
|
+
const principal = await authenticate(request)
|
|
791
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
792
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
793
|
+
const response = await git.handle(request, pathname.slice(gitPath.length), principal)
|
|
794
|
+
if (response) return response
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Non-contract, signed-in: import the schema files into this database. A GET
|
|
798
|
+
// asks what would happen and changes nothing; a POST applies it. Never
|
|
799
|
+
// automatic — see `importSchema`.
|
|
800
|
+
if (pathname === `${paths.pluginPath}/api/schema-import`) {
|
|
801
|
+
const principal = await authenticate(request)
|
|
802
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
803
|
+
// Settings: this applies type changes to the database, which is what every
|
|
804
|
+
// `document-type` write requires, and what the assistant asks of a schema
|
|
805
|
+
// proposal before it writes one.
|
|
806
|
+
if (!hasSection(principal, 'settings')) return new Response('Forbidden', { status: 403 })
|
|
807
|
+
if (request.method !== 'GET' && request.method !== 'POST') {
|
|
808
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
809
|
+
}
|
|
810
|
+
const dryRun = request.method === 'GET'
|
|
811
|
+
const result = await importSchema(db, config, schema, { dryRun })
|
|
812
|
+
if (!dryRun) {
|
|
813
|
+
log.info('{user} imported the schema: {action}, {classification}', {
|
|
814
|
+
user: principal.userName,
|
|
815
|
+
action: result.report?.action ?? 'nothing to do',
|
|
816
|
+
classification: result.classification,
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
return Response.json({
|
|
820
|
+
dryRun,
|
|
821
|
+
classification: result.classification,
|
|
822
|
+
materialised: result.materialised,
|
|
823
|
+
action: result.report?.action,
|
|
824
|
+
reason: result.report?.reason,
|
|
825
|
+
findings: result.findings,
|
|
826
|
+
state: { version: schema.nodeState.version, revision: schema.nodeState.revision },
|
|
827
|
+
})
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Non-contract, signed-in: the declarations the view editor type-checks a
|
|
831
|
+
// template against — `@crombolo/render`'s own sources and the schema's
|
|
832
|
+
// generated content types.
|
|
833
|
+
//
|
|
834
|
+
// Signed in and not Settings, although only Settings can open a template:
|
|
835
|
+
// this is the same vocabulary of type and property aliases that the content
|
|
836
|
+
// editor in front of every backoffice user already shows them, and the
|
|
837
|
+
// assistant's review pane loads a template for anyone who may use it.
|
|
838
|
+
if (pathname === `${paths.pluginPath}/api/editor-types`) {
|
|
839
|
+
const principal = await authenticate(request)
|
|
840
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
841
|
+
if (request.method !== 'GET') return new Response('Method Not Allowed', { status: 405 })
|
|
842
|
+
const body = JSON.stringify({
|
|
843
|
+
libs: await editorTypeLibs({ schemaDir: config.schemaDir }),
|
|
844
|
+
jsxImportSource: siteJsxImportSource(config.siteDir),
|
|
845
|
+
})
|
|
846
|
+
// Revalidated on every open rather than kept: the generated types follow
|
|
847
|
+
// the schema, which a colleague may have changed since this tab was
|
|
848
|
+
// opened. The tag spares the megabyte of React and CSS types when nothing
|
|
849
|
+
// has.
|
|
850
|
+
const etag = `"${Bun.hash(body).toString(36)}"`
|
|
851
|
+
const headers = { 'cache-control': 'private, no-cache', etag }
|
|
852
|
+
if (request.headers.get('if-none-match') === etag)
|
|
853
|
+
return new Response(null, { status: 304, headers })
|
|
854
|
+
return new Response(body, {
|
|
855
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* A bundle's own endpoints, below `api/bundle/<id>/`.
|
|
861
|
+
*
|
|
862
|
+
* Authentication happens here and authorisation happens in the registry
|
|
863
|
+
* against the section the bundle declared, so a bundle never sees an
|
|
864
|
+
* anonymous request and cannot widen its own access. `undefined` means no
|
|
865
|
+
* bundle claims the path, which falls through to the 404 below.
|
|
866
|
+
*/
|
|
867
|
+
if (bundles && pathname.startsWith(bundles.basePath)) {
|
|
868
|
+
const principal = await authenticate(request)
|
|
869
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
870
|
+
const answered = await bundles.handle(request, url, principal)
|
|
871
|
+
if (answered) return answered
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Non-contract: the Packages section's marketplace, installed list and
|
|
876
|
+
* install. There is no operation in the contract for any of it — Umbraco's
|
|
877
|
+
* marketplace is an iframe — so this is ours, and it answers to the
|
|
878
|
+
* Packages section exactly as the operations beside it would.
|
|
879
|
+
*/
|
|
880
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/bundles/`)) {
|
|
881
|
+
const principal = await authenticate(request)
|
|
882
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
883
|
+
if (!hasSection(principal, 'packages')) return new Response('Forbidden', { status: 403 })
|
|
884
|
+
const action = pathname.slice(`${paths.pluginPath}/api/bundles/`.length)
|
|
885
|
+
|
|
886
|
+
if (action === 'marketplace' && request.method === 'GET') {
|
|
887
|
+
return Response.json({
|
|
888
|
+
url: marketplace.url(),
|
|
889
|
+
keyword: config.packageKeyword,
|
|
890
|
+
appliesContent: config.applyBundleContentOnInstall,
|
|
891
|
+
items: await marketplace.search(url.searchParams.get('q') ?? undefined),
|
|
892
|
+
})
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// A created bundle's npm name and version: not in Umbraco's definition, so
|
|
896
|
+
// not in the contract, and set from the Created view beside it.
|
|
897
|
+
const created = /^created\/([0-9a-f-]{36})\/package$/i.exec(action)
|
|
898
|
+
if (created) {
|
|
899
|
+
const id = created[1] as string
|
|
900
|
+
if (request.method === 'GET') {
|
|
901
|
+
const found = await readCreatedBundlePackage(db, id)
|
|
902
|
+
return found ? Response.json(found) : new Response('Not Found', { status: 404 })
|
|
903
|
+
}
|
|
904
|
+
if (request.method === 'PUT') {
|
|
905
|
+
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
|
|
906
|
+
const outcome = await writeCreatedBundlePackage(db, id, body)
|
|
907
|
+
return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
if (action === 'installed' && request.method === 'GET') {
|
|
912
|
+
return Response.json({ items: await marketplace.installed() })
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
|
|
916
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
917
|
+
name?: unknown
|
|
918
|
+
version?: unknown
|
|
919
|
+
applyContent?: unknown
|
|
920
|
+
}
|
|
921
|
+
const name = typeof body.name === 'string' ? body.name : ''
|
|
922
|
+
const version = typeof body.version === 'string' ? body.version : undefined
|
|
923
|
+
const outcome =
|
|
924
|
+
action === 'install'
|
|
925
|
+
? await marketplace.install(name, version, {
|
|
926
|
+
applyContent: body.applyContent !== false,
|
|
927
|
+
})
|
|
928
|
+
: await marketplace.uninstall(name)
|
|
929
|
+
log.info('{user} {action} the package {name}: {outcome}', {
|
|
930
|
+
user: principal.email,
|
|
931
|
+
action: action === 'install' ? 'installed' : 'removed',
|
|
932
|
+
name,
|
|
933
|
+
outcome: outcome.ok ? 'ok' : outcome.message,
|
|
934
|
+
})
|
|
935
|
+
return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
return new Response('Not Found', { status: 404 })
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Non-contract: whether this site can send e-mail, and what is unavailable
|
|
943
|
+
* because it cannot.
|
|
944
|
+
*
|
|
945
|
+
* Any signed-in user may ask, because the features it gates are spread
|
|
946
|
+
* across sections — invitations live in Users, the explanation belongs
|
|
947
|
+
* wherever someone finds the option missing. The provider's `description`
|
|
948
|
+
* is deliberately left out: for a custom adapter it is an internal URL.
|
|
949
|
+
*/
|
|
950
|
+
if (pathname === `${paths.pluginPath}/api/email` && request.method === 'GET') {
|
|
951
|
+
if (!(await authenticate(request))) return new Response('Unauthorized', { status: 401 })
|
|
952
|
+
return Response.json({
|
|
953
|
+
available: emailStatus.available,
|
|
954
|
+
provider: emailStatus.provider,
|
|
955
|
+
reason: emailStatus.reason,
|
|
956
|
+
affects: emailStatus.affects,
|
|
957
|
+
canSendUserLinks,
|
|
958
|
+
})
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Non-contract: forms and their entries. There is no form operation in the
|
|
963
|
+
* vendored contract — Umbraco Forms ships its own API and its own client,
|
|
964
|
+
* and neither is here — so this is ours (`docs/forms.md`).
|
|
965
|
+
*
|
|
966
|
+
* Definitions are read from `schema/` per request rather than cached: the
|
|
967
|
+
* files are the truth, the backoffice may have just rewritten one, and a
|
|
968
|
+
* picker that disagreed with what a render uses would be worse than slow.
|
|
969
|
+
*/
|
|
970
|
+
if (pathname.startsWith(`${paths.pluginPath}/api/forms`)) {
|
|
971
|
+
const principal = await authenticate(request)
|
|
972
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
973
|
+
const action = pathname.slice(`${paths.pluginPath}/api/forms`.length).replace(/^\//, '')
|
|
974
|
+
// Through the registry the renderer uses, so the designer and a render
|
|
975
|
+
// cannot disagree; a save invalidates it rather than waiting out the memo.
|
|
976
|
+
const definitions = forms.all()
|
|
977
|
+
|
|
978
|
+
// A definition is structure, like a content type: anyone who may open the
|
|
979
|
+
// section may read it, and the picker needs it in the Content section too.
|
|
980
|
+
if (action === '' && request.method === 'GET')
|
|
981
|
+
return Response.json({ items: definitions.map(formSummary) })
|
|
982
|
+
|
|
983
|
+
const entries = new FormEntryRepository(db)
|
|
984
|
+
const byKey = formsByKey(definitions)
|
|
985
|
+
// Submissions are personal data, so reading them asks for more than a
|
|
986
|
+
// session. Four verbs, because designing a form and reading what people
|
|
987
|
+
// sent it are different jobs (`docs/forms.md`).
|
|
988
|
+
const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
|
|
989
|
+
const formFiles = {
|
|
990
|
+
schemaDir: config.schemaDir,
|
|
991
|
+
writable: config.schemaWritable,
|
|
992
|
+
onChanged: config.schemaStore
|
|
993
|
+
? async () => {
|
|
994
|
+
await publishSchema(config.schemaStore as SchemaStore, config.schemaDir)
|
|
995
|
+
}
|
|
996
|
+
: undefined,
|
|
997
|
+
}
|
|
998
|
+
const maySeeEntries = may(FormPermissions.EntriesView)
|
|
999
|
+
const mayManageEntries = may(FormPermissions.EntriesManage)
|
|
1000
|
+
// The group alias still counts: `sensitiveData` is how Umbraco carries
|
|
1001
|
+
// this and how members already work, so both routes to it are honoured.
|
|
1002
|
+
const maySeeSensitive =
|
|
1003
|
+
may(FormPermissions.EntriesSensitive) || hasAccessToSensitiveData(principal.groups)
|
|
1004
|
+
|
|
1005
|
+
const entryMatch = /^entries\/([^/]+)(?:\/(state))?$/.exec(action)
|
|
1006
|
+
if (entryMatch) {
|
|
1007
|
+
if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
|
|
1008
|
+
const id = decodeURIComponent(entryMatch[1] as string)
|
|
1009
|
+
if (entryMatch[2] === 'state' && request.method === 'POST') {
|
|
1010
|
+
if (!mayManageEntries) return new Response('Forbidden', { status: 403 })
|
|
1011
|
+
const body = (await request.json().catch(() => ({}))) as { state?: unknown }
|
|
1012
|
+
const next = parseEntryState(typeof body.state === 'string' ? body.state : null)
|
|
1013
|
+
if (!next) return new Response('Bad Request', { status: 400 })
|
|
1014
|
+
const updated = await entries.setState(id, next)
|
|
1015
|
+
if (!updated) return new Response('Not Found', { status: 404 })
|
|
1016
|
+
// Approval is the other trigger: a workflow declared `on = "approve"`
|
|
1017
|
+
// runs now, from the values the entry holds.
|
|
1018
|
+
if (next === 'approved') {
|
|
1019
|
+
const form = byKey.get(updated.formKey)
|
|
1020
|
+
const onApprove = form?.workflows.filter((workflow) => workflow.on === 'approve') ?? []
|
|
1021
|
+
if (onApprove.length > 0)
|
|
1022
|
+
await new FormWorkflowRepository(db).enqueue(
|
|
1023
|
+
onApprove.map((workflow) => ({
|
|
1024
|
+
formKey: updated.formKey,
|
|
1025
|
+
formAlias: updated.formAlias,
|
|
1026
|
+
workflowName: workflow.name,
|
|
1027
|
+
workflowType: workflow.type,
|
|
1028
|
+
runOn: workflow.on,
|
|
1029
|
+
entryId: updated.id,
|
|
1030
|
+
payload: updated.values.map((value) => ({
|
|
1031
|
+
fieldAlias: value.fieldAlias,
|
|
1032
|
+
values: value.values,
|
|
1033
|
+
})),
|
|
1034
|
+
})),
|
|
1035
|
+
)
|
|
1036
|
+
}
|
|
1037
|
+
log.info('{user} set form entry {id} to {state}', {
|
|
1038
|
+
user: principal.email,
|
|
1039
|
+
id,
|
|
1040
|
+
state: next,
|
|
1041
|
+
})
|
|
1042
|
+
return Response.json({ id: updated.id, state: updated.state })
|
|
1043
|
+
}
|
|
1044
|
+
if (request.method === 'DELETE') {
|
|
1045
|
+
if (!mayManageEntries) return new Response('Forbidden', { status: 403 })
|
|
1046
|
+
if (!(await entries.remove(id))) return new Response('Not Found', { status: 404 })
|
|
1047
|
+
log.info('{user} deleted form entry {id}', { user: principal.email, id })
|
|
1048
|
+
return new Response(null, { status: 200 })
|
|
1049
|
+
}
|
|
1050
|
+
if (request.method === 'GET') {
|
|
1051
|
+
const entry = await entries.byId(id)
|
|
1052
|
+
if (!entry) return new Response('Not Found', { status: 404 })
|
|
1053
|
+
return Response.json({
|
|
1054
|
+
...entry,
|
|
1055
|
+
values: redactSensitive(entry, byKey.get(entry.formKey), maySeeSensitive),
|
|
1056
|
+
})
|
|
1057
|
+
}
|
|
1058
|
+
return new Response('Method Not Allowed', { status: 405 })
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const csvMatch = /^([^/]+)\/entries\.csv$/.exec(action)
|
|
1062
|
+
if (csvMatch && request.method === 'GET') {
|
|
1063
|
+
if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
|
|
1064
|
+
const key = decodeURIComponent(csvMatch[1] as string)
|
|
1065
|
+
const form = byKey.get(key)
|
|
1066
|
+
if (!form) return new Response('Not Found', { status: 404 })
|
|
1067
|
+
const page = await entries.list({ formKey: key, take: 10_000 })
|
|
1068
|
+
const result = entriesCsv(form, page.items, { includeSensitive: maySeeSensitive })
|
|
1069
|
+
if (!result.ok)
|
|
1070
|
+
return new Response(result.reason, {
|
|
1071
|
+
status: 403,
|
|
1072
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1073
|
+
})
|
|
1074
|
+
return new Response(result.csv, {
|
|
1075
|
+
headers: {
|
|
1076
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
1077
|
+
'content-disposition': `attachment; filename="${result.fileName}"`,
|
|
1078
|
+
},
|
|
1079
|
+
})
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const listMatch = /^([^/]+)\/entries$/.exec(action)
|
|
1083
|
+
if (listMatch && request.method === 'GET') {
|
|
1084
|
+
if (!maySeeEntries) return new Response('Forbidden', { status: 403 })
|
|
1085
|
+
const key = decodeURIComponent(listMatch[1] as string)
|
|
1086
|
+
const since = url.searchParams.get('from')
|
|
1087
|
+
const until = url.searchParams.get('to')
|
|
1088
|
+
const page = await entries.list({
|
|
1089
|
+
formKey: key,
|
|
1090
|
+
state: parseEntryState(url.searchParams.get('state')),
|
|
1091
|
+
from: since ? new Date(since) : undefined,
|
|
1092
|
+
to: until ? new Date(until) : undefined,
|
|
1093
|
+
search: url.searchParams.get('search') ?? undefined,
|
|
1094
|
+
includeSpam: url.searchParams.get('includeSpam') === 'true',
|
|
1095
|
+
skip: Number(url.searchParams.get('skip') ?? 0) || 0,
|
|
1096
|
+
take: Number(url.searchParams.get('take') ?? 100) || 100,
|
|
1097
|
+
})
|
|
1098
|
+
const form = byKey.get(key)
|
|
1099
|
+
return Response.json({
|
|
1100
|
+
total: page.total,
|
|
1101
|
+
items: page.items.map((entry) => ({
|
|
1102
|
+
...entry,
|
|
1103
|
+
values: redactSensitive(entry, form, maySeeSensitive),
|
|
1104
|
+
})),
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
if (action && request.method === 'GET') {
|
|
1109
|
+
const form = byKey.get(decodeURIComponent(action))
|
|
1110
|
+
if (!form) return new Response('Not Found', { status: 404 })
|
|
1111
|
+
// The whole definition, which is what the designer edits.
|
|
1112
|
+
return Response.json({ ...formDetail(form), definition: form })
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Designing a form is a developer's job, so it is its own verb rather
|
|
1116
|
+
// than something an entry reader can do.
|
|
1117
|
+
if (request.method === 'POST' || request.method === 'PUT') {
|
|
1118
|
+
if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
|
|
1119
|
+
const body = await request.json().catch(() => ({}))
|
|
1120
|
+
const written = await saveForm(coerceForm(body), formFiles)
|
|
1121
|
+
if (!written.ok) {
|
|
1122
|
+
log.warning('{user} could not save a form: {detail}', {
|
|
1123
|
+
user: principal.email,
|
|
1124
|
+
detail: written.problems.map((problem) => problem.message).join('; '),
|
|
1125
|
+
})
|
|
1126
|
+
return Response.json(
|
|
1127
|
+
{ status: written.status, problems: written.problems },
|
|
1128
|
+
{ status: written.status === 'readOnly' ? 409 : 400 },
|
|
1129
|
+
)
|
|
1130
|
+
}
|
|
1131
|
+
// The memo would otherwise hold the old definition for a second, which
|
|
1132
|
+
// is exactly long enough for the designer to reload and see it.
|
|
1133
|
+
forms.invalidate()
|
|
1134
|
+
log.info('{user} saved the form {form}', {
|
|
1135
|
+
user: principal.email,
|
|
1136
|
+
form: coerceForm(body).alias,
|
|
1137
|
+
})
|
|
1138
|
+
return Response.json({ ok: true })
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (action && request.method === 'DELETE') {
|
|
1142
|
+
if (!may(FormPermissions.Manage)) return new Response('Forbidden', { status: 403 })
|
|
1143
|
+
const form = byKey.get(decodeURIComponent(action))
|
|
1144
|
+
if (!form) return new Response('Not Found', { status: 404 })
|
|
1145
|
+
const removed = await deleteForm(form.alias, formFiles)
|
|
1146
|
+
if (!removed.ok)
|
|
1147
|
+
return Response.json(
|
|
1148
|
+
{ status: removed.status, problems: removed.problems },
|
|
1149
|
+
{ status: removed.status === 'readOnly' ? 409 : 404 },
|
|
1150
|
+
)
|
|
1151
|
+
forms.invalidate()
|
|
1152
|
+
log.info('{user} deleted the form {form}; its entries are kept', {
|
|
1153
|
+
user: principal.email,
|
|
1154
|
+
form: form.alias,
|
|
1155
|
+
})
|
|
1156
|
+
return Response.json({ ok: true })
|
|
1157
|
+
}
|
|
1158
|
+
return new Response('Not Found', { status: 404 })
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Non-contract: what the Changes dashboard and the read-only banner read.
|
|
1162
|
+
//
|
|
1163
|
+
// Two audiences, so two answers. `health` says this node has fallen behind
|
|
1164
|
+
// and writes will be refused, which is the banner in the header — every
|
|
1165
|
+
// editor needs to see that, and gating it would leave them with failing saves
|
|
1166
|
+
// and no explanation. The findings name documents and the properties that are
|
|
1167
|
+
// unfilled: that is the Settings dashboard's material, and the dashboard's own
|
|
1168
|
+
// manifest is already `Umb.Condition.SectionAlias` on Settings.
|
|
1169
|
+
if (pathname === `${paths.pluginPath}/api/change-report`) {
|
|
1170
|
+
const principal = await authenticate(request)
|
|
1171
|
+
if (!principal) return new Response('Unauthorized', { status: 401 })
|
|
1172
|
+
if (!hasSection(principal, 'settings')) return Response.json({ health: health() })
|
|
1173
|
+
const findings = await readReport(db, { includeResolved: true })
|
|
1174
|
+
const counts: Record<string, number> = {}
|
|
1175
|
+
// Per source as well as overall, so the dashboard can separate a pending
|
|
1176
|
+
// upgrade from a content bundle waiting on a decision.
|
|
1177
|
+
const bySource: Record<string, Record<string, number>> = {}
|
|
1178
|
+
for (const f of findings) {
|
|
1179
|
+
if (f.status !== 'open') continue
|
|
1180
|
+
counts[f.kind] = (counts[f.kind] ?? 0) + 1
|
|
1181
|
+
const source = bySource[f.source] ?? {}
|
|
1182
|
+
source[f.kind] = (source[f.kind] ?? 0) + 1
|
|
1183
|
+
bySource[f.source] = source
|
|
1184
|
+
}
|
|
1185
|
+
// A bundle's tables are part of what an upgrade changes, so the dashboard
|
|
1186
|
+
// that shows an upgrade's findings shows their steps too.
|
|
1187
|
+
const bundles = await bundleTablesReport(db, chains)
|
|
1188
|
+
return Response.json({ health: health(), counts, bySource, findings, bundles })
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
const staticMatch = resolveStaticFile(paths, pathname, {
|
|
1192
|
+
extensions: extensions.list(),
|
|
1193
|
+
immutable: config.immutableAssets ?? !config.development,
|
|
1194
|
+
})
|
|
1195
|
+
if (staticMatch) {
|
|
1196
|
+
return (
|
|
1197
|
+
(await serveStaticFile(staticMatch, request)) ?? new Response('Not Found', { status: 404 })
|
|
1198
|
+
)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
const loginAsset = resolveLoginAsset(paths.backOfficePath, pathname)
|
|
1202
|
+
if (loginAsset) {
|
|
1203
|
+
const response = await serveStaticFile(
|
|
1204
|
+
{ file: loginAsset, cacheControl: 'no-cache' },
|
|
1205
|
+
request,
|
|
1206
|
+
)
|
|
1207
|
+
if (response) return response
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const graphic = resolveGraphic(paths, pathname)
|
|
1211
|
+
if (graphic) {
|
|
1212
|
+
const response = await serveStaticFile({ file: graphic, cacheControl: 'no-cache' }, request)
|
|
1213
|
+
if (response) return response
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const authResponse = await authRoutes.handle(request, pathname)
|
|
1217
|
+
if (authResponse) return authResponse
|
|
1218
|
+
|
|
1219
|
+
if (pathname.startsWith(apiPrefix)) {
|
|
1220
|
+
if (pathname === `${apiPrefix}openapi.json`) {
|
|
1221
|
+
return new Response(Bun.file(CONTRACT_PATH), {
|
|
1222
|
+
headers: { 'content-type': 'application/json' },
|
|
1223
|
+
})
|
|
1224
|
+
}
|
|
1225
|
+
return api.dispatch(request)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if (pathname === `${paths.backOfficePath}/login`) {
|
|
1229
|
+
const { importmap } = collectManifests(paths, extensions.list())
|
|
1230
|
+
return html(
|
|
1231
|
+
renderLoginShell(paths, importmap, {
|
|
1232
|
+
defaultUiLanguage: config.defaultUiLanguage,
|
|
1233
|
+
keepUserLoggedIn: config.keepUserLoggedIn,
|
|
1234
|
+
title: config.siteName,
|
|
1235
|
+
usernameIsEmail: true,
|
|
1236
|
+
allowUserInvite: false,
|
|
1237
|
+
// Offering "forgot password" with no way to send the link is a dead
|
|
1238
|
+
// end that looks like a bug; without a sender the option is not there.
|
|
1239
|
+
allowPasswordReset: config.allowPasswordReset && canSendUserLinks,
|
|
1240
|
+
disableLocalLogin: !config.allowLocalLogin,
|
|
1241
|
+
// Set by the authorize endpoint so sign-in resumes the OAuth flow. Only
|
|
1242
|
+
// a path on this site: the login page hands this to the browser once
|
|
1243
|
+
// the session exists, so anything else is an open redirect at the worst
|
|
1244
|
+
// possible moment, and `javascript:` there would run signed in.
|
|
1245
|
+
returnUrl: localReturnUrl(url.searchParams.get('returnUrl')) ?? paths.backOfficePath,
|
|
1246
|
+
}),
|
|
1247
|
+
)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (isSpaRoute(paths.backOfficePath, pathname)) {
|
|
1251
|
+
const { importmap } = collectManifests(paths, extensions.list())
|
|
1252
|
+
return html(
|
|
1253
|
+
renderBackOfficeShell(paths, importmap, {
|
|
1254
|
+
defaultUiLanguage: config.defaultUiLanguage,
|
|
1255
|
+
keepUserLoggedIn: config.keepUserLoggedIn,
|
|
1256
|
+
title: config.siteName,
|
|
1257
|
+
}),
|
|
1258
|
+
)
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
return undefined
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
/** A stylesheet or script the Settings section edits, from its folder; nothing else from there. */
|
|
1265
|
+
async function serveSiteFile(root: string, pathname: string, extension: string) {
|
|
1266
|
+
const segments = decodeURIComponent(pathname).split('/').slice(2)
|
|
1267
|
+
const safe =
|
|
1268
|
+
segments.length > 0 &&
|
|
1269
|
+
segments.every((s) => s !== '' && s !== '.' && s !== '..' && !s.includes('\\')) &&
|
|
1270
|
+
segments.at(-1)?.toLowerCase().endsWith(extension)
|
|
1271
|
+
const file = safe ? Bun.file(join(root, ...segments)) : undefined
|
|
1272
|
+
if (!file || !(await file.exists())) return new Response('Not Found', { status: 404 })
|
|
1273
|
+
const headers = { 'cache-control': 'no-cache', 'content-type': file.type }
|
|
1274
|
+
// Bun.serve answers an empty file body 204, and an empty stylesheet is still one
|
|
1275
|
+
return new Response(file.size === 0 ? '' : file, { status: 200, headers })
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* An uploaded file from the media store; an image with a resize or crop query
|
|
1280
|
+
* is served as that variant, computed once and cached in the store.
|
|
1281
|
+
*
|
|
1282
|
+
* A store that serves its own bytes (a CDN, a presigned bucket URL) gets the
|
|
1283
|
+
* browser sent straight there. Otherwise this streams them — from the local
|
|
1284
|
+
* file when there is one, so a disk-backed site still gets sendfile.
|
|
1285
|
+
*/
|
|
1286
|
+
async function serveMedia(pathname: string, search: URLSearchParams): Promise<Response> {
|
|
1287
|
+
// Media is what someone else uploaded, served from this origin, so a browser
|
|
1288
|
+
// must not guess a type the store did not give.
|
|
1289
|
+
const cacheable = {
|
|
1290
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
1291
|
+
'x-content-type-options': 'nosniff',
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* `sandbox` for the types that carry script, which Umbraco's upload rules
|
|
1295
|
+
* allow into the library as ours do. Only those: opening a PDF inline is
|
|
1296
|
+
* something a media library is expected to do, and sandboxing every response
|
|
1297
|
+
* would take that away to no purpose. An `<img src>` is unaffected either way
|
|
1298
|
+
* — what this stops is opening an SVG or an HTML file as a page.
|
|
1299
|
+
*/
|
|
1300
|
+
const protective = (contentType: string) =>
|
|
1301
|
+
/^(?:image\/svg\+xml|text\/html|application\/xhtml\+xml|(?:text|application)\/xml)\b/.test(
|
|
1302
|
+
contentType,
|
|
1303
|
+
)
|
|
1304
|
+
? { ...cacheable, 'content-security-policy': 'sandbox' }
|
|
1305
|
+
: cacheable
|
|
1306
|
+
// A prefix may be a path on this host as well as an absolute URL, and
|
|
1307
|
+
// Response.redirect insists on the latter.
|
|
1308
|
+
const sendTo = (location: string) =>
|
|
1309
|
+
new Response(null, { status: 302, headers: { location, ...cacheable } })
|
|
1310
|
+
const key = mediaFiles.key(pathname)
|
|
1311
|
+
if (!key) return new Response('Not Found', { status: 404 })
|
|
1312
|
+
const request = parseImagingQuery(search)
|
|
1313
|
+
const extension = key.split('.').pop()?.toLowerCase() ?? ''
|
|
1314
|
+
|
|
1315
|
+
if (request && DEFAULT_UPLOAD_SETTINGS.imageFileTypes.includes(extension)) {
|
|
1316
|
+
const source = await mediaStore.get(key)
|
|
1317
|
+
if (source) {
|
|
1318
|
+
try {
|
|
1319
|
+
const variant = await images.variant(source, request)
|
|
1320
|
+
const elsewhere = mediaStore.publicUrl?.(variant.key)
|
|
1321
|
+
if (elsewhere) return sendTo(elsewhere)
|
|
1322
|
+
return new Response(variant.bytes as Uint8Array<ArrayBuffer>, {
|
|
1323
|
+
headers: { ...cacheable, 'content-type': variant.contentType },
|
|
1324
|
+
})
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
log.error('Could not process image {path}', { path: pathname, error })
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const elsewhere = mediaStore.publicUrl?.(key)
|
|
1332
|
+
if (elsewhere) return sendTo(elsewhere)
|
|
1333
|
+
const stored = await mediaStore.get(key)
|
|
1334
|
+
if (!stored) return new Response('Not Found', { status: 404 })
|
|
1335
|
+
const headers = { ...protective(stored.contentType), 'content-type': stored.contentType }
|
|
1336
|
+
if (stored.localPath) return new Response(Bun.file(stored.localPath), { headers })
|
|
1337
|
+
return new Response((await stored.bytes()) as Uint8Array<ArrayBuffer>, { headers })
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Preview: a signed-in editor with the preview cookie sees drafts. The
|
|
1342
|
+
* backoffice's preview app frames `/<document key>`; a routed URL renders the
|
|
1343
|
+
* draft of the page it resolves to.
|
|
1344
|
+
*/
|
|
1345
|
+
async function renderPreview(request: Request, pathname: string, host: string) {
|
|
1346
|
+
if (!readCookie(request, PREVIEW_COOKIE) || !(await authenticate(request))) return undefined
|
|
1347
|
+
const byKey = /^\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i.exec(
|
|
1348
|
+
pathname,
|
|
1349
|
+
)
|
|
1350
|
+
const key = byKey?.[1] ?? (await cache.resolve(host, pathname))?.content.key
|
|
1351
|
+
if (!key) return undefined
|
|
1352
|
+
const draft = await draftModel(key, new URL(request.url).searchParams.get('culture'))
|
|
1353
|
+
if (!draft) return undefined
|
|
1354
|
+
return renderer.renderModel(draft.content, draft.culture, await pageRequest(request))
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/** What every rendered page is told about the request and the site's search defaults. */
|
|
1358
|
+
async function pageRequest(request: Request) {
|
|
1359
|
+
return {
|
|
1360
|
+
origin: originOf(request, config),
|
|
1361
|
+
url: request.url,
|
|
1362
|
+
seo: await seo.siteDefaults(),
|
|
1363
|
+
signal: request.signal,
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* The front end: resolve the route against the published cache and render.
|
|
1369
|
+
*
|
|
1370
|
+
* Public access is enforced here, not in the renderer: a protected page is
|
|
1371
|
+
* *rewritten* to its login or error page, as Umbraco does, so the visitor
|
|
1372
|
+
* keeps the URL they asked for. Preview is exempt — only a signed-in editor
|
|
1373
|
+
* reaches it.
|
|
1374
|
+
*/
|
|
1375
|
+
/**
|
|
1376
|
+
* A form submission from a visitor. Not the Management API: the caller is the
|
|
1377
|
+
* public, and there is no operation in the vendored contract for it.
|
|
1378
|
+
*
|
|
1379
|
+
* Answers JSON to a `fetch` caller and a 303 to a browser, so a form works
|
|
1380
|
+
* with no JavaScript — `form-submit.ts` says why the state goes in a cookie
|
|
1381
|
+
* rather than a body.
|
|
1382
|
+
*/
|
|
1383
|
+
async function submitFormRoute(request: Request, pathname: string): Promise<Response> {
|
|
1384
|
+
const key = decodeURIComponent(pathname.slice(`${FORM_SUBMIT_PATH}/`.length))
|
|
1385
|
+
// Where to send a browser back to. The form was rendered on a page, and the
|
|
1386
|
+
// referer is the only thing that names it without the view passing it down.
|
|
1387
|
+
const back = (() => {
|
|
1388
|
+
const referer = request.headers.get('referer')
|
|
1389
|
+
if (!referer) return '/'
|
|
1390
|
+
try {
|
|
1391
|
+
const url = new URL(referer)
|
|
1392
|
+
return `${url.pathname}${url.search}`
|
|
1393
|
+
} catch {
|
|
1394
|
+
return '/'
|
|
1395
|
+
}
|
|
1396
|
+
})()
|
|
1397
|
+
|
|
1398
|
+
const outcome = await submitForm(
|
|
1399
|
+
{
|
|
1400
|
+
db,
|
|
1401
|
+
forms,
|
|
1402
|
+
mediaFiles,
|
|
1403
|
+
uploads: DEFAULT_UPLOAD_SETTINGS,
|
|
1404
|
+
urlOf: async (contentKey) => (await cache.byKey(contentKey))?.url,
|
|
1405
|
+
},
|
|
1406
|
+
request,
|
|
1407
|
+
key,
|
|
1408
|
+
{ ip: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() },
|
|
1409
|
+
)
|
|
1410
|
+
|
|
1411
|
+
const json = wantsJson(request)
|
|
1412
|
+
const flash = (state: Parameters<typeof writeFlash>[1]) =>
|
|
1413
|
+
serializeCookie({
|
|
1414
|
+
name: FORM_FLASH_COOKIE,
|
|
1415
|
+
value: writeFlash(formSecret, state),
|
|
1416
|
+
secure: config.secureCookies,
|
|
1417
|
+
maxAgeSeconds: 60,
|
|
1418
|
+
})
|
|
1419
|
+
const plain = (body: string, status: number) =>
|
|
1420
|
+
new Response(body, { status, headers: { 'content-type': 'text/plain; charset=utf-8' } })
|
|
1421
|
+
|
|
1422
|
+
switch (outcome.status) {
|
|
1423
|
+
case 'notFound':
|
|
1424
|
+
return json
|
|
1425
|
+
? Response.json({ ok: false, error: 'No such form.' }, { status: 404 })
|
|
1426
|
+
: plain('Not Found', 404)
|
|
1427
|
+
case 'badToken': {
|
|
1428
|
+
// Stale or forged. Somebody who left the page open overnight lands here,
|
|
1429
|
+
// so it says what to do rather than only refusing.
|
|
1430
|
+
const message = 'This form has expired. Reload the page and try again.'
|
|
1431
|
+
return json
|
|
1432
|
+
? Response.json({ ok: false, error: message }, { status: 400 })
|
|
1433
|
+
: plain(message, 400)
|
|
1434
|
+
}
|
|
1435
|
+
case 'full': {
|
|
1436
|
+
const message = 'This form is no longer accepting entries.'
|
|
1437
|
+
return json
|
|
1438
|
+
? Response.json({ ok: false, error: message }, { status: 409 })
|
|
1439
|
+
: plain(message, 409)
|
|
1440
|
+
}
|
|
1441
|
+
case 'invalid':
|
|
1442
|
+
return json
|
|
1443
|
+
? Response.json({ ok: false, errors: outcome.state.errors }, { status: 422 })
|
|
1444
|
+
: new Response(null, {
|
|
1445
|
+
status: 303,
|
|
1446
|
+
headers: { location: back, 'set-cookie': flash(outcome.state) },
|
|
1447
|
+
})
|
|
1448
|
+
case 'ok':
|
|
1449
|
+
return json
|
|
1450
|
+
? Response.json({ ok: true, message: outcome.state.message, entryId: outcome.entryId })
|
|
1451
|
+
: new Response(null, {
|
|
1452
|
+
status: 303,
|
|
1453
|
+
headers: {
|
|
1454
|
+
location: outcome.redirectTo ?? back,
|
|
1455
|
+
'set-cookie': flash(outcome.state),
|
|
1456
|
+
},
|
|
1457
|
+
})
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
async function renderSite(request: Request, pathname: string, host: string): Promise<Response> {
|
|
1462
|
+
const preview = await renderPreview(request, pathname, host)
|
|
1463
|
+
// An `api` node renders for preview and nothing else. The backoffice opens a
|
|
1464
|
+
// site URL to preview a draft, so the renderer has to be here; serving the
|
|
1465
|
+
// published site as well would mean a second public copy of it on whatever
|
|
1466
|
+
// address the editing box answers.
|
|
1467
|
+
if (!preview && config.role === 'api')
|
|
1468
|
+
return new Response('Not Found', {
|
|
1469
|
+
status: 404,
|
|
1470
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1471
|
+
})
|
|
1472
|
+
const member = preview ? undefined : await members.resolve(request)
|
|
1473
|
+
// What a submission redirected back with, if anything. Read once and
|
|
1474
|
+
// cleared, so a reload shows the page rather than the thank-you again.
|
|
1475
|
+
const cookie = readCookie(request, FORM_FLASH_COOKIE)
|
|
1476
|
+
const submission = cookie ? readFlash(formSecret, cookie) : undefined
|
|
1477
|
+
const result =
|
|
1478
|
+
preview ??
|
|
1479
|
+
(await renderer.render(pathname, host, {
|
|
1480
|
+
member,
|
|
1481
|
+
submission,
|
|
1482
|
+
access: (content) => members.decide(content, member),
|
|
1483
|
+
...(await pageRequest(request)),
|
|
1484
|
+
}))
|
|
1485
|
+
const cleared: Record<string, string> = cookie
|
|
1486
|
+
? { 'set-cookie': expireCookie(FORM_FLASH_COOKIE, config.secureCookies) }
|
|
1487
|
+
: {}
|
|
1488
|
+
switch (result.status) {
|
|
1489
|
+
case 'ok':
|
|
1490
|
+
return new Response(result.html, {
|
|
1491
|
+
headers: { 'content-type': 'text/html; charset=utf-8', ...cleared },
|
|
1492
|
+
})
|
|
1493
|
+
case 'redirect':
|
|
1494
|
+
// Umbraco's `umbracoRedirect` answers 302: the page may stop redirecting.
|
|
1495
|
+
return new Response(null, {
|
|
1496
|
+
status: 302,
|
|
1497
|
+
headers: { location: withQuery(result.location, request.url), ...cleared },
|
|
1498
|
+
})
|
|
1499
|
+
case 'noTemplate':
|
|
1500
|
+
// Content exists but renders nothing, which Umbraco also treats as a 404.
|
|
1501
|
+
return new Response(`No template is assigned to '${result.content.name}'.`, {
|
|
1502
|
+
status: 404,
|
|
1503
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1504
|
+
})
|
|
1505
|
+
case 'error': {
|
|
1506
|
+
// The visitor left and the render was stopped for it: nobody reads this
|
|
1507
|
+
// response, and it says nothing about the template.
|
|
1508
|
+
if (request.signal.aborted) return new Response(null, { status: 499, headers: cleared })
|
|
1509
|
+
// Through `inSource`, so the file named is the one somebody can open
|
|
1510
|
+
// rather than the snapshot generation it was imported from.
|
|
1511
|
+
const detail = renderer.inSource(result.error.stack ?? result.error.message)
|
|
1512
|
+
renderLog.error('Template error while rendering {name} ({key}): {detail}', {
|
|
1513
|
+
name: result.content.name,
|
|
1514
|
+
key: result.content.key,
|
|
1515
|
+
detail,
|
|
1516
|
+
})
|
|
1517
|
+
return new Response(
|
|
1518
|
+
config.development
|
|
1519
|
+
? `Template error while rendering '${result.content.name}':\n\n${detail}`
|
|
1520
|
+
: 'Server Error',
|
|
1521
|
+
{ status: 500, headers: { 'content-type': 'text/plain; charset=utf-8' } },
|
|
1522
|
+
)
|
|
1523
|
+
}
|
|
1524
|
+
default: {
|
|
1525
|
+
// Only once no page answered, so a redirect can never hide live content.
|
|
1526
|
+
const redirect = await cache.redirect(host, pathname)
|
|
1527
|
+
if (redirect)
|
|
1528
|
+
return new Response(null, {
|
|
1529
|
+
status: redirect.statusCode,
|
|
1530
|
+
headers: {
|
|
1531
|
+
location: withQuery(redirect.location, request.url),
|
|
1532
|
+
// Umbraco sends a 301 uncacheable on purpose: browsers cache them
|
|
1533
|
+
// hard, so a page renamed and then renamed back would keep
|
|
1534
|
+
// redirecting for visitors who saw the first answer.
|
|
1535
|
+
'cache-control': 'no-store, must-revalidate',
|
|
1536
|
+
pragma: 'no-cache',
|
|
1537
|
+
expires: '0',
|
|
1538
|
+
},
|
|
1539
|
+
})
|
|
1540
|
+
// A site with nothing published is not a 404 anybody can act on, so it
|
|
1541
|
+
// explains itself instead. Still a 404: see `no-nodes.ts`.
|
|
1542
|
+
const snapshot = await cache.snapshot()
|
|
1543
|
+
if ([...snapshot.views.values()].every((view) => view.roots.length === 0))
|
|
1544
|
+
return new Response(
|
|
1545
|
+
noNodesPage({ siteName: config.siteName, backOfficePath: config.backOfficePath }),
|
|
1546
|
+
{ status: 404, headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
1547
|
+
)
|
|
1548
|
+
const page = await renderNotFoundPage(request, pathname, host, member)
|
|
1549
|
+
if (page) return page
|
|
1550
|
+
return new Response('Not Found', {
|
|
1551
|
+
status: 404,
|
|
1552
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
1553
|
+
})
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* The page chosen to answer an address nothing else does, with status 404. A
|
|
1560
|
+
* page that fails to render leaves the plain answer rather than a second error.
|
|
1561
|
+
*/
|
|
1562
|
+
async function renderNotFoundPage(
|
|
1563
|
+
request: Request,
|
|
1564
|
+
pathname: string,
|
|
1565
|
+
host: string,
|
|
1566
|
+
member: Awaited<ReturnType<typeof members.resolve>>,
|
|
1567
|
+
): Promise<Response | undefined> {
|
|
1568
|
+
const found = await seo.notFound(host, pathname)
|
|
1569
|
+
if (!found) return undefined
|
|
1570
|
+
const result = await renderer.renderModel(found.content, found.culture, {
|
|
1571
|
+
member,
|
|
1572
|
+
...(await pageRequest(request)),
|
|
1573
|
+
})
|
|
1574
|
+
if (result.status !== 'ok') {
|
|
1575
|
+
if (result.status === 'error' && !request.signal.aborted)
|
|
1576
|
+
renderLog.error('Template error while rendering the not-found page {name}: {detail}', {
|
|
1577
|
+
name: found.content.name,
|
|
1578
|
+
detail: renderer.inSource(result.error.stack ?? result.error.message),
|
|
1579
|
+
})
|
|
1580
|
+
return undefined
|
|
1581
|
+
}
|
|
1582
|
+
return new Response(result.html, {
|
|
1583
|
+
status: 404,
|
|
1584
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
1585
|
+
})
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
return {
|
|
1589
|
+
config,
|
|
1590
|
+
fetch,
|
|
1591
|
+
paths,
|
|
1592
|
+
db,
|
|
1593
|
+
auth,
|
|
1594
|
+
cache,
|
|
1595
|
+
renderer,
|
|
1596
|
+
snapshots,
|
|
1597
|
+
seededAdminPassword,
|
|
1598
|
+
schema,
|
|
1599
|
+
poll: () => poller.poll(),
|
|
1600
|
+
notImplemented,
|
|
1601
|
+
health,
|
|
1602
|
+
events,
|
|
1603
|
+
jobs,
|
|
1604
|
+
serveOptions: {
|
|
1605
|
+
port: config.port,
|
|
1606
|
+
maxRequestBodySize: config.maxRequestBodyBytes,
|
|
1607
|
+
fetch: diagnostics.wrap(async (request, bunServer: Server<HubSocketData>) => {
|
|
1608
|
+
const pathname = new URL(request.url).pathname
|
|
1609
|
+
if (pathname === hubPath || pathname === previewHubPath) {
|
|
1610
|
+
const principal = await authenticate(request)
|
|
1611
|
+
const hub = pathname === hubPath ? events : previewHub
|
|
1612
|
+
return hub.upgrade(request, bunServer, principal?.id)
|
|
1613
|
+
}
|
|
1614
|
+
return fetch(request)
|
|
1615
|
+
}),
|
|
1616
|
+
websocket: combineHubs([events, previewHub]),
|
|
1617
|
+
},
|
|
1618
|
+
async close() {
|
|
1619
|
+
diagnostics.stop()
|
|
1620
|
+
jobs.stop()
|
|
1621
|
+
events.stop()
|
|
1622
|
+
previewHub.stop()
|
|
1623
|
+
watcher?.close()
|
|
1624
|
+
poller.stop()
|
|
1625
|
+
await db.close()
|
|
1626
|
+
},
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function html(body: string): Response {
|
|
1631
|
+
return new Response(body, { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Carries the request's query string onto a redirect target, as Umbraco does, so
|
|
1636
|
+
* campaign and tracking parameters survive a rename. A target that names its own
|
|
1637
|
+
* query wins.
|
|
1638
|
+
*/
|
|
1639
|
+
function withQuery(location: string, requestUrl: string): string {
|
|
1640
|
+
const query = new URL(requestUrl).search
|
|
1641
|
+
return query && !location.includes('?') ? `${location}${query}` : location
|
|
1642
|
+
}
|