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/xml.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { R2Object } from '../bindings/r2'
|
|
2
|
+
|
|
3
|
+
export function escapeXML(str: string): string {
|
|
4
|
+
return str.replace(/[<>&'"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', "'": ''', '"': '"' })[c]!)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const ERROR_STATUS = {
|
|
8
|
+
NoSuchBucket: 404,
|
|
9
|
+
NoSuchKey: 404,
|
|
10
|
+
NoSuchUpload: 404,
|
|
11
|
+
NoSuchCORSConfiguration: 404,
|
|
12
|
+
AccessDenied: 403,
|
|
13
|
+
PreconditionFailed: 412,
|
|
14
|
+
NotModified: 304,
|
|
15
|
+
InvalidArgument: 400,
|
|
16
|
+
InvalidRequest: 400,
|
|
17
|
+
InvalidRange: 416,
|
|
18
|
+
InvalidPart: 400,
|
|
19
|
+
MalformedXML: 400,
|
|
20
|
+
BadDigest: 400,
|
|
21
|
+
InternalError: 500,
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type S3ErrorCode = keyof typeof ERROR_STATUS
|
|
25
|
+
|
|
26
|
+
export function statusForError(code: S3ErrorCode): number {
|
|
27
|
+
return ERROR_STATUS[code]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function xmlResponse(body: string, status: number, extra?: Headers): Response {
|
|
31
|
+
const headers = new Headers({ 'content-type': 'application/xml' })
|
|
32
|
+
if (extra) { for (const [k, v] of extra) headers.set(k, v) }
|
|
33
|
+
return new Response(body, { status, headers })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function xmlError(code: S3ErrorCode, message: string, resource = '', extra?: Headers): Response {
|
|
37
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
38
|
+
<Error>
|
|
39
|
+
<Code>${escapeXML(code)}</Code>
|
|
40
|
+
<Message>${escapeXML(message)}</Message>
|
|
41
|
+
<Resource>${escapeXML(resource)}</Resource>
|
|
42
|
+
<RequestId>0000000000000000</RequestId>
|
|
43
|
+
</Error>`
|
|
44
|
+
return xmlResponse(body, statusForError(code), extra)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ListV2Params {
|
|
48
|
+
prefix?: string
|
|
49
|
+
continuationToken?: string
|
|
50
|
+
startAfter?: string
|
|
51
|
+
maxKeys?: number
|
|
52
|
+
delimiter?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ListV1Params {
|
|
56
|
+
prefix?: string
|
|
57
|
+
marker?: string
|
|
58
|
+
maxKeys?: number
|
|
59
|
+
delimiter?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderContents(items: R2Object[]): string {
|
|
63
|
+
return items
|
|
64
|
+
.map(
|
|
65
|
+
(o) =>
|
|
66
|
+
` <Contents>
|
|
67
|
+
<Key>${escapeXML(o.key)}</Key>
|
|
68
|
+
<LastModified>${o.uploaded.toISOString()}</LastModified>
|
|
69
|
+
<ETag>"${o.etag}"</ETag>
|
|
70
|
+
<Size>${o.size}</Size>
|
|
71
|
+
<StorageClass>STANDARD</StorageClass>
|
|
72
|
+
</Contents>`,
|
|
73
|
+
)
|
|
74
|
+
.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function renderCommonPrefixes(delimitedPrefixes: string[]): string {
|
|
78
|
+
return delimitedPrefixes
|
|
79
|
+
.map((p) => ` <CommonPrefixes><Prefix>${escapeXML(p)}</Prefix></CommonPrefixes>`)
|
|
80
|
+
.join('\n')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function listBucketV2Xml(
|
|
84
|
+
bucket: string,
|
|
85
|
+
params: ListV2Params,
|
|
86
|
+
items: R2Object[],
|
|
87
|
+
truncated: boolean,
|
|
88
|
+
nextContinuation: string | undefined,
|
|
89
|
+
delimitedPrefixes: string[],
|
|
90
|
+
): string {
|
|
91
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
92
|
+
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
93
|
+
<Name>${escapeXML(bucket)}</Name>
|
|
94
|
+
<Prefix>${escapeXML(params.prefix ?? '')}</Prefix>
|
|
95
|
+
${params.delimiter ? `<Delimiter>${escapeXML(params.delimiter)}</Delimiter>` : ''}
|
|
96
|
+
<KeyCount>${items.length + delimitedPrefixes.length}</KeyCount>
|
|
97
|
+
<MaxKeys>${params.maxKeys ?? 1000}</MaxKeys>
|
|
98
|
+
<IsTruncated>${truncated ? 'true' : 'false'}</IsTruncated>
|
|
99
|
+
${nextContinuation ? `<NextContinuationToken>${escapeXML(nextContinuation)}</NextContinuationToken>` : ''}
|
|
100
|
+
${renderContents(items)}
|
|
101
|
+
${renderCommonPrefixes(delimitedPrefixes)}
|
|
102
|
+
</ListBucketResult>`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function listBucketV1Xml(
|
|
106
|
+
bucket: string,
|
|
107
|
+
params: ListV1Params,
|
|
108
|
+
items: R2Object[],
|
|
109
|
+
truncated: boolean,
|
|
110
|
+
nextMarker: string | undefined,
|
|
111
|
+
delimitedPrefixes: string[],
|
|
112
|
+
): string {
|
|
113
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
114
|
+
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
115
|
+
<Name>${escapeXML(bucket)}</Name>
|
|
116
|
+
<Prefix>${escapeXML(params.prefix ?? '')}</Prefix>
|
|
117
|
+
${params.marker ? `<Marker>${escapeXML(params.marker)}</Marker>` : '<Marker/>'}
|
|
118
|
+
${params.delimiter ? `<Delimiter>${escapeXML(params.delimiter)}</Delimiter>` : ''}
|
|
119
|
+
<MaxKeys>${params.maxKeys ?? 1000}</MaxKeys>
|
|
120
|
+
<IsTruncated>${truncated ? 'true' : 'false'}</IsTruncated>
|
|
121
|
+
${nextMarker ? `<NextMarker>${escapeXML(nextMarker)}</NextMarker>` : ''}
|
|
122
|
+
${renderContents(items)}
|
|
123
|
+
${renderCommonPrefixes(delimitedPrefixes)}
|
|
124
|
+
</ListBucketResult>`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getBucketLocationXml(): string {
|
|
128
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
129
|
+
<LocationConstraint xmlns="http://s3.amazonaws.com/doc/2006-03-01/">auto</LocationConstraint>`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function listAllMyBucketsXml(buckets: Array<{ name: string; creationDate: Date }>): string {
|
|
133
|
+
const items = buckets
|
|
134
|
+
.map(
|
|
135
|
+
(b) =>
|
|
136
|
+
` <Bucket>
|
|
137
|
+
<Name>${escapeXML(b.name)}</Name>
|
|
138
|
+
<CreationDate>${b.creationDate.toISOString()}</CreationDate>
|
|
139
|
+
</Bucket>`,
|
|
140
|
+
)
|
|
141
|
+
.join('\n')
|
|
142
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
143
|
+
<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
144
|
+
<Owner>
|
|
145
|
+
<ID>00000000</ID>
|
|
146
|
+
<DisplayName>lopata</DisplayName>
|
|
147
|
+
</Owner>
|
|
148
|
+
<Buckets>
|
|
149
|
+
${items}
|
|
150
|
+
</Buckets>
|
|
151
|
+
</ListAllMyBucketsResult>`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function initiateMultipartUploadXml(bucket: string, key: string, uploadId: string): string {
|
|
155
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
156
|
+
<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
157
|
+
<Bucket>${escapeXML(bucket)}</Bucket>
|
|
158
|
+
<Key>${escapeXML(key)}</Key>
|
|
159
|
+
<UploadId>${escapeXML(uploadId)}</UploadId>
|
|
160
|
+
</InitiateMultipartUploadResult>`
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function completeMultipartUploadXml(bucket: string, key: string, etag: string, location: string): string {
|
|
164
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
165
|
+
<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
166
|
+
<Location>${escapeXML(location)}</Location>
|
|
167
|
+
<Bucket>${escapeXML(bucket)}</Bucket>
|
|
168
|
+
<Key>${escapeXML(key)}</Key>
|
|
169
|
+
<ETag>"${escapeXML(etag)}"</ETag>
|
|
170
|
+
</CompleteMultipartUploadResult>`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function copyObjectResultXml(etag: string, lastModified: Date): string {
|
|
174
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
175
|
+
<CopyObjectResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
176
|
+
<LastModified>${lastModified.toISOString()}</LastModified>
|
|
177
|
+
<ETag>"${escapeXML(etag)}"</ETag>
|
|
178
|
+
</CopyObjectResult>`
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function copyPartResultXml(etag: string, lastModified: Date): string {
|
|
182
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
183
|
+
<CopyPartResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
184
|
+
<LastModified>${lastModified.toISOString()}</LastModified>
|
|
185
|
+
<ETag>"${escapeXML(etag)}"</ETag>
|
|
186
|
+
</CopyPartResult>`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface ObjectAttributes {
|
|
190
|
+
etag?: string
|
|
191
|
+
size?: number
|
|
192
|
+
storageClass?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getObjectAttributesXml(a: ObjectAttributes): string {
|
|
196
|
+
const parts: string[] = []
|
|
197
|
+
if (a.etag !== undefined) parts.push(` <ETag>${escapeXML(a.etag)}</ETag>`)
|
|
198
|
+
if (a.size !== undefined) parts.push(` <ObjectSize>${a.size}</ObjectSize>`)
|
|
199
|
+
if (a.storageClass !== undefined) parts.push(` <StorageClass>${escapeXML(a.storageClass)}</StorageClass>`)
|
|
200
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
201
|
+
<GetObjectAttributesOutput xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
202
|
+
${parts.join('\n')}
|
|
203
|
+
</GetObjectAttributesOutput>`
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function taggingXml(tags: Array<{ key: string; value: string }>): string {
|
|
207
|
+
const items = tags
|
|
208
|
+
.map(
|
|
209
|
+
(t) => ` <Tag><Key>${escapeXML(t.key)}</Key><Value>${escapeXML(t.value)}</Value></Tag>`,
|
|
210
|
+
)
|
|
211
|
+
.join('\n')
|
|
212
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
213
|
+
<Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
214
|
+
<TagSet>
|
|
215
|
+
${items}
|
|
216
|
+
</TagSet>
|
|
217
|
+
</Tagging>`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function parseTaggingXml(body: string): Array<{ key: string; value: string }> {
|
|
221
|
+
const out: Array<{ key: string; value: string }> = []
|
|
222
|
+
const re = /<Tag>\s*<Key>([^<]*)<\/Key>\s*<Value>([^<]*)<\/Value>\s*<\/Tag>/g
|
|
223
|
+
let m: RegExpExecArray | null
|
|
224
|
+
while ((m = re.exec(body)) !== null) {
|
|
225
|
+
out.push({ key: decodeXmlEntities(m[1]!), value: decodeXmlEntities(m[2]!) })
|
|
226
|
+
}
|
|
227
|
+
return out
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export interface DeleteResultEntry {
|
|
231
|
+
key: string
|
|
232
|
+
error?: { code: string; message: string }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function deleteResultXml(entries: DeleteResultEntry[], quiet: boolean): string {
|
|
236
|
+
const parts = entries
|
|
237
|
+
.map((e) => {
|
|
238
|
+
if (e.error) {
|
|
239
|
+
return ` <Error>
|
|
240
|
+
<Key>${escapeXML(e.key)}</Key>
|
|
241
|
+
<Code>${escapeXML(e.error.code)}</Code>
|
|
242
|
+
<Message>${escapeXML(e.error.message)}</Message>
|
|
243
|
+
</Error>`
|
|
244
|
+
}
|
|
245
|
+
if (quiet) return ''
|
|
246
|
+
return ` <Deleted>
|
|
247
|
+
<Key>${escapeXML(e.key)}</Key>
|
|
248
|
+
</Deleted>`
|
|
249
|
+
})
|
|
250
|
+
.filter((s) => s !== '')
|
|
251
|
+
.join('\n')
|
|
252
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
253
|
+
<DeleteResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
254
|
+
${parts}
|
|
255
|
+
</DeleteResult>`
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function listMultipartUploadsXml(
|
|
259
|
+
bucket: string,
|
|
260
|
+
uploads: Array<{ key: string; uploadId: string; initiated: Date }>,
|
|
261
|
+
): string {
|
|
262
|
+
const items = uploads
|
|
263
|
+
.map(
|
|
264
|
+
(u) =>
|
|
265
|
+
` <Upload>
|
|
266
|
+
<Key>${escapeXML(u.key)}</Key>
|
|
267
|
+
<UploadId>${escapeXML(u.uploadId)}</UploadId>
|
|
268
|
+
<Initiated>${u.initiated.toISOString()}</Initiated>
|
|
269
|
+
<StorageClass>STANDARD</StorageClass>
|
|
270
|
+
</Upload>`,
|
|
271
|
+
)
|
|
272
|
+
.join('\n')
|
|
273
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
274
|
+
<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
275
|
+
<Bucket>${escapeXML(bucket)}</Bucket>
|
|
276
|
+
<IsTruncated>false</IsTruncated>
|
|
277
|
+
${items}
|
|
278
|
+
</ListMultipartUploadsResult>`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function listPartsXml(
|
|
282
|
+
bucket: string,
|
|
283
|
+
key: string,
|
|
284
|
+
uploadId: string,
|
|
285
|
+
parts: Array<{ partNumber: number; etag: string; size: number; lastModified: Date }>,
|
|
286
|
+
): string {
|
|
287
|
+
const items = parts
|
|
288
|
+
.map(
|
|
289
|
+
(p) =>
|
|
290
|
+
` <Part>
|
|
291
|
+
<PartNumber>${p.partNumber}</PartNumber>
|
|
292
|
+
<LastModified>${p.lastModified.toISOString()}</LastModified>
|
|
293
|
+
<ETag>"${escapeXML(p.etag)}"</ETag>
|
|
294
|
+
<Size>${p.size}</Size>
|
|
295
|
+
</Part>`,
|
|
296
|
+
)
|
|
297
|
+
.join('\n')
|
|
298
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
299
|
+
<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
300
|
+
<Bucket>${escapeXML(bucket)}</Bucket>
|
|
301
|
+
<Key>${escapeXML(key)}</Key>
|
|
302
|
+
<UploadId>${escapeXML(uploadId)}</UploadId>
|
|
303
|
+
<StorageClass>STANDARD</StorageClass>
|
|
304
|
+
<IsTruncated>false</IsTruncated>
|
|
305
|
+
${items}
|
|
306
|
+
</ListPartsResult>`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Parse a CompleteMultipartUpload request body.
|
|
311
|
+
* Expected: <CompleteMultipartUpload><Part><PartNumber>N</PartNumber><ETag>"x"</ETag></Part>...</CompleteMultipartUpload>
|
|
312
|
+
*/
|
|
313
|
+
export function parseCompletePartsXml(body: string): Array<{ partNumber: number; etag: string }> {
|
|
314
|
+
const parts: Array<{ partNumber: number; etag: string }> = []
|
|
315
|
+
const partBlocks = body.match(/<Part>[\s\S]*?<\/Part>/g) ?? []
|
|
316
|
+
for (const block of partBlocks) {
|
|
317
|
+
const pn = block.match(/<PartNumber>\s*(\d+)\s*<\/PartNumber>/)
|
|
318
|
+
const et = block.match(/<ETag>\s*([\s\S]*?)\s*<\/ETag>/)
|
|
319
|
+
if (!pn || !et) continue
|
|
320
|
+
const etag = et[1]!.trim().replace(/^"+|"+$/g, '').replace(/^"+|"+$/g, '')
|
|
321
|
+
parts.push({ partNumber: Number(pn[1]), etag })
|
|
322
|
+
}
|
|
323
|
+
return parts
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Parse a DeleteObjects request body.
|
|
328
|
+
* Expected: <Delete><Object><Key>k</Key></Object>...<Quiet>true</Quiet>?</Delete>
|
|
329
|
+
*/
|
|
330
|
+
export function parseDeleteRequestXml(body: string): { keys: string[]; quiet: boolean } {
|
|
331
|
+
const keys: string[] = []
|
|
332
|
+
const re = /<Object>\s*<Key>([^<]+)<\/Key>(?:\s*<VersionId>[^<]*<\/VersionId>)?\s*<\/Object>/g
|
|
333
|
+
let m: RegExpExecArray | null
|
|
334
|
+
while ((m = re.exec(body)) !== null) {
|
|
335
|
+
keys.push(decodeXmlEntities(m[1]!))
|
|
336
|
+
}
|
|
337
|
+
const quiet = /<Quiet>\s*true\s*<\/Quiet>/i.test(body)
|
|
338
|
+
return { keys, quiet }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function decodeXmlEntities(s: string): string {
|
|
342
|
+
return s
|
|
343
|
+
.replace(/</g, '<')
|
|
344
|
+
.replace(/>/g, '>')
|
|
345
|
+
.replace(/"/g, '"')
|
|
346
|
+
.replace(/'/g, "'")
|
|
347
|
+
.replace(/&/g, '&')
|
|
348
|
+
}
|