@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
|
@@ -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('<', '<')
|
|
215
|
+
.replaceAll('>', '>')
|
|
216
|
+
.replaceAll('"', '"')
|
|
217
|
+
.replaceAll(''', "'")
|
|
218
|
+
.replaceAll('&', '&')
|
|
@@ -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
|
+
}
|