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/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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[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(/^&quot;+|&quot;+$/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(/&lt;/g, '<')
344
+ .replace(/&gt;/g, '>')
345
+ .replace(/&quot;/g, '"')
346
+ .replace(/&apos;/g, "'")
347
+ .replace(/&amp;/g, '&')
348
+ }