@felipedsvit/s3node 0.1.2

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,161 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { childNamed, childText, childrenNamed, document, parseXml, text } from '../xml.js'
3
+
4
+ const DISPATCH_TIMEOUT_MS = 5000
5
+
6
+ const KNOWN_EVENTS = [
7
+ 's3:ObjectCreated:*',
8
+ 's3:ObjectCreated:Put',
9
+ 's3:ObjectCreated:Post',
10
+ 's3:ObjectCreated:Copy',
11
+ 's3:ObjectCreated:CompleteMultipartUpload',
12
+ 's3:ObjectRemoved:*',
13
+ 's3:ObjectRemoved:Delete',
14
+ 's3:ObjectRemoved:DeleteMarkerCreated',
15
+ ]
16
+
17
+ /**
18
+ * There is no SQS or SNS behind this server, so destinations are plain
19
+ * webhooks. The element name is deliberately non-standard rather than
20
+ * pretending to be `<QueueConfiguration>` and silently doing something else.
21
+ */
22
+ function parseFilter(node) {
23
+ const filter = childNamed(node, 'Filter')
24
+ const s3Key = filter ? childNamed(filter, 'S3Key') : null
25
+ const rules = {}
26
+ for (const rule of childrenNamed(s3Key, 'FilterRule')) {
27
+ const name = childText(rule, 'Name')?.toLowerCase()
28
+ if (name === 'prefix' || name === 'suffix') rules[name] = childText(rule, 'Value') ?? ''
29
+ }
30
+ return rules
31
+ }
32
+
33
+ export function parseNotificationXml(body) {
34
+ const root = parseXml(body)
35
+ if (root.name !== 'NotificationConfiguration') {
36
+ throw new S3Error('MalformedXML', 'Expected a NotificationConfiguration element')
37
+ }
38
+ const targets = childrenNamed(root, 'WebhookConfiguration').map((node, index) => {
39
+ const endpoint = childText(node, 'Endpoint')
40
+ if (!endpoint) throw new S3Error('MalformedXML', 'Each WebhookConfiguration requires an Endpoint')
41
+ let url
42
+ try {
43
+ url = new URL(endpoint)
44
+ } catch {
45
+ throw new S3Error('MalformedXML', `Invalid webhook endpoint ${endpoint}`)
46
+ }
47
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
48
+ throw new S3Error('MalformedXML', 'Webhook endpoints must be http or https')
49
+ }
50
+ const events = childrenNamed(node, 'Event').map((event) => event.text)
51
+ if (events.length === 0) throw new S3Error('MalformedXML', 'Each WebhookConfiguration requires an Event')
52
+ for (const event of events) {
53
+ if (!KNOWN_EVENTS.includes(event)) {
54
+ throw new S3Error('MalformedXML', `Unsupported event ${event}`)
55
+ }
56
+ }
57
+ return { id: childText(node, 'Id') ?? `webhook-${index}`, endpoint, events, filter: parseFilter(node) }
58
+ })
59
+ return { targets }
60
+ }
61
+
62
+ export function notificationXml(config) {
63
+ const targets = (config?.targets ?? []).map((target) => {
64
+ const rules = Object.entries(target.filter ?? {})
65
+ .map(([name, value]) => `<FilterRule>${text('Name', name)}${text('Value', value)}</FilterRule>`)
66
+ .join('')
67
+ return '<WebhookConfiguration>' +
68
+ text('Id', target.id) +
69
+ text('Endpoint', target.endpoint) +
70
+ target.events.map((event) => text('Event', event)).join('') +
71
+ (rules ? `<Filter><S3Key>${rules}</S3Key></Filter>` : '') +
72
+ '</WebhookConfiguration>'
73
+ }).join('')
74
+ return document('NotificationConfiguration', targets)
75
+ }
76
+
77
+ function eventMatches(pattern, eventName) {
78
+ const full = `s3:${eventName}`
79
+ if (pattern === full) return true
80
+ if (!pattern.endsWith(':*')) return false
81
+ return full.startsWith(pattern.slice(0, -1))
82
+ }
83
+
84
+ function filterMatches(filter, key) {
85
+ if (filter.prefix !== undefined && !key.startsWith(filter.prefix)) return false
86
+ if (filter.suffix !== undefined && !key.endsWith(filter.suffix)) return false
87
+ return true
88
+ }
89
+
90
+ export function buildEvent({ eventName, region, bucket, key, size, etag, versionId, configurationId }) {
91
+ return {
92
+ Records: [{
93
+ eventVersion: '2.1',
94
+ eventSource: 'aws:s3',
95
+ awsRegion: region,
96
+ eventTime: new Date().toISOString(),
97
+ eventName,
98
+ s3: {
99
+ s3SchemaVersion: '1.0',
100
+ configurationId,
101
+ bucket: { name: bucket, arn: `arn:aws:s3:::${bucket}` },
102
+ object: {
103
+ key: encodeURIComponent(key).replaceAll('%2F', '/'),
104
+ size,
105
+ eTag: etag ? etag.replaceAll('"', '') : undefined,
106
+ versionId,
107
+ },
108
+ },
109
+ }],
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Best-effort, fire-and-forget delivery. A slow or broken webhook must never
115
+ * stall or fail the S3 request that triggered it.
116
+ */
117
+ export class NotificationDispatcher {
118
+ constructor(store, { region = 'us-east-1', logger = null, fetchImpl = fetch } = {}) {
119
+ this.store = store
120
+ this.region = region
121
+ this.logger = logger
122
+ this.fetchImpl = fetchImpl
123
+ this.inFlight = new Set()
124
+ }
125
+
126
+ dispatch({ bucket, eventName, key, size, etag, versionId }) {
127
+ let config
128
+ try {
129
+ config = this.store.metadata.getConfig(bucket, 'notification')
130
+ } catch {
131
+ return
132
+ }
133
+ if (!config?.targets?.length) return
134
+
135
+ for (const target of config.targets) {
136
+ if (!target.events.some((pattern) => eventMatches(pattern, eventName))) continue
137
+ if (!filterMatches(target.filter ?? {}, key)) continue
138
+
139
+ const payload = buildEvent({
140
+ eventName, region: this.region, bucket, key, size, etag, versionId,
141
+ configurationId: target.id,
142
+ })
143
+ const promise = this.fetchImpl(target.endpoint, {
144
+ method: 'POST',
145
+ headers: { 'content-type': 'application/json', 'x-amz-event-source': 's3node' },
146
+ body: JSON.stringify(payload),
147
+ signal: AbortSignal.timeout(DISPATCH_TIMEOUT_MS),
148
+ }).catch((err) => {
149
+ this.logger?.error?.({ message: 'notification delivery failed', endpoint: target.endpoint, error: err.message })
150
+ }).finally(() => this.inFlight.delete(promise))
151
+ this.inFlight.add(promise)
152
+ }
153
+ }
154
+
155
+ /** Used by tests and shutdown to wait for outstanding deliveries. */
156
+ async drain() {
157
+ while (this.inFlight.size) await Promise.allSettled([...this.inFlight])
158
+ }
159
+ }
160
+
161
+ export { KNOWN_EVENTS }
Binary file
@@ -0,0 +1,150 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { ALGORITHM, calculateSignature, deriveSigningKey, signaturesMatch } from '../auth/sigv4.js'
3
+
4
+ const MAX_POLICY_BYTES = 20 * 1024
5
+
6
+ /**
7
+ * Fields the browser always sends that do not themselves need a matching
8
+ * policy condition.
9
+ */
10
+ const EXEMPT_FIELDS = new Set([
11
+ 'policy', 'x-amz-signature', 'x-amz-algorithm', 'x-amz-credential', 'x-amz-date',
12
+ 'x-amz-security-token', 'file', 'signature', 'awsaccesskeyid',
13
+ ])
14
+
15
+ function parseCredential(credential) {
16
+ const parts = String(credential ?? '').split('/')
17
+ if (parts.length !== 5 || parts[4] !== 'aws4_request') {
18
+ throw new S3Error('AuthorizationHeaderMalformed', 'Malformed x-amz-credential')
19
+ }
20
+ return { accessKeyId: parts[0], date: parts[1], region: parts[2], service: parts[3] }
21
+ }
22
+
23
+ function conditionValue(fields, name) {
24
+ return fields.get(String(name).toLowerCase())
25
+ }
26
+
27
+ /**
28
+ * Validates the decoded policy against the submitted fields.
29
+ *
30
+ * Every condition must hold, and every submitted field must be covered by some
31
+ * condition. The second half is the important one: without it a form signed for
32
+ * `uploads/` could carry any other field the server acts on.
33
+ */
34
+ function validateConditions(policy, fields, { contentLength }) {
35
+ if (!Array.isArray(policy.conditions)) {
36
+ throw new S3Error('MalformedPOSTRequest', 'The policy requires a conditions array')
37
+ }
38
+ const covered = new Set(EXEMPT_FIELDS)
39
+ let range = null
40
+
41
+ for (const condition of policy.conditions) {
42
+ if (Array.isArray(condition)) {
43
+ const [operator, target, ...rest] = condition
44
+ const op = String(operator).toLowerCase()
45
+
46
+ if (op === 'content-length-range') {
47
+ range = { min: Number(target), max: Number(rest[0]) }
48
+ continue
49
+ }
50
+
51
+ const name = String(target ?? '').replace(/^\$/, '').toLowerCase()
52
+ covered.add(name)
53
+ const actual = conditionValue(fields, name)
54
+ if (op === 'eq') {
55
+ if (actual !== String(rest[0])) {
56
+ throw new S3Error('AccessDenied', `Policy condition failed: ${name} must equal "${rest[0]}"`)
57
+ }
58
+ } else if (op === 'starts-with') {
59
+ const prefix = String(rest[0] ?? '')
60
+ if (actual === undefined || !actual.startsWith(prefix)) {
61
+ throw new S3Error('AccessDenied', `Policy condition failed: ${name} must start with "${prefix}"`)
62
+ }
63
+ } else {
64
+ throw new S3Error('MalformedPOSTRequest', `Unsupported policy condition ${operator}`)
65
+ }
66
+ continue
67
+ }
68
+
69
+ if (!condition || typeof condition !== 'object') {
70
+ throw new S3Error('MalformedPOSTRequest', 'Malformed policy condition')
71
+ }
72
+ for (const [rawName, expected] of Object.entries(condition)) {
73
+ const name = rawName.toLowerCase()
74
+ covered.add(name)
75
+ if (conditionValue(fields, name) !== String(expected)) {
76
+ throw new S3Error('AccessDenied', `Policy condition failed: ${name} must equal "${expected}"`)
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const name of fields.keys()) {
82
+ if (!covered.has(name)) {
83
+ throw new S3Error('AccessDenied', `Field ${name} is not covered by the policy`)
84
+ }
85
+ }
86
+
87
+ if (range && contentLength !== undefined) {
88
+ if (contentLength < range.min || contentLength > range.max) {
89
+ throw new S3Error('EntityTooLarge',
90
+ `The uploaded body must be between ${range.min} and ${range.max} bytes`)
91
+ }
92
+ }
93
+ return { range }
94
+ }
95
+
96
+ /**
97
+ * Verifies a browser POST upload. The signature is taken over the base64 policy
98
+ * string exactly as submitted, so it must not be re-encoded before signing.
99
+ */
100
+ export function verifyPostPolicy({ fields, bucket, lookupCredential, now = Date.now(), contentLength }) {
101
+ const encodedPolicy = fields.get('policy')
102
+ if (!encodedPolicy) throw new S3Error('AccessDenied', 'The POST request is missing a policy')
103
+ if (encodedPolicy.length > MAX_POLICY_BYTES) {
104
+ throw new S3Error('MalformedPOSTRequest', 'The policy exceeds the maximum accepted size')
105
+ }
106
+
107
+ const algorithm = fields.get('x-amz-algorithm')
108
+ if (algorithm !== ALGORITHM) {
109
+ throw new S3Error('AccessDenied', 'Unsupported or missing x-amz-algorithm')
110
+ }
111
+ const signature = fields.get('x-amz-signature')
112
+ if (!signature) throw new S3Error('AccessDenied', 'The POST request is missing a signature')
113
+
114
+ const { accessKeyId, date, region, service } = parseCredential(fields.get('x-amz-credential'))
115
+ if (service !== 's3') throw new S3Error('AuthorizationHeaderMalformed', 'Credential must be scoped to s3')
116
+
117
+ const credential = lookupCredential(accessKeyId)
118
+ if (!credential) throw new S3Error('InvalidAccessKeyId')
119
+
120
+ let policy
121
+ try {
122
+ policy = JSON.parse(Buffer.from(encodedPolicy, 'base64').toString('utf8'))
123
+ } catch {
124
+ throw new S3Error('MalformedPOSTRequest', 'The policy is not valid base64-encoded JSON')
125
+ }
126
+
127
+ if (!policy.expiration || Number.isNaN(Date.parse(policy.expiration))) {
128
+ throw new S3Error('MalformedPOSTRequest', 'The policy requires an expiration')
129
+ }
130
+ if (now > Date.parse(policy.expiration)) throw new S3Error('AccessDenied', 'The policy has expired')
131
+
132
+ const expected = calculateSignature(
133
+ deriveSigningKey(credential.secretAccessKey, date, region, service, accessKeyId),
134
+ encodedPolicy,
135
+ )
136
+ if (!signaturesMatch(expected, signature)) throw new S3Error('SignatureDoesNotMatch')
137
+
138
+ if (fields.get('bucket') !== undefined && fields.get('bucket') !== bucket) {
139
+ throw new S3Error('AccessDenied', 'The policy was signed for a different bucket')
140
+ }
141
+
142
+ const { range } = validateConditions(policy, fields, { contentLength })
143
+ return { accessKeyId, policy, range }
144
+ }
145
+
146
+ /** S3 substitutes `${filename}` in the key with the uploaded file name. */
147
+ export function resolveKey(rawKey, filename) {
148
+ if (!rawKey) throw new S3Error('MalformedPOSTRequest', 'The POST request is missing a key')
149
+ return rawKey.replaceAll('${filename}', filename ?? '')
150
+ }
@@ -0,0 +1,64 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { childNamed, childText, childrenNamed, document, parseXml, text } from '../xml.js'
3
+
4
+ const MAX_OBJECT_TAGS = 10
5
+ const MAX_BUCKET_TAGS = 50
6
+ const MAX_KEY_LENGTH = 128
7
+ const MAX_VALUE_LENGTH = 256
8
+
9
+ export function validateTagSet(tags, { max = MAX_OBJECT_TAGS } = {}) {
10
+ const entries = Object.entries(tags)
11
+ if (entries.length > max) {
12
+ throw new S3Error('InvalidTag', `A maximum of ${max} tags is allowed`)
13
+ }
14
+ for (const [key, value] of entries) {
15
+ if (!key || key.length > MAX_KEY_LENGTH) {
16
+ throw new S3Error('InvalidTag', `Tag key must be between 1 and ${MAX_KEY_LENGTH} characters`)
17
+ }
18
+ if (String(value).length > MAX_VALUE_LENGTH) {
19
+ throw new S3Error('InvalidTag', `Tag value must be at most ${MAX_VALUE_LENGTH} characters`)
20
+ }
21
+ }
22
+ return tags
23
+ }
24
+
25
+ /** `x-amz-tagging: a=1&b=2` — the header form used on PutObject. */
26
+ export function parseTaggingHeader(header) {
27
+ if (!header) return {}
28
+ const tags = {}
29
+ for (const pair of String(header).split('&')) {
30
+ if (!pair) continue
31
+ const eq = pair.indexOf('=')
32
+ const rawKey = eq === -1 ? pair : pair.slice(0, eq)
33
+ const rawValue = eq === -1 ? '' : pair.slice(eq + 1)
34
+ try {
35
+ tags[decodeURIComponent(rawKey.replaceAll('+', ' '))] =
36
+ decodeURIComponent(rawValue.replaceAll('+', ' '))
37
+ } catch {
38
+ throw new S3Error('InvalidArgument', 'Malformed x-amz-tagging header')
39
+ }
40
+ }
41
+ return validateTagSet(tags)
42
+ }
43
+
44
+ export function parseTaggingXml(body, options) {
45
+ const root = parseXml(body)
46
+ if (root.name !== 'Tagging') throw new S3Error('MalformedXML', 'Expected a Tagging element')
47
+ const tagSet = childNamed(root, 'TagSet')
48
+ const tags = {}
49
+ for (const tag of childrenNamed(tagSet, 'Tag')) {
50
+ const key = childText(tag, 'Key')
51
+ if (!key) throw new S3Error('MalformedXML', 'Each Tag requires a Key')
52
+ tags[key] = childText(tag, 'Value') ?? ''
53
+ }
54
+ return validateTagSet(tags, options)
55
+ }
56
+
57
+ export function taggingXml(tags) {
58
+ const entries = Object.entries(tags ?? {})
59
+ .map(([key, value]) => `<Tag>${text('Key', key)}${text('Value', value)}</Tag>`)
60
+ .join('')
61
+ return document('Tagging', `<TagSet>${entries}</TagSet>`)
62
+ }
63
+
64
+ export { MAX_BUCKET_TAGS, MAX_OBJECT_TAGS }
@@ -0,0 +1,197 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { collectBody, isoDate, sendEmpty, sendXml } from '../http.js'
3
+ import { childText, childrenNamed, document, escapeXml, parseXml, text } from '../xml.js'
4
+ import { MAX_DELETE_KEYS, integerParam, maybeEncode, ownerXml } from './shared.js'
5
+ import { notify } from './shared.js'
6
+
7
+ export async function createBucket(ctx, res, { store }) {
8
+ await collectBody(ctx.bodyStreams).catch(() => Buffer.alloc(0))
9
+ store.createBucket(ctx.bucket)
10
+ sendEmpty(ctx, res, 200, { Location: `/${ctx.bucket}` })
11
+ }
12
+
13
+ export async function deleteBucket(ctx, res, { store }) {
14
+ await store.deleteBucket(ctx.bucket)
15
+ sendEmpty(ctx, res, 204)
16
+ }
17
+
18
+ export function headBucket(ctx, res, { store }) {
19
+ store.requireBucket(ctx.bucket)
20
+ sendEmpty(ctx, res, 200, { 'x-amz-bucket-region': store.region })
21
+ }
22
+
23
+ export function getBucketLocation(ctx, res, { store }) {
24
+ store.requireBucket(ctx.bucket)
25
+ const constraint = store.region === 'us-east-1' ? '' : escapeXml(store.region)
26
+ sendXml(ctx, res, 200, document('LocationConstraint', constraint))
27
+ }
28
+
29
+ function listCommon(ctx) {
30
+ return {
31
+ prefix: ctx.query.get('prefix') ?? '',
32
+ delimiter: ctx.query.get('delimiter') ?? '',
33
+ encodingType: ctx.query.get('encoding-type') ?? '',
34
+ maxKeys: integerParam(ctx.query, 'max-keys', 1000, { min: 0, max: 1000 }),
35
+ }
36
+ }
37
+
38
+ function contentsXml(records, encodingType, { withOwner }) {
39
+ return records.map((record) => (
40
+ `<Contents>${
41
+ text('Key', maybeEncode(record.key.toString('utf8'), encodingType))
42
+ }${text('LastModified', isoDate(record.lastModified))
43
+ }${text('ETag', record.etag)
44
+ }${text('Size', record.size)
45
+ }${text('StorageClass', 'STANDARD')
46
+ }${withOwner ? ownerXml : ''}</Contents>`
47
+ )).join('')
48
+ }
49
+
50
+ function prefixesXml(prefixes, encodingType) {
51
+ return prefixes
52
+ .map((value) => `<CommonPrefixes>${text('Prefix', maybeEncode(value, encodingType))}</CommonPrefixes>`)
53
+ .join('')
54
+ }
55
+
56
+ export function listObjectsV2(ctx, res, { store }) {
57
+ const { prefix, delimiter, encodingType, maxKeys } = listCommon(ctx)
58
+ const continuationToken = ctx.query.get('continuation-token')
59
+ const startAfter = ctx.query.get('start-after') ?? ''
60
+ const fetchOwner = ctx.query.get('fetch-owner') === 'true'
61
+
62
+ const cursor = continuationToken ? Buffer.from(continuationToken, 'base64') : null
63
+ const result = store.listObjects(ctx.bucket, { prefix, delimiter, maxKeys, startAfter, cursor })
64
+
65
+ const body =
66
+ text('Name', ctx.bucket) +
67
+ text('Prefix', maybeEncode(prefix, encodingType)) +
68
+ (delimiter ? text('Delimiter', maybeEncode(delimiter, encodingType)) : '') +
69
+ text('MaxKeys', maxKeys) +
70
+ text('KeyCount', result.contents.length + result.commonPrefixes.length) +
71
+ text('IsTruncated', String(result.truncated)) +
72
+ (continuationToken ? text('ContinuationToken', continuationToken) : '') +
73
+ (result.truncated && result.nextCursor
74
+ ? text('NextContinuationToken', Buffer.from(result.nextCursor).toString('base64'))
75
+ : '') +
76
+ (startAfter ? text('StartAfter', maybeEncode(startAfter, encodingType)) : '') +
77
+ (encodingType ? text('EncodingType', encodingType) : '') +
78
+ contentsXml(result.contents, encodingType, { withOwner: fetchOwner }) +
79
+ prefixesXml(result.commonPrefixes, encodingType)
80
+
81
+ sendXml(ctx, res, 200, document('ListBucketResult', body))
82
+ }
83
+
84
+ export function listObjectsV1(ctx, res, { store }) {
85
+ const { prefix, delimiter, encodingType, maxKeys } = listCommon(ctx)
86
+ const marker = ctx.query.get('marker') ?? ''
87
+ const result = store.listObjects(ctx.bucket, { prefix, delimiter, maxKeys, startAfter: marker })
88
+
89
+ // V1 pagination is by last key returned, not by an opaque cursor, so the
90
+ // marker is whichever of the two result lists ends highest in byte order.
91
+ const candidates = [
92
+ result.contents.at(-1)?.key.toString('utf8'),
93
+ result.commonPrefixes.at(-1),
94
+ ].filter((value) => value !== undefined)
95
+ const lastKey = candidates.sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b))).at(-1)
96
+
97
+ const body =
98
+ text('Name', ctx.bucket) +
99
+ text('Prefix', maybeEncode(prefix, encodingType)) +
100
+ text('Marker', maybeEncode(marker, encodingType)) +
101
+ (delimiter ? text('Delimiter', maybeEncode(delimiter, encodingType)) : '') +
102
+ text('MaxKeys', maxKeys) +
103
+ text('IsTruncated', String(result.truncated)) +
104
+ (result.truncated && lastKey ? text('NextMarker', maybeEncode(lastKey, encodingType)) : '') +
105
+ (encodingType ? text('EncodingType', encodingType) : '') +
106
+ contentsXml(result.contents, encodingType, { withOwner: true }) +
107
+ prefixesXml(result.commonPrefixes, encodingType)
108
+
109
+ sendXml(ctx, res, 200, document('ListBucketResult', body))
110
+ }
111
+
112
+ export function listObjectVersions(ctx, res, { store }) {
113
+ const { prefix, delimiter, encodingType, maxKeys } = listCommon(ctx)
114
+ const keyMarker = ctx.query.get('key-marker') ?? ''
115
+ const versionIdMarker = ctx.query.get('version-id-marker') ?? ''
116
+
117
+ const result = store.listVersions(ctx.bucket, {
118
+ prefix, delimiter, maxKeys, keyMarker, versionIdMarker,
119
+ })
120
+
121
+ const entries = result.versions.map((record) => {
122
+ const common =
123
+ text('Key', maybeEncode(record.key.toString('utf8'), encodingType)) +
124
+ text('VersionId', record.versionId) +
125
+ text('IsLatest', String(record.isLatest)) +
126
+ text('LastModified', isoDate(record.lastModified))
127
+ return record.isDeleteMarker
128
+ ? `<DeleteMarker>${common}${ownerXml}</DeleteMarker>`
129
+ : `<Version>${common}${text('ETag', record.etag)}${text('Size', record.size)}` +
130
+ `${text('StorageClass', 'STANDARD')}${ownerXml}</Version>`
131
+ }).join('')
132
+
133
+ const body =
134
+ text('Name', ctx.bucket) +
135
+ text('Prefix', maybeEncode(prefix, encodingType)) +
136
+ (delimiter ? text('Delimiter', maybeEncode(delimiter, encodingType)) : '') +
137
+ text('KeyMarker', maybeEncode(keyMarker, encodingType)) +
138
+ (versionIdMarker ? text('VersionIdMarker', versionIdMarker) : '') +
139
+ text('MaxKeys', maxKeys) +
140
+ text('IsTruncated', String(result.truncated)) +
141
+ (result.nextKeyMarker ? text('NextKeyMarker', maybeEncode(result.nextKeyMarker, encodingType)) : '') +
142
+ (result.nextVersionIdMarker ? text('NextVersionIdMarker', result.nextVersionIdMarker) : '') +
143
+ (encodingType ? text('EncodingType', encodingType) : '') +
144
+ entries +
145
+ prefixesXml(result.commonPrefixes, encodingType)
146
+
147
+ sendXml(ctx, res, 200, document('ListVersionsResult', body))
148
+ }
149
+
150
+ export async function deleteObjects(ctx, res, { store, server }) {
151
+ store.requireBucket(ctx.bucket)
152
+ const body = await collectBody(ctx.bodyStreams)
153
+ const root = parseXml(body)
154
+ if (root.name !== 'Delete') throw new S3Error('MalformedXML', 'Expected a Delete element')
155
+
156
+ const quiet = childText(root, 'Quiet') === 'true'
157
+ const entries = childrenNamed(root, 'Object')
158
+ if (entries.length === 0) throw new S3Error('MalformedXML', 'No objects specified')
159
+ if (entries.length > MAX_DELETE_KEYS) {
160
+ throw new S3Error('MalformedXML', `A maximum of ${MAX_DELETE_KEYS} keys may be deleted in one request`)
161
+ }
162
+
163
+ const deleted = []
164
+ const errors = []
165
+ for (const entry of entries) {
166
+ const key = childText(entry, 'Key')
167
+ if (!key) {
168
+ errors.push({ key: '', code: 'MalformedXML', message: 'Missing Key' })
169
+ continue
170
+ }
171
+ const versionId = childText(entry, 'VersionId')
172
+ try {
173
+ const result = await store.deleteObject(ctx.bucket, key, versionId ?? null)
174
+ deleted.push({ key, ...result })
175
+ notify(server, {
176
+ bucket: ctx.bucket,
177
+ eventName: result.deleteMarker ? 'ObjectRemoved:DeleteMarkerCreated' : 'ObjectRemoved:Delete',
178
+ key, size: 0, versionId: result.versionId,
179
+ })
180
+ } catch (err) {
181
+ const s3 = err instanceof S3Error ? err : new S3Error('InternalError', err.message)
182
+ errors.push({ key, code: s3.code, message: s3.message })
183
+ }
184
+ }
185
+
186
+ const payload =
187
+ (quiet ? '' : deleted.map((entry) =>
188
+ `<Deleted>${text('Key', entry.key)}${
189
+ entry.versionId ? text('VersionId', entry.versionId) : ''
190
+ }${entry.deleteMarker ? text('DeleteMarker', 'true') : ''}</Deleted>`
191
+ ).join('')) +
192
+ errors.map((entry) =>
193
+ `<Error>${text('Key', entry.key)}${text('Code', entry.code)}${text('Message', entry.message)}</Error>`
194
+ ).join('')
195
+
196
+ sendXml(ctx, res, 200, document('DeleteResult', payload))
197
+ }