@crombolo/server 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- package/README.md +0 -3
package/src/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/14-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/17-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/17-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
|
+
}
|