lopata 0.14.2 → 0.15.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.
@@ -0,0 +1,699 @@
1
+ import { type FileR2Bucket, HTTP_METADATA_FIELDS, type R2HTTPMetadata, type R2ObjectBody } from '../bindings/r2'
2
+ import { decodeAwsChunked, isAwsChunked } from './chunked'
3
+ import { applyObjectHeaders, corsHeaders, evaluateConditional, extractPutOptions, parseConditional, parseRange } from './headers'
4
+ import {
5
+ completeMultipartUploadXml,
6
+ copyObjectResultXml,
7
+ copyPartResultXml,
8
+ type DeleteResultEntry,
9
+ deleteResultXml,
10
+ getBucketLocationXml,
11
+ getObjectAttributesXml,
12
+ initiateMultipartUploadXml,
13
+ listAllMyBucketsXml,
14
+ listBucketV1Xml,
15
+ listBucketV2Xml,
16
+ listMultipartUploadsXml,
17
+ listPartsXml,
18
+ type ListV1Params,
19
+ type ListV2Params,
20
+ type ObjectAttributes,
21
+ parseCompletePartsXml,
22
+ parseDeleteRequestXml,
23
+ parseTaggingXml,
24
+ taggingXml,
25
+ xmlError,
26
+ xmlResponse,
27
+ } from './xml'
28
+
29
+ export type ResolveBucket = (name: string) => FileR2Bucket | undefined
30
+ export type ListAllBuckets = () => Array<{ name: string; creationDate: Date }>
31
+
32
+ /**
33
+ * Per-bucket CORS configuration XML, set via PutBucketCors and returned by
34
+ * GetBucketCors. Purely observational — lopata's dev server sends a fixed
35
+ * permissive CORS header set regardless of what's stored here.
36
+ */
37
+ const bucketCorsConfig = new Map<string, string>()
38
+
39
+ /**
40
+ * S3-compatible proxy over R2 bindings.
41
+ *
42
+ * Covers the R2-supported S3 ops: Get/Put/Head/Delete object, List v1/v2,
43
+ * multipart upload, CopyObject, DeleteObjects, HeadBucket, GetBucketLocation.
44
+ * No SigV4 auth — intended for local dev use.
45
+ *
46
+ * `resolveBucket` is used to look up another R2 binding for cross-bucket CopyObject.
47
+ * When absent, only same-bucket copies are supported.
48
+ */
49
+ export async function handleS3Request(
50
+ req: Request,
51
+ bucket: string,
52
+ r2: FileR2Bucket | undefined,
53
+ resolveBucket?: ResolveBucket,
54
+ listAllBuckets?: ListAllBuckets,
55
+ ): Promise<Response> {
56
+ const url = new URL(req.url)
57
+ const origin = req.headers.get('origin')
58
+ const cors = corsHeaders(origin)
59
+
60
+ if (req.method === 'OPTIONS') {
61
+ return new Response(null, { headers: cors })
62
+ }
63
+
64
+ // ListBuckets: GET at the root with no bucket.
65
+ if (!bucket && req.method === 'GET' && listAllBuckets) {
66
+ return xmlResponse(listAllMyBucketsXml(listAllBuckets()), 200, cors)
67
+ }
68
+ if (!r2) {
69
+ return xmlError('NoSuchBucket', 'The specified bucket does not exist.', bucket, cors)
70
+ }
71
+
72
+ const sp = url.searchParams
73
+ const rawKey = url.pathname.replace(/^\/+/, '')
74
+ let key: string
75
+ try {
76
+ key = decodeURIComponent(rawKey)
77
+ } catch {
78
+ return xmlError('InvalidRequest', 'Invalid URL-encoded key', url.pathname, cors)
79
+ }
80
+
81
+ // Bucket-level ops — no key
82
+ if (!key) {
83
+ return handleBucketLevel(req, bucket, r2, url, sp, cors)
84
+ }
85
+
86
+ // Multipart object-level ops — detected by query params
87
+ if (sp.has('uploads') && req.method === 'POST') {
88
+ return handleCreateMultipartUpload(req, bucket, key, r2, cors)
89
+ }
90
+ const uploadId = sp.get('uploadId')
91
+ if (uploadId) {
92
+ if (req.method === 'PUT' && sp.has('partNumber')) {
93
+ const copySource = req.headers.get('x-amz-copy-source')
94
+ if (copySource) {
95
+ return handleUploadPartCopy(
96
+ req,
97
+ bucket,
98
+ key,
99
+ r2,
100
+ resolveBucket,
101
+ uploadId,
102
+ Number(sp.get('partNumber')),
103
+ copySource,
104
+ cors,
105
+ )
106
+ }
107
+ return handleUploadPart(req, bucket, key, r2, uploadId, Number(sp.get('partNumber')), cors)
108
+ }
109
+ if (req.method === 'POST') {
110
+ return handleCompleteMultipartUpload(req, bucket, key, r2, uploadId, cors)
111
+ }
112
+ if (req.method === 'DELETE') {
113
+ return handleAbortMultipartUpload(bucket, key, r2, uploadId, cors)
114
+ }
115
+ if (req.method === 'GET') {
116
+ return handleListParts(bucket, key, r2, uploadId, cors)
117
+ }
118
+ }
119
+
120
+ // Object sub-resource ops (distinguished by query flag)
121
+ if (sp.has('attributes') && req.method === 'GET') {
122
+ return handleGetObjectAttributes(req, bucket, key, r2, cors)
123
+ }
124
+ if (sp.has('tagging')) {
125
+ if (req.method === 'GET') return handleGetObjectTagging(bucket, key, r2, cors)
126
+ if (req.method === 'PUT') return handlePutObjectTagging(req, bucket, key, r2, cors)
127
+ if (req.method === 'DELETE') return handleDeleteObjectTagging(bucket, key, r2, cors)
128
+ }
129
+
130
+ // Regular object ops
131
+ switch (req.method) {
132
+ case 'GET':
133
+ return handleGetObject(req, bucket, key, r2, cors)
134
+ case 'HEAD':
135
+ return handleHeadObject(req, bucket, key, r2, cors)
136
+ case 'PUT':
137
+ return handlePutObject(req, bucket, key, r2, resolveBucket, cors)
138
+ case 'DELETE':
139
+ return handleDeleteObject(key, r2, cors)
140
+ }
141
+ return xmlError('InvalidRequest', `Unsupported method: ${req.method}`, url.pathname, cors)
142
+ }
143
+
144
+ // ─── Bucket-level dispatch ───────────────────────────────────────────────────
145
+
146
+ async function handleBucketLevel(
147
+ req: Request,
148
+ bucket: string,
149
+ r2: FileR2Bucket,
150
+ url: URL,
151
+ sp: URLSearchParams,
152
+ cors: Headers,
153
+ ): Promise<Response> {
154
+ if (req.method === 'HEAD') {
155
+ return new Response(null, { status: 200, headers: cors })
156
+ }
157
+ if (req.method === 'GET' && sp.has('location')) {
158
+ return xmlResponse(getBucketLocationXml(), 200, cors)
159
+ }
160
+ if (sp.has('cors')) {
161
+ if (req.method === 'GET') {
162
+ const stored = bucketCorsConfig.get(bucket)
163
+ if (!stored) {
164
+ return xmlError('NoSuchCORSConfiguration', 'The CORS configuration does not exist', bucket, cors)
165
+ }
166
+ return xmlResponse(stored, 200, cors)
167
+ }
168
+ if (req.method === 'PUT') {
169
+ bucketCorsConfig.set(bucket, await req.text())
170
+ return new Response('', { status: 200, headers: cors })
171
+ }
172
+ if (req.method === 'DELETE') {
173
+ bucketCorsConfig.delete(bucket)
174
+ return new Response('', { status: 204, headers: cors })
175
+ }
176
+ }
177
+ if (req.method === 'GET' && sp.has('uploads')) {
178
+ const prefix = sp.get('prefix') ?? undefined
179
+ const uploads = r2.listMultipartUploads(prefix)
180
+ return xmlResponse(listMultipartUploadsXml(bucket, uploads), 200, cors)
181
+ }
182
+ if (req.method === 'POST' && sp.has('delete')) {
183
+ return handleDeleteObjects(req, r2, cors)
184
+ }
185
+ if (req.method === 'POST' && req.headers.get('content-type')?.startsWith('multipart/form-data')) {
186
+ return handlePresignedPost(req, bucket, r2, cors)
187
+ }
188
+ if (req.method === 'GET') {
189
+ return handleListObjects(bucket, r2, sp, cors)
190
+ }
191
+ return xmlError('InvalidRequest', `Unsupported bucket operation: ${req.method}`, url.pathname, cors)
192
+ }
193
+
194
+ /**
195
+ * S3 presigned POST: browser HTML-form upload. The request is multipart/form-data
196
+ * whose last field is `file` (per S3 convention); other fields carry the object key
197
+ * and HTTP/custom metadata. SigV4 policy fields are present but unchecked.
198
+ */
199
+ async function handlePresignedPost(
200
+ req: Request,
201
+ bucket: string,
202
+ r2: FileR2Bucket,
203
+ cors: Headers,
204
+ ): Promise<Response> {
205
+ const form = await req.formData()
206
+ // S3 field names are case-insensitive for the control fields.
207
+ const getField = (name: string): string | undefined => {
208
+ for (const [k, v] of form) {
209
+ if (k.toLowerCase() === name.toLowerCase() && typeof v === 'string') return v
210
+ }
211
+ return undefined
212
+ }
213
+
214
+ let key = getField('key')
215
+ const file = form.get('file')
216
+ if (!key) return xmlError('InvalidArgument', 'Missing required form field: key', `/${bucket}/`, cors)
217
+ if (!(file instanceof File)) {
218
+ return xmlError('InvalidArgument', 'Missing required form field: file', `/${bucket}/`, cors)
219
+ }
220
+ key = key.replace('${filename}', file.name)
221
+
222
+ const httpMetadata: R2HTTPMetadata = {}
223
+ for (const [header, field] of HTTP_METADATA_FIELDS) {
224
+ const v = getField(header)
225
+ if (!v) continue
226
+ if (field === 'cacheExpiry') {
227
+ const d = new Date(v)
228
+ if (!Number.isNaN(d.getTime())) httpMetadata.cacheExpiry = d
229
+ } else {
230
+ ;(httpMetadata[field] as string) = v
231
+ }
232
+ }
233
+ const customMetadata: Record<string, string> = {}
234
+ for (const [k, v] of form) {
235
+ if (typeof v !== 'string') continue
236
+ if (k.toLowerCase().startsWith('x-amz-meta-')) {
237
+ customMetadata[k.slice('x-amz-meta-'.length)] = v
238
+ }
239
+ }
240
+
241
+ const putRes = await r2.put(key, file.stream(), { httpMetadata, customMetadata })
242
+ const etag = putRes?.etag ?? ''
243
+
244
+ const status = Number(getField('success_action_status')) || 204
245
+ if (status === 201) {
246
+ const location = `/${bucket}/${encodeURIComponent(key)}`
247
+ const body = `<?xml version="1.0" encoding="UTF-8"?>
248
+ <PostResponse>
249
+ <Location>${location}</Location>
250
+ <Bucket>${bucket}</Bucket>
251
+ <Key>${key}</Key>
252
+ <ETag>"${etag}"</ETag>
253
+ </PostResponse>`
254
+ return xmlResponse(body, 201, cors)
255
+ }
256
+ const headers = new Headers(cors)
257
+ if (etag) headers.set('ETag', `"${etag}"`)
258
+ return new Response('', { status: status === 200 ? 200 : 204, headers })
259
+ }
260
+
261
+ async function handleListObjects(
262
+ bucket: string,
263
+ r2: FileR2Bucket,
264
+ sp: URLSearchParams,
265
+ cors: Headers,
266
+ ): Promise<Response> {
267
+ const listType = sp.get('list-type')
268
+ if (listType === '2') {
269
+ const params: ListV2Params = {
270
+ prefix: sp.get('prefix') ?? undefined,
271
+ continuationToken: sp.get('continuation-token') ?? undefined,
272
+ maxKeys: sp.get('max-keys') ? Number(sp.get('max-keys')) : undefined,
273
+ delimiter: sp.get('delimiter') ?? undefined,
274
+ }
275
+ const list = await r2.list({
276
+ prefix: params.prefix,
277
+ cursor: params.continuationToken,
278
+ limit: params.maxKeys ?? 1000,
279
+ delimiter: params.delimiter,
280
+ })
281
+ const body = listBucketV2Xml(
282
+ bucket,
283
+ params,
284
+ list.objects,
285
+ list.truncated,
286
+ list.truncated && list.cursor ? list.cursor : undefined,
287
+ list.delimitedPrefixes,
288
+ )
289
+ return xmlResponse(body, 200, cors)
290
+ }
291
+
292
+ // V1
293
+ const params: ListV1Params = {
294
+ prefix: sp.get('prefix') ?? undefined,
295
+ marker: sp.get('marker') ?? undefined,
296
+ maxKeys: sp.get('max-keys') ? Number(sp.get('max-keys')) : undefined,
297
+ delimiter: sp.get('delimiter') ?? undefined,
298
+ }
299
+ const list = await r2.list({
300
+ prefix: params.prefix,
301
+ cursor: params.marker,
302
+ limit: params.maxKeys ?? 1000,
303
+ delimiter: params.delimiter,
304
+ })
305
+ const nextMarker = list.truncated && list.objects.length > 0
306
+ ? list.objects[list.objects.length - 1]!.key
307
+ : undefined
308
+ const body = listBucketV1Xml(bucket, params, list.objects, list.truncated, nextMarker, list.delimitedPrefixes)
309
+ return xmlResponse(body, 200, cors)
310
+ }
311
+
312
+ async function handleDeleteObjects(req: Request, r2: FileR2Bucket, cors: Headers): Promise<Response> {
313
+ const body = await req.text()
314
+ const { keys, quiet } = parseDeleteRequestXml(body)
315
+ const results: DeleteResultEntry[] = []
316
+ for (const k of keys) {
317
+ try {
318
+ await r2.delete(k)
319
+ results.push({ key: k })
320
+ } catch (err) {
321
+ results.push({ key: k, error: { code: 'InternalError', message: String(err) } })
322
+ }
323
+ }
324
+ return xmlResponse(deleteResultXml(results, quiet), 200, cors)
325
+ }
326
+
327
+ // ─── Object-level handlers ───────────────────────────────────────────────────
328
+
329
+ async function handleGetObject(
330
+ req: Request,
331
+ bucket: string,
332
+ key: string,
333
+ r2: FileR2Bucket,
334
+ cors: Headers,
335
+ ): Promise<Response> {
336
+ const range = parseRange(req.headers.get('range'))
337
+ const head = await r2.head(key)
338
+ if (!head) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
339
+
340
+ const cond = parseConditional(req.headers)
341
+ const condResult = evaluateConditional(cond, head, 'read')
342
+ if (condResult === 'precondition-failed') {
343
+ return xmlError('PreconditionFailed', 'At least one of the preconditions failed', `/${bucket}/${key}`, cors)
344
+ }
345
+ if (condResult === 'not-modified') {
346
+ const headers = applyObjectHeaders(head, new Headers(cors))
347
+ return new Response(null, { status: 304, headers })
348
+ }
349
+
350
+ const obj = (await r2.get(key, { range: range ?? undefined })) as R2ObjectBody | null
351
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
352
+
353
+ const headers = applyObjectHeaders(obj, new Headers(cors))
354
+ if (range && obj.range) {
355
+ const { offset, length } = obj.range
356
+ headers.set('Content-Length', String(length))
357
+ headers.set('Content-Range', `bytes ${offset}-${offset + length - 1}/${head.size}`)
358
+ return new Response(obj.body, { status: 206, headers })
359
+ }
360
+ return new Response(obj.body, { status: 200, headers })
361
+ }
362
+
363
+ async function handleHeadObject(
364
+ req: Request,
365
+ bucket: string,
366
+ key: string,
367
+ r2: FileR2Bucket,
368
+ cors: Headers,
369
+ ): Promise<Response> {
370
+ const obj = await r2.head(key)
371
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
372
+
373
+ const cond = parseConditional(req.headers)
374
+ const condResult = evaluateConditional(cond, obj, 'read')
375
+ const headers = applyObjectHeaders(obj, new Headers(cors))
376
+ if (condResult === 'precondition-failed') return new Response(null, { status: 412, headers })
377
+ if (condResult === 'not-modified') return new Response(null, { status: 304, headers })
378
+ return new Response(null, { status: 200, headers })
379
+ }
380
+
381
+ async function handlePutObject(
382
+ req: Request,
383
+ bucket: string,
384
+ key: string,
385
+ r2: FileR2Bucket,
386
+ resolveBucket: ResolveBucket | undefined,
387
+ cors: Headers,
388
+ ): Promise<Response> {
389
+ const copySource = req.headers.get('x-amz-copy-source')
390
+ if (copySource) {
391
+ return handleCopyObject(req, bucket, key, r2, resolveBucket, copySource, cors)
392
+ }
393
+
394
+ // Conditional PUT: apply to existing object if any
395
+ const cond = parseConditional(req.headers)
396
+ if (cond.ifMatch || cond.ifNoneMatch || cond.ifUnmodifiedSince) {
397
+ const existing = await r2.head(key)
398
+ if (existing) {
399
+ const res = evaluateConditional(cond, existing, 'write')
400
+ if (res === 'precondition-failed') {
401
+ return xmlError('PreconditionFailed', 'At least one of the preconditions failed', `/${bucket}/${key}`, cors)
402
+ }
403
+ } else if (cond.ifMatch) {
404
+ return xmlError('PreconditionFailed', 'If-Match precondition failed (no object)', `/${bucket}/${key}`, cors)
405
+ }
406
+ }
407
+
408
+ const { httpMetadata, customMetadata } = extractPutOptions(req)
409
+ const body = decodedBody(req)
410
+ const putRes = await r2.put(key, body, { httpMetadata, customMetadata })
411
+
412
+ // Validate Content-MD5 (base64 of md5 bytes). On mismatch, delete the just-
413
+ // written object and return BadDigest. S3 semantics: the request is rejected.
414
+ const contentMd5 = req.headers.get('content-md5')
415
+ if (contentMd5 && putRes) {
416
+ const got = Buffer.from(putRes.etag, 'hex').toString('base64')
417
+ if (got !== contentMd5.trim()) {
418
+ await r2.delete(key)
419
+ return xmlError(
420
+ 'BadDigest',
421
+ 'The Content-MD5 you specified did not match what we received.',
422
+ `/${bucket}/${key}`,
423
+ cors,
424
+ )
425
+ }
426
+ }
427
+
428
+ const headers = new Headers(cors)
429
+ if (putRes) headers.set('ETag', `"${putRes.etag}"`)
430
+ return new Response('', { status: 200, headers })
431
+ }
432
+
433
+ async function handleCopyObject(
434
+ req: Request,
435
+ destBucket: string,
436
+ destKey: string,
437
+ destR2: FileR2Bucket,
438
+ resolveBucket: ResolveBucket | undefined,
439
+ copySource: string,
440
+ cors: Headers,
441
+ ): Promise<Response> {
442
+ const source = parseCopySource(copySource)
443
+ if (!source) return xmlError('InvalidArgument', 'Invalid x-amz-copy-source header', copySource, cors)
444
+
445
+ const srcR2 = source.bucket === destBucket ? destR2 : resolveBucket?.(source.bucket)
446
+ if (!srcR2) return xmlError('NoSuchBucket', `Source bucket not found: ${source.bucket}`, copySource, cors)
447
+
448
+ const srcObj = (await srcR2.get(source.key)) as R2ObjectBody | null
449
+ if (!srcObj) return xmlError('NoSuchKey', 'Source object not found', copySource, cors)
450
+
451
+ // metadataDirective=REPLACE copies request headers; otherwise inherit from source
452
+ const directive = (req.headers.get('x-amz-metadata-directive') ?? 'COPY').toUpperCase()
453
+ let httpMetadata: R2HTTPMetadata
454
+ let customMetadata: Record<string, string>
455
+ if (directive === 'REPLACE') {
456
+ const opts = extractPutOptions(req)
457
+ httpMetadata = opts.httpMetadata
458
+ customMetadata = opts.customMetadata
459
+ } else {
460
+ httpMetadata = { ...srcObj.httpMetadata }
461
+ customMetadata = { ...srcObj.customMetadata }
462
+ }
463
+
464
+ const putRes = await destR2.put(destKey, srcObj.body, { httpMetadata, customMetadata })
465
+ if (!putRes) return xmlError('InvalidRequest', 'Copy failed', `/${destBucket}/${destKey}`, cors)
466
+ return xmlResponse(copyObjectResultXml(putRes.etag, putRes.uploaded), 200, cors)
467
+ }
468
+
469
+ function parseCopySource(raw: string): { bucket: string; key: string } | null {
470
+ let s = raw.trim()
471
+ try {
472
+ s = decodeURIComponent(s)
473
+ } catch {
474
+ return null
475
+ }
476
+ // Strip optional ?versionId=
477
+ const q = s.indexOf('?')
478
+ if (q !== -1) s = s.slice(0, q)
479
+ if (s.startsWith('/')) s = s.slice(1)
480
+ const slash = s.indexOf('/')
481
+ if (slash === -1) return null
482
+ return { bucket: s.slice(0, slash), key: s.slice(slash + 1) }
483
+ }
484
+
485
+ async function handleDeleteObject(key: string, r2: FileR2Bucket, cors: Headers): Promise<Response> {
486
+ await r2.delete(key)
487
+ return new Response('', { status: 204, headers: cors })
488
+ }
489
+
490
+ // ─── Multipart handlers ──────────────────────────────────────────────────────
491
+
492
+ async function handleCreateMultipartUpload(
493
+ req: Request,
494
+ bucket: string,
495
+ key: string,
496
+ r2: FileR2Bucket,
497
+ cors: Headers,
498
+ ): Promise<Response> {
499
+ const { httpMetadata, customMetadata } = extractPutOptions(req)
500
+ const upload = await r2.createMultipartUpload(key, { httpMetadata, customMetadata })
501
+ return xmlResponse(initiateMultipartUploadXml(bucket, key, upload.uploadId), 200, cors)
502
+ }
503
+
504
+ async function handleUploadPart(
505
+ req: Request,
506
+ bucket: string,
507
+ key: string,
508
+ r2: FileR2Bucket,
509
+ uploadId: string,
510
+ partNumber: number,
511
+ cors: Headers,
512
+ ): Promise<Response> {
513
+ if (!Number.isInteger(partNumber) || partNumber < 1) {
514
+ return xmlError('InvalidArgument', 'Invalid partNumber', `/${bucket}/${key}`, cors)
515
+ }
516
+ const upload = r2.resumeMultipartUpload(key, uploadId)
517
+ const body = decodedBody(req) ?? new ReadableStream({
518
+ start(c) {
519
+ c.close()
520
+ },
521
+ })
522
+ try {
523
+ const part = await upload.uploadPart(partNumber, body)
524
+ const headers = new Headers(cors)
525
+ headers.set('ETag', `"${part.etag}"`)
526
+ return new Response('', { status: 200, headers })
527
+ } catch (err) {
528
+ return xmlError('NoSuchUpload', String(err), `/${bucket}/${key}`, cors)
529
+ }
530
+ }
531
+
532
+ async function handleCompleteMultipartUpload(
533
+ req: Request,
534
+ bucket: string,
535
+ key: string,
536
+ r2: FileR2Bucket,
537
+ uploadId: string,
538
+ cors: Headers,
539
+ ): Promise<Response> {
540
+ const xml = await req.text()
541
+ const parts = parseCompletePartsXml(xml)
542
+ if (parts.length === 0) return xmlError('MalformedXML', 'No parts in request', `/${bucket}/${key}`, cors)
543
+
544
+ const upload = r2.resumeMultipartUpload(key, uploadId)
545
+ try {
546
+ const result = await upload.complete(parts)
547
+ const location = `/${bucket}/${encodeURIComponent(key)}`
548
+ return xmlResponse(completeMultipartUploadXml(bucket, key, result.etag, location), 200, cors)
549
+ } catch (err) {
550
+ const msg = String(err)
551
+ if (msg.includes('etag mismatch')) {
552
+ return xmlError('InvalidPart', msg, `/${bucket}/${key}`, cors)
553
+ }
554
+ return xmlError('NoSuchUpload', msg, `/${bucket}/${key}`, cors)
555
+ }
556
+ }
557
+
558
+ async function handleAbortMultipartUpload(
559
+ bucket: string,
560
+ key: string,
561
+ r2: FileR2Bucket,
562
+ uploadId: string,
563
+ cors: Headers,
564
+ ): Promise<Response> {
565
+ const upload = r2.resumeMultipartUpload(key, uploadId)
566
+ await upload.abort()
567
+ // S3 returns 204 whether or not the upload existed
568
+ void bucket
569
+ return new Response('', { status: 204, headers: cors })
570
+ }
571
+
572
+ async function handleListParts(
573
+ bucket: string,
574
+ key: string,
575
+ r2: FileR2Bucket,
576
+ uploadId: string,
577
+ cors: Headers,
578
+ ): Promise<Response> {
579
+ const upload = r2.resumeMultipartUpload(key, uploadId)
580
+ const parts = upload.listParts()
581
+ return xmlResponse(listPartsXml(bucket, key, uploadId, parts), 200, cors)
582
+ }
583
+
584
+ async function handleGetObjectAttributes(
585
+ req: Request,
586
+ bucket: string,
587
+ key: string,
588
+ r2: FileR2Bucket,
589
+ cors: Headers,
590
+ ): Promise<Response> {
591
+ const obj = await r2.head(key)
592
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
593
+ const requested = (req.headers.get('x-amz-object-attributes') ?? '')
594
+ .split(',')
595
+ .map((s) => s.trim())
596
+ const attrs: ObjectAttributes = {}
597
+ if (requested.includes('ETag')) attrs.etag = obj.etag
598
+ if (requested.includes('ObjectSize')) attrs.size = obj.size
599
+ // S3 uses all-caps STANDARD / STANDARD_IA; the R2 binding uses title case.
600
+ if (requested.includes('StorageClass')) attrs.storageClass = obj.storageClass.toUpperCase()
601
+ return xmlResponse(getObjectAttributesXml(attrs), 200, cors)
602
+ }
603
+
604
+ async function handleGetObjectTagging(
605
+ bucket: string,
606
+ key: string,
607
+ r2: FileR2Bucket,
608
+ cors: Headers,
609
+ ): Promise<Response> {
610
+ const obj = await r2.head(key)
611
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
612
+ const tags = r2.getTags(key)
613
+ return xmlResponse(taggingXml(tags), 200, cors)
614
+ }
615
+
616
+ async function handlePutObjectTagging(
617
+ req: Request,
618
+ bucket: string,
619
+ key: string,
620
+ r2: FileR2Bucket,
621
+ cors: Headers,
622
+ ): Promise<Response> {
623
+ const obj = await r2.head(key)
624
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
625
+ const body = await req.text()
626
+ const tags = parseTaggingXml(body)
627
+ r2.setTags(key, tags)
628
+ return new Response('', { status: 200, headers: cors })
629
+ }
630
+
631
+ async function handleDeleteObjectTagging(
632
+ bucket: string,
633
+ key: string,
634
+ r2: FileR2Bucket,
635
+ cors: Headers,
636
+ ): Promise<Response> {
637
+ const obj = await r2.head(key)
638
+ if (!obj) return xmlError('NoSuchKey', 'The specified key does not exist.', `/${bucket}/${key}`, cors)
639
+ r2.setTags(key, [])
640
+ return new Response('', { status: 204, headers: cors })
641
+ }
642
+
643
+ async function handleUploadPartCopy(
644
+ req: Request,
645
+ destBucket: string,
646
+ destKey: string,
647
+ destR2: FileR2Bucket,
648
+ resolveBucket: ResolveBucket | undefined,
649
+ uploadId: string,
650
+ partNumber: number,
651
+ copySource: string,
652
+ cors: Headers,
653
+ ): Promise<Response> {
654
+ if (!Number.isInteger(partNumber) || partNumber < 1) {
655
+ return xmlError('InvalidArgument', 'Invalid partNumber', `/${destBucket}/${destKey}`, cors)
656
+ }
657
+ const source = parseCopySource(copySource)
658
+ if (!source) return xmlError('InvalidArgument', 'Invalid x-amz-copy-source header', copySource, cors)
659
+
660
+ const srcR2 = source.bucket === destBucket ? destR2 : resolveBucket?.(source.bucket)
661
+ if (!srcR2) return xmlError('NoSuchBucket', `Source bucket not found: ${source.bucket}`, copySource, cors)
662
+
663
+ // Optional byte range: "bytes=start-end"
664
+ const rangeHeader = req.headers.get('x-amz-copy-source-range')
665
+ const range = rangeHeader ? parseRange(rangeHeader) : null
666
+
667
+ const srcObj = (await srcR2.get(source.key, range ? { range } : undefined)) as R2ObjectBody | null
668
+ if (!srcObj) return xmlError('NoSuchKey', 'Source object not found', copySource, cors)
669
+
670
+ const upload = destR2.resumeMultipartUpload(destKey, uploadId)
671
+ try {
672
+ const part = await upload.uploadPart(partNumber, srcObj.body)
673
+ return xmlResponse(copyPartResultXml(part.etag, srcObj.uploaded), 200, cors)
674
+ } catch (err) {
675
+ return xmlError('NoSuchUpload', String(err), `/${destBucket}/${destKey}`, cors)
676
+ }
677
+ }
678
+
679
+ // ─── Body helpers ────────────────────────────────────────────────────────────
680
+
681
+ function decodedBody(req: Request): ReadableStream<Uint8Array> | null {
682
+ if (!req.body) return null
683
+ if (isAwsChunked(req.headers)) return decodeAwsChunked(req.body)
684
+ return req.body
685
+ }
686
+
687
+ // ─── Path matcher for the dev server ─────────────────────────────────────────
688
+
689
+ /**
690
+ * Route dispatcher for /__s3/{bucket}/{key...}.
691
+ * Returns null if pathname does not match; caller falls through to other routes.
692
+ */
693
+ export function matchS3Path(pathname: string): { bucket: string; keyPath: string } | null {
694
+ if (!pathname.startsWith('/__s3/')) return null
695
+ const rest = pathname.slice('/__s3/'.length)
696
+ const slash = rest.indexOf('/')
697
+ if (slash === -1) return { bucket: rest, keyPath: '' }
698
+ return { bucket: rest.slice(0, slash), keyPath: rest.slice(slash + 1) }
699
+ }