@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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
package/src/email.ts ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Sending e-mail: a port, HTTP adapters, and an honest answer when there is none.
3
+ *
4
+ * Strictly opt-in. A site that configures no provider has no e-mail, which is
5
+ * the default — and the features that need it (user invitations, password
6
+ * resets, and the Forms `sendEmail` workflow) must become *unavailable* rather
7
+ * than fail when someone presses the button. `emailAvailability` is what they
8
+ * ask, and it carries the reason so the answer can be shown to a person instead
9
+ * of logged and lost.
10
+ *
11
+ * `send` never throws. A provider that is down, a key that is wrong and a
12
+ * network that is gone all come back as `{ ok: false, error }`, because the
13
+ * callers are a job runner and an invitation flow: both need to record a failure
14
+ * and carry on, and neither can usefully catch.
15
+ *
16
+ * No SMTP. Outbound 25/587 is blocked on most container hosts, so a hand-rolled
17
+ * SMTP client would be protocol risk in exchange for a path that often cannot be
18
+ * used. These adapters are plain `fetch` against a provider's HTTPS API, so this
19
+ * file adds no dependency.
20
+ */
21
+ import { isValidEmail } from '@crombolo/core'
22
+ import { customEmail } from './email-http.ts'
23
+ import { postmarkEmail } from './email-postmark.ts'
24
+ import { resendEmail } from './email-resend.ts'
25
+ import { sesEmail } from './email-ses.ts'
26
+ import { logger } from './logging.ts'
27
+
28
+ export interface EmailAddress {
29
+ email: string
30
+ name?: string
31
+ }
32
+
33
+ export interface EmailAttachment {
34
+ filename: string
35
+ contentType: string
36
+ bytes: Uint8Array
37
+ }
38
+
39
+ export interface EmailMessage {
40
+ to: EmailAddress[]
41
+ cc?: EmailAddress[]
42
+ bcc?: EmailAddress[]
43
+ replyTo?: EmailAddress
44
+ /** Overrides the adapter's configured sender, which is the usual case. */
45
+ from?: EmailAddress
46
+ subject: string
47
+ text?: string
48
+ html?: string
49
+ attachments?: EmailAttachment[]
50
+ }
51
+
52
+ export type EmailResult = { ok: true; id: string | null } | { ok: false; error: string }
53
+
54
+ /** Which adapter is in play; named in logs and in the availability report. */
55
+ export type EmailProvider = 'resend' | 'postmark' | 'ses' | 'custom' | 'log'
56
+
57
+ export interface EmailPort {
58
+ /** Named in the boot banner and in logs: `resend`, `postmark (broadcast)`, … */
59
+ readonly description: string
60
+ readonly provider: EmailProvider
61
+ send(message: EmailMessage): Promise<EmailResult>
62
+ }
63
+
64
+ /** What an adapter needs from every site that configures it. */
65
+ export interface EmailAdapterOptions {
66
+ /** The sender a message uses when it names none. Providers verify this domain. */
67
+ from: EmailAddress
68
+ /** Overridden by tests; nothing here keeps a client of its own. */
69
+ fetch?: typeof fetch
70
+
71
+ apiKey?: string
72
+ }
73
+
74
+ // ------------------------------------------------------------------ shared helpers
75
+
76
+ /** `Name <addr@example.com>`, or the bare address when there is no name. */
77
+ export function formatAddress(address: EmailAddress): string {
78
+ // A name with a comma or a quote would break the header it lands in, so it is
79
+ // quoted and escaped rather than rejected: a person's name is not input to
80
+ // validate, it is data to carry.
81
+ if (!address.name) return address.email
82
+ return `"${address.name.replace(/[\\"]/g, '\\$&')}" <${address.email}>`
83
+ }
84
+
85
+ /**
86
+ * Hosts where plain HTTP cannot leave the machine.
87
+ *
88
+ * A mail relay in a sidecar, reached on `http://localhost:8025`, is a real
89
+ * deployment and nothing about it is on a wire. Anything else is not: a
90
+ * service name inside a cluster still crosses the pod boundary, so it is held
91
+ * to HTTPS like everything else.
92
+ */
93
+ function isLoopback(hostname: string): boolean {
94
+ const host = hostname.replace(/^\[|\]$/g, '')
95
+ return host === 'localhost' || host === '::1' || /^127\./.test(host)
96
+ }
97
+
98
+ /**
99
+ * Refuses an endpoint that would carry credentials in cleartext.
100
+ *
101
+ * Every adapter authenticates with something worth stealing — a bearer token,
102
+ * a server token, a SigV4 `Authorization` header — so an `http://` endpoint is
103
+ * a configuration error, not a preference. Checked at construction: a site that
104
+ * gets this wrong fails at boot rather than leaking on its first send.
105
+ */
106
+ export function assertSecureEndpoint(endpoint: string, adapter: string): void {
107
+ let url: URL
108
+ try {
109
+ url = new URL(endpoint)
110
+ } catch {
111
+ throw new Error(`The ${adapter} e-mail adapter needs a valid URL; '${endpoint}' is not one.`)
112
+ }
113
+ if (url.protocol === 'https:') return
114
+ if (url.protocol === 'http:' && isLoopback(url.hostname)) return
115
+ throw new Error(
116
+ `The ${adapter} e-mail adapter refuses '${endpoint}': credentials would travel in cleartext. Use https, or http on localhost for a relay on this machine.`,
117
+ )
118
+ }
119
+
120
+ export function base64(bytes: Uint8Array): string {
121
+ return Buffer.from(bytes).toString('base64')
122
+ }
123
+
124
+ /**
125
+ * The checks every adapter would otherwise repeat. A message with no recipient
126
+ * or no body is a programming error that must not reach a provider, where it
127
+ * would be charged for and rejected.
128
+ */
129
+ export function validateMessage(message: EmailMessage, from: EmailAddress): string | undefined {
130
+ const recipients = [...message.to, ...(message.cc ?? []), ...(message.bcc ?? [])]
131
+ if (recipients.length === 0) return 'the message has no recipient'
132
+ const bad = recipients.find((address) => !isValidEmail(address.email))
133
+ if (bad) return `'${bad.email}' is not a valid e-mail address`
134
+ if (!isValidEmail(from.email)) return `the sender '${from.email}' is not a valid e-mail address`
135
+ if (!message.subject.trim()) return 'the message has no subject'
136
+ if (!message.text && !message.html) return 'the message has no body'
137
+ return undefined
138
+ }
139
+
140
+ /**
141
+ * A provider call, with every failure mode flattened into `EmailResult`.
142
+ *
143
+ * `fetch` throws on DNS and TLS failures, `response.json()` throws on a provider
144
+ * returning an error page, and a 4xx is neither. All three are the same thing to
145
+ * a caller: the mail did not go.
146
+ */
147
+ export async function postToProvider(
148
+ call: typeof fetch,
149
+ url: string,
150
+ init: RequestInit,
151
+ readId: (body: unknown) => string | null,
152
+ ): Promise<EmailResult> {
153
+ let response: Response
154
+ try {
155
+ response = await call(url, init)
156
+ } catch (error) {
157
+ return { ok: false, error: error instanceof Error ? error.message : String(error) }
158
+ }
159
+ const body = await response.text()
160
+ if (!response.ok) {
161
+ // The provider's own message is the useful part; it names the wrong key or
162
+ // the unverified domain. Truncated, because some return an HTML page.
163
+ const detail = body.trim().slice(0, 500)
164
+ return {
165
+ ok: false,
166
+ error: `the provider answered ${response.status}${detail ? `: ${detail}` : ''}`,
167
+ }
168
+ }
169
+ try {
170
+ return { ok: true, id: readId(body ? JSON.parse(body) : {}) }
171
+ } catch {
172
+ // Accepted but unparseable: the mail went, and the id is the only loss.
173
+ return { ok: true, id: null }
174
+ }
175
+ }
176
+
177
+ // ------------------------------------------------------------------ the log adapter
178
+
179
+ /**
180
+ * Development's stand-in: the message on the console, in full.
181
+ *
182
+ * This is a real port, so invitations and resets work locally with nothing
183
+ * configured — but `emailAvailability` reports it as `log`, so a site cannot
184
+ * mistake it for delivery.
185
+ */
186
+ export function logEmail(): EmailPort {
187
+ const log = logger('email')
188
+ return {
189
+ description: 'log (nothing is sent)',
190
+ provider: 'log',
191
+ async send(message) {
192
+ log.info('E-mail to {to}: {subject}', {
193
+ to: message.to.map((address) => address.email).join(', '),
194
+ subject: message.subject,
195
+ body: message.text ?? message.html ?? '',
196
+ })
197
+ return { ok: true, id: null }
198
+ },
199
+ }
200
+ }
201
+
202
+ // ------------------------------------------------------------------ availability
203
+
204
+ /** Why e-mail is unavailable, and what that costs — shown to a person, not logged. */
205
+ export interface EmailAvailability {
206
+ /** Whether a message would actually leave this site. */
207
+ available: boolean
208
+ provider: EmailProvider | null
209
+ description: string | null
210
+ /** Present when `available` is false: one sentence someone can act on. */
211
+ reason: string | null
212
+ /** What is turned off because of it, by name. */
213
+ affects: string[]
214
+ }
215
+
216
+ /** The features that need e-mail, named the way the backoffice names them. */
217
+ export const EMAIL_DEPENDENT_FEATURES = [
218
+ 'Inviting backoffice users',
219
+ 'Password reset by e-mail',
220
+ "Forms' Send email workflow",
221
+ ]
222
+
223
+ export const EMAIL_SETUP_HINT =
224
+ 'Set CROMBOLO_EMAIL_PROVIDER to resend, postmark or custom with its key and CROMBOLO_EMAIL_FROM, or pass `email` in crombolo.config.ts. See docs/configuration.md.'
225
+
226
+ export function emailAvailability(port: EmailPort | undefined): EmailAvailability {
227
+ if (!port)
228
+ return {
229
+ available: false,
230
+ provider: null,
231
+ description: null,
232
+ reason: `No e-mail provider is configured. ${EMAIL_SETUP_HINT}`,
233
+ affects: [...EMAIL_DEPENDENT_FEATURES],
234
+ }
235
+ if (port.provider === 'log')
236
+ return {
237
+ available: false,
238
+ provider: 'log',
239
+ description: port.description,
240
+ reason:
241
+ 'E-mail is written to the console in development, not sent. Configure a provider before relying on it.',
242
+ affects: [],
243
+ }
244
+ return {
245
+ available: true,
246
+ provider: port.provider,
247
+ description: port.description,
248
+ reason: null,
249
+ affects: [],
250
+ }
251
+ }
252
+
253
+ // ------------------------------------------------------------------ from the environment
254
+
255
+ export type EmailProviderKind = 'none' | 'resend' | 'postmark' | 'ses' | 'custom'
256
+
257
+ /**
258
+ * The provider the environment asks for. `CROMBOLO_EMAIL_PROVIDER` picks it and
259
+ * each kind reads its own variables:
260
+ *
261
+ * CROMBOLO_EMAIL_PROVIDER=resend
262
+ * CROMBOLO_EMAIL_API_KEY=re_…
263
+ * CROMBOLO_EMAIL_FROM=no-reply@example.com
264
+ * CROMBOLO_EMAIL_FROM_NAME=Example (optional)
265
+ *
266
+ * CROMBOLO_EMAIL_PROVIDER=postmark
267
+ * CROMBOLO_EMAIL_API_KEY=… (server token)
268
+ * CROMBOLO_EMAIL_POSTMARK_STREAM=outbound (optional)
269
+ *
270
+ * CROMBOLO_EMAIL_PROVIDER=ses
271
+ * CROMBOLO_EMAIL_SES_REGION=ap-southeast-2
272
+ * AWS_ACCESS_KEY_ID=… / AWS_SECRET_ACCESS_KEY=… (or the CROMBOLO_EMAIL_SES_* pair)
273
+ *
274
+ * CROMBOLO_EMAIL_PROVIDER=custom
275
+ * CROMBOLO_EMAIL_URL=https://… (your endpoint)
276
+ * CROMBOLO_EMAIL_API_KEY=… (sent as a bearer token)
277
+ *
278
+ * Unset, or `none`, means no e-mail — the default. An unrecognised value or a
279
+ * provider missing its key is *also* no e-mail, with a warning naming what is
280
+ * wrong: a typo must leave the site bootable, not half-configured.
281
+ */
282
+ /**
283
+ * An adapter built from the environment, where a configuration error has to be
284
+ * a warning rather than a crash: a site must still boot and report e-mail as
285
+ * unavailable, which is the whole contract of this being opt-in.
286
+ */
287
+ function built(
288
+ kind: string,
289
+ log: { warning(message: string, properties?: Record<string, unknown>): void },
290
+ make: () => EmailPort,
291
+ ): EmailPort | undefined {
292
+ try {
293
+ return make()
294
+ } catch (error) {
295
+ log.warning('CROMBOLO_EMAIL_PROVIDER is {kind} but {detail} E-mail is off.', {
296
+ kind,
297
+ detail: error instanceof Error ? error.message : String(error),
298
+ })
299
+ return undefined
300
+ }
301
+ }
302
+
303
+ export function emailFromEnvironment(
304
+ env: Record<string, string | undefined> = Bun.env,
305
+ log: { warning(message: string, properties?: Record<string, unknown>): void } = logger('email'),
306
+ ): EmailPort | undefined {
307
+ const kind = (env.CROMBOLO_EMAIL_PROVIDER ?? 'none').toLowerCase()
308
+ if (kind === 'none' || kind === '') return undefined
309
+
310
+ if (kind !== 'resend' && kind !== 'postmark' && kind !== 'ses' && kind !== 'custom') {
311
+ log.warning('CROMBOLO_EMAIL_PROVIDER is {kind}, which is not a provider. {known}', {
312
+ kind,
313
+ known: 'Use resend, postmark, ses, custom or none. E-mail is off.',
314
+ })
315
+ return undefined
316
+ }
317
+
318
+ const address = env.CROMBOLO_EMAIL_FROM ?? ''
319
+ if (!isValidEmail(address)) {
320
+ log.warning('CROMBOLO_EMAIL_PROVIDER is {kind} but {missing}. E-mail is off.', {
321
+ kind,
322
+ missing: address
323
+ ? `CROMBOLO_EMAIL_FROM ('${address}') is not a valid address`
324
+ : 'CROMBOLO_EMAIL_FROM is not set',
325
+ })
326
+ return undefined
327
+ }
328
+ const from: EmailAddress = { email: address, name: env.CROMBOLO_EMAIL_FROM_NAME || undefined }
329
+ const apiKey = env.CROMBOLO_EMAIL_API_KEY ?? ''
330
+
331
+ if (kind === 'ses') {
332
+ const region = env.CROMBOLO_EMAIL_SES_REGION ?? env.AWS_REGION ?? ''
333
+ if (!region) {
334
+ log.warning('CROMBOLO_EMAIL_PROVIDER is ses but no region is set. {off}', {
335
+ off: 'Set CROMBOLO_EMAIL_SES_REGION or AWS_REGION. E-mail is off.',
336
+ })
337
+ return undefined
338
+ }
339
+ return built(kind, log, () =>
340
+ sesEmail({
341
+ from,
342
+ region,
343
+ // Defaulted to empty rather than left undefined, so what the environment
344
+ // holds is the whole answer: the adapter's own fallback to `Bun.env`
345
+ // would otherwise make this depend on variables not passed in here.
346
+ accessKeyId: env.CROMBOLO_EMAIL_SES_ACCESS_KEY_ID ?? env.AWS_ACCESS_KEY_ID ?? '',
347
+ secretAccessKey:
348
+ env.CROMBOLO_EMAIL_SES_SECRET_ACCESS_KEY ?? env.AWS_SECRET_ACCESS_KEY ?? '',
349
+ sessionToken: env.AWS_SESSION_TOKEN,
350
+ endpoint: env.CROMBOLO_EMAIL_SES_ENDPOINT,
351
+ configurationSetName: env.CROMBOLO_EMAIL_SES_CONFIGURATION_SET,
352
+ }),
353
+ )
354
+ }
355
+
356
+ if (kind === 'custom') {
357
+ const url = env.CROMBOLO_EMAIL_URL ?? ''
358
+ if (!url) {
359
+ log.warning('CROMBOLO_EMAIL_PROVIDER is custom but CROMBOLO_EMAIL_URL is not set. {off}', {
360
+ off: 'E-mail is off.',
361
+ })
362
+ return undefined
363
+ }
364
+ return built(kind, log, () => customEmail({ from, url, apiKey }))
365
+ }
366
+
367
+ if (!apiKey) {
368
+ log.warning('CROMBOLO_EMAIL_PROVIDER is {kind} but CROMBOLO_EMAIL_API_KEY is not set. {off}', {
369
+ kind,
370
+ off: 'E-mail is off.',
371
+ })
372
+ return undefined
373
+ }
374
+
375
+ return built(kind, log, () =>
376
+ kind === 'resend'
377
+ ? resendEmail({ from, apiKey, endpoint: env.CROMBOLO_EMAIL_URL })
378
+ : postmarkEmail({
379
+ from,
380
+ apiKey,
381
+ endpoint: env.CROMBOLO_EMAIL_URL,
382
+ messageStream: env.CROMBOLO_EMAIL_POSTMARK_STREAM,
383
+ }),
384
+ )
385
+ }
386
+
387
+ /**
388
+ * The port a site ends up with, from the three states of `config.email`.
389
+ *
390
+ * Structural rather than taking `CromboloConfig`, so this file stays free of
391
+ * the config module and can be used from anywhere that knows those two fields.
392
+ */
393
+ export function resolveEmailPort(
394
+ config: { email?: EmailPort | null; development?: boolean },
395
+ env?: Record<string, string | undefined>,
396
+ ): EmailPort | undefined {
397
+ if (config.email === null) return undefined
398
+ if (config.email) return config.email
399
+ // The console stand-in is development only: a production node with nothing
400
+ // configured must report e-mail as unavailable, not quietly swallow messages.
401
+ return emailFromEnvironment(env) ?? (config.development ? logEmail() : undefined)
402
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The one trace App_Plugins leaves behind.
3
+ *
4
+ * Extensions are npm dependencies now (`docs/bundles.md`), and nothing
5
+ * reads this directory any more. A site upgrading with files still in it would
6
+ * otherwise find its extensions had quietly stopped loading, so the boot says
7
+ * what happened. Remove at 1.0.
8
+ */
9
+ import { existsSync, readdirSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ export interface NoticeLog {
13
+ warning(message: string, properties?: Record<string, unknown>): void
14
+ }
15
+
16
+ export function warnAboutAppPlugins(siteDir: string, log: NoticeLog): void {
17
+ const dir = join(siteDir, 'App_Plugins')
18
+ if (!existsSync(dir)) return
19
+ let entries: string[]
20
+ try {
21
+ entries = readdirSync(dir).filter((entry) => !entry.startsWith('.'))
22
+ } catch {
23
+ return
24
+ }
25
+ if (entries.length === 0) return
26
+ log.warning(
27
+ 'App_Plugins is no longer read: {count} item(s) in {dir} are being ignored. {remedy}',
28
+ {
29
+ count: entries.length,
30
+ dir,
31
+ remedy:
32
+ 'Backoffice extensions are npm dependencies now — install one with `bun add`, and see docs/bundles.md.',
33
+ },
34
+ )
35
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The save-time step for uploaded files: a value naming a temporary upload has
3
+ * the file placed under the media root and stores its path instead. When the
4
+ * file is `umbracoFile`, the width, height, size and extension properties are
5
+ * filled in, as Umbraco's `AutoFillImageProperties` does for media types.
6
+ */
7
+ import {
8
+ type DocumentValue,
9
+ FILE_VALUE_EDITORS,
10
+ IMAGE_CROPPER_EDITOR_ALIAS,
11
+ toStoredUpload,
12
+ } from '@crombolo/core'
13
+ import type { ValueIntake } from '@crombolo/data'
14
+ import type { MediaFileStore, PlacedFile } from './media-files.ts'
15
+
16
+ const AUTO_FILL = {
17
+ file: 'umbracoFile',
18
+ width: 'umbracoWidth',
19
+ height: 'umbracoHeight',
20
+ bytes: 'umbracoBytes',
21
+ extension: 'umbracoExtension',
22
+ } as const
23
+
24
+ export class TemporaryFileMissingError extends Error {}
25
+
26
+ const temporaryIdOf = (value: unknown): string | undefined => {
27
+ const id = (value as { temporaryFileId?: unknown } | null)?.temporaryFileId
28
+ return typeof id === 'string' && id ? id : undefined
29
+ }
30
+
31
+ export function createFileIntake(store: MediaFileStore): ValueIntake {
32
+ return async (values, propertyTypes) => {
33
+ const editorOf = new Map(propertyTypes.map((p) => [p.alias, p.editorAlias]))
34
+ const result: DocumentValue[] = [...values]
35
+ const fills: DocumentValue[] = []
36
+
37
+ for (const [index, value] of result.entries()) {
38
+ const editor = editorOf.get(value.alias)
39
+ if (!editor || !FILE_VALUE_EDITORS.has(editor)) continue
40
+ const temporaryId = temporaryIdOf(value.value)
41
+ let placed: PlacedFile | undefined
42
+ if (temporaryId) {
43
+ placed = await store.place(temporaryId)
44
+ if (!placed)
45
+ throw new TemporaryFileMissingError(
46
+ `The upload for '${value.alias}' has expired or does not exist; upload it again.`,
47
+ )
48
+ }
49
+ let stored: unknown
50
+ if (editor === IMAGE_CROPPER_EDITOR_ALIAS) {
51
+ const incoming = (value.value ?? {}) as Record<string, unknown>
52
+ const src = placed?.src ?? (typeof incoming.src === 'string' ? incoming.src : '')
53
+ stored = src
54
+ ? {
55
+ src,
56
+ crops: Array.isArray(incoming.crops) ? incoming.crops : [],
57
+ focalPoint: incoming.focalPoint ?? null,
58
+ }
59
+ : null
60
+ } else {
61
+ stored = placed?.src ?? toStoredUpload(value.value)
62
+ }
63
+ result[index] = { ...value, value: stored }
64
+
65
+ if (placed && value.alias === AUTO_FILL.file) {
66
+ const fill = (alias: string, fact: unknown) => {
67
+ if (editorOf.has(alias) && fact !== undefined)
68
+ fills.push({ alias, culture: value.culture, segment: value.segment, value: fact })
69
+ }
70
+ fill(AUTO_FILL.width, placed.width)
71
+ fill(AUTO_FILL.height, placed.height)
72
+ fill(AUTO_FILL.bytes, placed.bytes)
73
+ fill(AUTO_FILL.extension, placed.extension)
74
+ }
75
+ }
76
+
77
+ // A filled-in fact replaces whatever the editor sent for it.
78
+ const filled = new Set(fills.map((f) => `${f.alias}|${f.culture ?? ''}|${f.segment ?? ''}`))
79
+ return [
80
+ ...result.filter((v) => !filled.has(`${v.alias}|${v.culture ?? ''}|${v.segment ?? ''}`)),
81
+ ...fills,
82
+ ]
83
+ }
84
+ }