@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
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Uploaded files, as Umbraco keeps them: a temporary upload first
3
+ * (`TemporaryFile`, kept for a day), then, when a value that names it is saved, a
4
+ * placed file served at `/media/<folder>/<name>`.
5
+ *
6
+ * Where the bytes actually live is the `MediaStore`'s business (see
7
+ * media-store.ts) — a disk, an S3 bucket, an Azure container. This is the layer
8
+ * that knows the *naming*: which key a `/media/…` URL means, and that a temporary
9
+ * upload is `.temp/<id>/…` with its metadata beside it.
10
+ *
11
+ * Temporary uploads go to the store rather than to local disk, so a load-balanced
12
+ * set can place an upload that a different node received.
13
+ */
14
+ import { basename, extname } from 'node:path'
15
+ import {
16
+ contentTypeFor,
17
+ fileSystemMediaStore,
18
+ type MediaStore,
19
+ type StoredFile,
20
+ } from './media-store.ts'
21
+
22
+ export interface TemporaryFile {
23
+ id: string
24
+ fileName: string
25
+ availableUntil: Date
26
+ size: number
27
+ }
28
+
29
+ export interface PlacedFile {
30
+ /** The URL path, e.g. `/media/ab12cd34/photo.jpg`. */
31
+ src: string
32
+ bytes: number
33
+ /** Lowercase, without the dot. */
34
+ extension: string
35
+ width?: number
36
+ height?: number
37
+ }
38
+
39
+ const TEMP = '.temp'
40
+ const DAY = 24 * 60 * 60 * 1000
41
+
42
+ /** `My Photo (1).JPG` → `my-photo-1.jpg`: safe on every file system and in a URL. */
43
+ export function safeFileName(name: string): string {
44
+ const base = basename(name.replaceAll('\\', '/'))
45
+ const extension = extname(base).toLowerCase()
46
+ const stem = base
47
+ .slice(0, base.length - extname(base).length)
48
+ .toLowerCase()
49
+ .normalize('NFKD')
50
+ .replace(/[̀-ͯ]/g, '')
51
+ .replace(/[^a-z0-9._-]+/g, '-')
52
+ .replace(/-+/g, '-')
53
+ .replace(/^[-.]+|[-.]+$/g, '')
54
+ return `${stem || 'file'}${extension.replace(/[^a-z0-9.]/g, '')}`
55
+ }
56
+
57
+ const isUuid = (id: string) =>
58
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
59
+
60
+ export interface MediaFileStoreOptions {
61
+ lifetimeMs?: number
62
+ }
63
+
64
+ export class MediaFileStore {
65
+ readonly store: MediaStore
66
+ #lifetimeMs: number
67
+
68
+ /** Takes a store, or a directory for the file system store a plain path implies. */
69
+ constructor(store: MediaStore | string, options: MediaFileStoreOptions = {}) {
70
+ this.store = typeof store === 'string' ? fileSystemMediaStore(store) : store
71
+ this.#lifetimeMs = options.lifetimeMs ?? DAY
72
+ }
73
+
74
+ #tempKey(id: string, name: string) {
75
+ return `${TEMP}/${id.toLowerCase()}/${name}`
76
+ }
77
+
78
+ async saveTemporary(id: string, file: File, now = new Date()): Promise<TemporaryFile> {
79
+ if (!isUuid(id)) throw new Error('A temporary file id must be a uuid.')
80
+ // A second upload under the same id replaces the first, including its name.
81
+ await this.deleteTemporary(id)
82
+ const fileName = safeFileName(file.name)
83
+ // From the name, never from `file.type`: that is the multipart part's own
84
+ // `Content-Type` header, so the uploader picks it. A bucket-backed store keeps
85
+ // what it is given and hands it back on read, which would let `photo.jpg` be
86
+ // served as `text/html` from this origin — past the extension checks, because
87
+ // the name really is a .jpg. The file system store re-derives on read and was
88
+ // never exposed; deriving here makes all three agree.
89
+ await this.store.put(this.#tempKey(id, fileName), file, {
90
+ contentType: contentTypeFor(fileName),
91
+ })
92
+ const record = {
93
+ id: id.toLowerCase(),
94
+ fileName,
95
+ availableUntil: new Date(now.getTime() + this.#lifetimeMs),
96
+ size: file.size,
97
+ }
98
+ await this.store.put(
99
+ this.#tempKey(id, 'meta.json'),
100
+ new TextEncoder().encode(JSON.stringify(record)),
101
+ { contentType: 'application/json' },
102
+ )
103
+ return record
104
+ }
105
+
106
+ async temporary(id: string, now = new Date()): Promise<TemporaryFile | undefined> {
107
+ if (!isUuid(id)) return undefined
108
+ const meta = await this.store.get(this.#tempKey(id, 'meta.json'))
109
+ if (!meta) return undefined
110
+ let raw: TemporaryFile & { availableUntil: string }
111
+ try {
112
+ raw = JSON.parse(new TextDecoder().decode(await meta.bytes()))
113
+ } catch {
114
+ return undefined
115
+ }
116
+ const record = { ...raw, availableUntil: new Date(raw.availableUntil) }
117
+ if (record.availableUntil.getTime() < now.getTime()) {
118
+ await this.deleteTemporary(id)
119
+ return undefined
120
+ }
121
+ return record
122
+ }
123
+
124
+ /** A temporary upload's name and contents, for uploads that are read rather than placed. */
125
+ async readTemporary(id: string): Promise<{ fileName: string; bytes: Uint8Array } | undefined> {
126
+ const record = await this.temporary(id)
127
+ if (!record) return undefined
128
+ const stored = await this.store.get(this.#tempKey(id, record.fileName))
129
+ if (!stored) return undefined
130
+ return { fileName: record.fileName, bytes: await stored.bytes() }
131
+ }
132
+
133
+ async deleteTemporary(id: string): Promise<boolean> {
134
+ if (!isUuid(id)) return false
135
+ const keys = await this.store.list(`${TEMP}/${id.toLowerCase()}/`)
136
+ if (keys.length === 0) return false
137
+ for (const key of keys) await this.store.delete(key)
138
+ return true
139
+ }
140
+
141
+ /** Moves a temporary upload into the media root; undefined when it is gone or expired. */
142
+ async place(id: string): Promise<PlacedFile | undefined> {
143
+ const temporary = await this.temporary(id)
144
+ if (!temporary) return undefined
145
+ const source = await this.store.get(this.#tempKey(id, temporary.fileName))
146
+ if (!source) return undefined
147
+ const bytes = await source.bytes()
148
+ let folder: string
149
+ do folder = crypto.randomUUID().replaceAll('-', '').slice(0, 8)
150
+ while ((await this.store.list(`${folder}/`)).length > 0)
151
+ const key = `${folder}/${temporary.fileName}`
152
+ await this.store.put(key, bytes, { contentType: source.contentType })
153
+ await this.deleteTemporary(id)
154
+ const placed: PlacedFile = {
155
+ src: `/media/${key}`,
156
+ bytes: bytes.byteLength,
157
+ extension: extname(temporary.fileName).slice(1).toLowerCase(),
158
+ }
159
+ try {
160
+ const metadata = await new Bun.Image(bytes).metadata()
161
+ placed.width = metadata.width
162
+ placed.height = metadata.height
163
+ } catch {
164
+ // Not an image Bun can decode (a PDF, an SVG): no dimensions.
165
+ }
166
+ return placed
167
+ }
168
+
169
+ /** The store key a `/media/…` URL path names; undefined when it names nothing. */
170
+ key(urlPath: string): string | undefined {
171
+ if (!urlPath.startsWith('/media/')) return undefined
172
+ let key: string
173
+ try {
174
+ key = decodeURIComponent(urlPath.slice('/media/'.length))
175
+ } catch {
176
+ return undefined
177
+ }
178
+ // A placed file is always `<folder>/<name>`; the reserved folders are not
179
+ // reachable through a URL, whatever the store would allow.
180
+ const parts = key.split('/')
181
+ if (parts.length < 2 || parts.some((part) => part === '' || part.startsWith('.')))
182
+ return undefined
183
+ return key
184
+ }
185
+
186
+ /** The file a `/media/…` URL path names, or undefined. */
187
+ async open(urlPath: string): Promise<StoredFile | undefined> {
188
+ const key = this.key(urlPath)
189
+ return key ? this.store.get(key) : undefined
190
+ }
191
+
192
+ /** Where the browser should go for a `/media/…` path, when the store serves it itself. */
193
+ publicUrl(urlPath: string): string | undefined {
194
+ const key = this.key(urlPath)
195
+ return key ? this.store.publicUrl?.(key) : undefined
196
+ }
197
+
198
+ /** Deletes a placed file. */
199
+ async remove(src: string): Promise<void> {
200
+ const key = this.key(src)
201
+ if (key) await this.store.delete(key)
202
+ }
203
+
204
+ /** Drops temporary uploads past their lifetime; returns how many went. */
205
+ async cleanupExpired(now = new Date()): Promise<number> {
206
+ const ids = new Set<string>()
207
+ for (const key of await this.store.list(`${TEMP}/`)) {
208
+ const id = key.split('/')[1]
209
+ if (id) ids.add(id)
210
+ }
211
+ let removed = 0
212
+ for (const id of ids) {
213
+ if (await this.temporary(id, now)) continue
214
+ // Reading an expired upload already drops it; this clears whatever is left,
215
+ // such as a folder whose metadata went missing.
216
+ await this.deleteTemporary(id)
217
+ removed += 1
218
+ }
219
+ return removed
220
+ }
221
+ }
222
+
223
+ /**
224
+ * The media store a command should use: whatever the site configured, else the
225
+ * environment's, else the directory on this disk. The same resolution the
226
+ * server does at boot, for a process that is not the server.
227
+ */
228
+ export async function mediaStoreFor(config: {
229
+ mediaStore?: MediaStore | undefined
230
+ mediaDir: string
231
+ }): Promise<MediaStore> {
232
+ if (config.mediaStore) return config.mediaStore
233
+ const { mediaStoreFromEnvironment } = await import('./media-store.ts')
234
+ return mediaStoreFromEnvironment(config.mediaDir)
235
+ }
236
+
237
+ /**
238
+ * Reads the media files a bundle wants to carry.
239
+ *
240
+ * A key the store has not got is reported rather than guessed at: the writer
241
+ * then marks that blob as not carried, so the manifest says what is true and
242
+ * the destination reports it missing instead of silently serving nothing.
243
+ */
244
+ export async function readBlobs(
245
+ store: MediaStore,
246
+ keys: readonly string[],
247
+ ): Promise<{ bytes: Map<string, Uint8Array>; missing: string[] }> {
248
+ const bytes = new Map<string, Uint8Array>()
249
+ const missing: string[] = []
250
+ for (const key of keys) {
251
+ const file = await store.get(key)
252
+ if (!file) {
253
+ missing.push(key)
254
+ continue
255
+ }
256
+ bytes.set(key, await file.bytes())
257
+ }
258
+ return { bytes, missing }
259
+ }
260
+
261
+ /** Puts a bundle's media files into this environment's store, under their own keys. */
262
+ export async function placeBlobs(
263
+ store: MediaStore,
264
+ blobs: ReadonlyMap<string, string>,
265
+ ): Promise<{ placed: number; bytes: number }> {
266
+ let placed = 0
267
+ let bytes = 0
268
+ for (const [key, path] of blobs) {
269
+ const file = Bun.file(path)
270
+ const content = new Uint8Array(await file.arrayBuffer())
271
+ await store.put(key, content, { contentType: contentTypeFor(key) })
272
+ placed += 1
273
+ bytes += content.length
274
+ }
275
+ return { placed, bytes }
276
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Media in an Azure Blob Storage container.
3
+ *
4
+ * Azure has no client built into Bun, and `@azure/storage-blob` is a large
5
+ * dependency for four verbs, so this speaks the REST API directly. Two ways in:
6
+ *
7
+ * - **A SAS token.** Appended to every request; nothing to sign. Simplest, and
8
+ * what a deployment that already issues SAS tokens should use.
9
+ * - **An account key.** Signed per request with Shared Key authorisation, which
10
+ * is an HMAC-SHA256 over a canonical form of the request that Azure specifies
11
+ * exactly — hence the deliberate ordering below, where a stray newline or a
12
+ * header out of order fails with 403 and no explanation.
13
+ *
14
+ * Serving: by default the server streams the bytes, keeping a private container
15
+ * private. `publicUrlPrefix` redirects browsers to a CDN; a container with public
16
+ * read access can point it at the container URL itself.
17
+ */
18
+ import { contentTypeFor, isSafeMediaKey, type MediaStore, type StoredFile } from './media-store.ts'
19
+
20
+ export interface AzureMediaStoreOptions {
21
+ account: string
22
+ container: string
23
+ /** The account key, for Shared Key authorisation. */
24
+ accountKey?: string
25
+ /** A SAS token instead of a key; a leading `?` is optional. */
26
+ sasToken?: string
27
+ /** Override for Azurite or a sovereign cloud; default is the public endpoint. */
28
+ endpoint?: string
29
+ /** A folder inside the container. */
30
+ prefix?: string
31
+ /** A CDN or public container URL; browsers are redirected there. */
32
+ publicUrlPrefix?: string
33
+ }
34
+
35
+ const VERSION = '2021-08-06'
36
+
37
+ /**
38
+ * Azure's `StringToSign` for Shared Key: the verb, thirteen fixed headers, then
39
+ * the canonicalised `x-ms-*` headers and the canonicalised resource. Every field
40
+ * is present even when empty, which is why this is a list and not a template.
41
+ */
42
+ async function sharedKeyAuthorization(
43
+ account: string,
44
+ accountKey: string,
45
+ method: string,
46
+ url: URL,
47
+ headers: Headers,
48
+ contentLength: number,
49
+ ): Promise<string> {
50
+ const canonicalHeaders = [...headers.keys()]
51
+ .filter((name) => name.startsWith('x-ms-'))
52
+ .sort()
53
+ .map((name) => `${name}:${(headers.get(name) ?? '').trim()}`)
54
+ .join('\n')
55
+ const canonicalResource = [
56
+ `/${account}${url.pathname}`,
57
+ ...[...url.searchParams.keys()]
58
+ .sort()
59
+ .map((name) => `${name.toLowerCase()}:${url.searchParams.getAll(name).sort().join(',')}`),
60
+ ].join('\n')
61
+ const stringToSign = [
62
+ method,
63
+ headers.get('content-encoding') ?? '',
64
+ headers.get('content-language') ?? '',
65
+ // Azure wants an empty string for a zero-length body, not "0".
66
+ contentLength === 0 ? '' : String(contentLength),
67
+ headers.get('content-md5') ?? '',
68
+ headers.get('content-type') ?? '',
69
+ '', // Date, superseded by x-ms-date
70
+ '', // If-Modified-Since
71
+ '', // If-Match
72
+ '', // If-None-Match
73
+ '', // If-Unmodified-Since
74
+ '', // Range
75
+ canonicalHeaders,
76
+ canonicalResource,
77
+ ].join('\n')
78
+
79
+ const key = await crypto.subtle.importKey(
80
+ 'raw',
81
+ Uint8Array.from(atob(accountKey), (c) => c.charCodeAt(0)),
82
+ { name: 'HMAC', hash: 'SHA-256' },
83
+ false,
84
+ ['sign'],
85
+ )
86
+ const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(stringToSign))
87
+ return `SharedKey ${account}:${btoa(String.fromCharCode(...new Uint8Array(signature)))}`
88
+ }
89
+
90
+ export function azureMediaStore(options: AzureMediaStoreOptions): MediaStore {
91
+ if (!options.account) throw new Error('An Azure media store needs an account.')
92
+ if (!options.container) throw new Error('An Azure media store needs a container.')
93
+ if (!options.accountKey && !options.sasToken)
94
+ throw new Error('An Azure media store needs either an account key or a SAS token.')
95
+ const origin = (options.endpoint ?? `https://${options.account}.blob.core.windows.net`).replace(
96
+ /\/$/,
97
+ '',
98
+ )
99
+ const prefix = options.prefix ? `${options.prefix.replace(/^\/+|\/+$/g, '')}/` : ''
100
+ const sas = options.sasToken?.replace(/^\?/, '')
101
+ const publicPrefix = options.publicUrlPrefix?.replace(/\/$/, '')
102
+
103
+ const blobUrl = (key: string, query: Record<string, string> = {}) => {
104
+ const url = new URL(
105
+ `${origin}/${options.container}/${encodeURI(`${prefix}${key}`).replaceAll('#', '%23')}`,
106
+ )
107
+ for (const [name, value] of Object.entries(query)) url.searchParams.set(name, value)
108
+ return url
109
+ }
110
+
111
+ /** Adds the SAS token last, so it is never part of what Shared Key signs. */
112
+ const send = async (
113
+ method: string,
114
+ url: URL,
115
+ init: { body?: Uint8Array<ArrayBuffer>; headers?: Record<string, string> } = {},
116
+ ): Promise<Response> => {
117
+ const headers = new Headers(init.headers)
118
+ headers.set('x-ms-version', VERSION)
119
+ headers.set('x-ms-date', new Date().toUTCString())
120
+ const length = init.body?.byteLength ?? 0
121
+ if (options.accountKey) {
122
+ headers.set(
123
+ 'authorization',
124
+ await sharedKeyAuthorization(
125
+ options.account,
126
+ options.accountKey,
127
+ method,
128
+ url,
129
+ headers,
130
+ length,
131
+ ),
132
+ )
133
+ }
134
+ const signed = new URL(url)
135
+ if (sas)
136
+ for (const [name, value] of new URLSearchParams(sas)) signed.searchParams.set(name, value)
137
+ return fetch(signed, { method, headers, body: init.body })
138
+ }
139
+
140
+ return {
141
+ description: `azure://${options.account}/${options.container}${prefix ? `/${prefix.slice(0, -1)}` : ''}`,
142
+
143
+ async get(key): Promise<StoredFile | undefined> {
144
+ if (!isSafeMediaKey(key)) return undefined
145
+ const url = blobUrl(key)
146
+ const head = await send('HEAD', url)
147
+ if (head.status === 404) return undefined
148
+ if (!head.ok) throw new Error(`Azure HEAD ${key} failed with ${head.status}.`)
149
+ return {
150
+ key,
151
+ size: Number(head.headers.get('content-length') ?? 0),
152
+ etag: (head.headers.get('etag') ?? '').replaceAll('"', ''),
153
+ contentType: head.headers.get('content-type') || contentTypeFor(key),
154
+ bytes: async () => {
155
+ const response = await send('GET', url)
156
+ if (!response.ok) throw new Error(`Azure GET ${key} failed with ${response.status}.`)
157
+ return new Uint8Array(await response.arrayBuffer())
158
+ },
159
+ }
160
+ },
161
+
162
+ async put(key, body, putOptions) {
163
+ if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
164
+ // Copied into a plain ArrayBuffer so `fetch` and the signature agree on its length.
165
+ const bytes = new Uint8Array(body instanceof File ? await body.arrayBuffer() : body)
166
+ const response = await send('PUT', blobUrl(key), {
167
+ body: bytes,
168
+ headers: {
169
+ 'x-ms-blob-type': 'BlockBlob',
170
+ 'content-type': putOptions?.contentType ?? contentTypeFor(key),
171
+ },
172
+ })
173
+ if (!response.ok)
174
+ throw new Error(`Azure PUT ${key} failed with ${response.status}: ${await response.text()}`)
175
+ },
176
+
177
+ async delete(key) {
178
+ if (!isSafeMediaKey(key)) return
179
+ const response = await send('DELETE', blobUrl(key))
180
+ if (!response.ok && response.status !== 404)
181
+ throw new Error(`Azure DELETE ${key} failed with ${response.status}.`)
182
+ },
183
+
184
+ async list(keyPrefix) {
185
+ const found: string[] = []
186
+ let marker: string | undefined
187
+ do {
188
+ const url = blobUrl('', {
189
+ restype: 'container',
190
+ comp: 'list',
191
+ prefix: `${prefix}${keyPrefix}`,
192
+ })
193
+ // blobUrl appends the key to the container path; listing is the container itself.
194
+ url.pathname = `/${options.container}`
195
+ if (marker) url.searchParams.set('marker', marker)
196
+ const response = await send('GET', url)
197
+ if (!response.ok) throw new Error(`Azure list failed with ${response.status}.`)
198
+ const xml = await response.text()
199
+ for (const match of xml.matchAll(/<Name>([^<]*)<\/Name>/g)) {
200
+ const name = decodeXmlText(match[1] ?? '')
201
+ if (name.startsWith(prefix)) found.push(name.slice(prefix.length))
202
+ }
203
+ marker = /<NextMarker>([^<]*)<\/NextMarker>/.exec(xml)?.[1] || undefined
204
+ } while (marker)
205
+ return found
206
+ },
207
+
208
+ publicUrl: publicPrefix ? (key) => `${publicPrefix}/${key}` : undefined,
209
+ }
210
+ }
211
+
212
+ const decodeXmlText = (value: string) =>
213
+ value
214
+ .replaceAll('&lt;', '<')
215
+ .replaceAll('&gt;', '>')
216
+ .replaceAll('&quot;', '"')
217
+ .replaceAll('&apos;', "'")
218
+ .replaceAll('&amp;', '&')
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Media in an S3 bucket, through Bun's own S3 client — so this needs no
3
+ * dependency, and works against anything that speaks S3: AWS, Cloudflare R2,
4
+ * DigitalOcean Spaces, MinIO, Backblaze B2.
5
+ *
6
+ * Credentials come from the options, else from the environment Bun's client
7
+ * already reads (`AWS_ACCESS_KEY_ID` and friends), so a machine with an instance
8
+ * role needs none configured.
9
+ *
10
+ * Serving: by default the server streams the bytes, which keeps the bucket
11
+ * private. `publicUrlPrefix` sends browsers to a CDN instead, and
12
+ * `presignSeconds` sends them to a signed bucket URL — pick one, not both.
13
+ */
14
+ import { contentTypeFor, isSafeMediaKey, type MediaStore, type StoredFile } from './media-store.ts'
15
+
16
+ export interface S3MediaStoreOptions {
17
+ bucket: string
18
+ region?: string
19
+ /** For S3-compatible services: `https://<account>.r2.cloudflarestorage.com`. */
20
+ endpoint?: string
21
+ accessKeyId?: string
22
+ secretAccessKey?: string
23
+ sessionToken?: string
24
+ /** A folder inside the bucket, when media shares it with something else. */
25
+ prefix?: string
26
+ /** A CDN in front of the bucket; browsers are redirected there. */
27
+ publicUrlPrefix?: string
28
+ /** Redirect browsers to a presigned URL valid for this long. */
29
+ presignSeconds?: number
30
+ /** The ACL to write objects with; omit for a private bucket. */
31
+ acl?: 'private' | 'public-read'
32
+ }
33
+
34
+ export function s3MediaStore(options: S3MediaStoreOptions): MediaStore {
35
+ if (!options.bucket) throw new Error('An S3 media store needs a bucket.')
36
+ const client = new Bun.S3Client({
37
+ bucket: options.bucket,
38
+ region: options.region,
39
+ endpoint: options.endpoint,
40
+ accessKeyId: options.accessKeyId,
41
+ secretAccessKey: options.secretAccessKey,
42
+ sessionToken: options.sessionToken,
43
+ })
44
+ const prefix = options.prefix ? `${options.prefix.replace(/^\/+|\/+$/g, '')}/` : ''
45
+ const publicPrefix = options.publicUrlPrefix?.replace(/\/$/, '')
46
+ const objectKey = (key: string) => `${prefix}${key}`
47
+
48
+ return {
49
+ description: `s3://${options.bucket}${prefix ? `/${prefix.slice(0, -1)}` : ''}`,
50
+
51
+ async get(key): Promise<StoredFile | undefined> {
52
+ if (!isSafeMediaKey(key)) return undefined
53
+ const file = client.file(objectKey(key))
54
+ try {
55
+ const stat = await file.stat()
56
+ return {
57
+ key,
58
+ size: stat.size,
59
+ // S3's ETag is the content hash for a single-part upload, and a
60
+ // composite for a multipart one; either way it changes with the bytes.
61
+ etag: stat.etag.replaceAll('"', ''),
62
+ contentType: stat.type || contentTypeFor(key),
63
+ bytes: async () => new Uint8Array(await file.arrayBuffer()),
64
+ }
65
+ } catch {
66
+ // A missing object is a thrown NoSuchKey, not a null stat.
67
+ return undefined
68
+ }
69
+ },
70
+
71
+ async put(key, body, putOptions) {
72
+ if (!isSafeMediaKey(key)) throw new Error(`"${key}" is not a usable media key.`)
73
+ await client.write(objectKey(key), body, {
74
+ type: putOptions?.contentType ?? contentTypeFor(key),
75
+ acl: options.acl,
76
+ })
77
+ },
78
+
79
+ async delete(key) {
80
+ if (!isSafeMediaKey(key)) return
81
+ // A delete of something absent succeeds in S3, which is what we want.
82
+ await client.delete(objectKey(key))
83
+ },
84
+
85
+ async list(keyPrefix) {
86
+ const found: string[] = []
87
+ let continuationToken: string | undefined
88
+ do {
89
+ const page = await client.list({
90
+ prefix: `${prefix}${keyPrefix}`,
91
+ continuationToken,
92
+ maxKeys: 1000,
93
+ })
94
+ for (const object of page.contents ?? [])
95
+ if (object.key.startsWith(prefix)) found.push(object.key.slice(prefix.length))
96
+ continuationToken = page.isTruncated ? page.nextContinuationToken : undefined
97
+ } while (continuationToken)
98
+ return found
99
+ },
100
+
101
+ publicUrl:
102
+ publicPrefix || options.presignSeconds
103
+ ? (key) => {
104
+ if (!isSafeMediaKey(key)) return undefined
105
+ if (publicPrefix) return `${publicPrefix}/${key}`
106
+ return client.presign(objectKey(key), {
107
+ method: 'GET',
108
+ expiresIn: options.presignSeconds,
109
+ })
110
+ }
111
+ : undefined,
112
+ }
113
+ }