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.
- package/package.json +5 -1
- package/src/bindings/r2.ts +289 -167
- package/src/cli/dev.ts +44 -0
- package/src/s3/chunked.ts +129 -0
- package/src/s3/headers.ts +131 -0
- package/src/s3/proxy.ts +699 -0
- package/src/s3/xml.ts +348 -0
package/src/s3/proxy.ts
ADDED
|
@@ -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
|
+
}
|