@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/bin/s3node.js +101 -0
- package/docs/plan.md +340 -0
- package/package.json +50 -0
- package/src/auth/chunked.js +277 -0
- package/src/auth/credentials.js +41 -0
- package/src/auth/sigv4.js +293 -0
- package/src/errors.js +65 -0
- package/src/features/cors.js +115 -0
- package/src/features/encryption.js +201 -0
- package/src/features/formdata.js +169 -0
- package/src/features/lifecycle.js +175 -0
- package/src/features/notifications.js +161 -0
- package/src/features/policy.js +0 -0
- package/src/features/postpolicy.js +150 -0
- package/src/features/tagging.js +64 -0
- package/src/handlers/bucket.js +197 -0
- package/src/handlers/config.js +129 -0
- package/src/handlers/index.js +6 -0
- package/src/handlers/multipart.js +109 -0
- package/src/handlers/object.js +211 -0
- package/src/handlers/post.js +106 -0
- package/src/handlers/service.js +10 -0
- package/src/handlers/shared.js +84 -0
- package/src/http.js +243 -0
- package/src/index.js +30 -0
- package/src/router.js +128 -0
- package/src/server.js +282 -0
- package/src/storage/blobs.js +135 -0
- package/src/storage/metadata.js +616 -0
- package/src/storage/store.js +548 -0
- package/src/util/bytes.js +35 -0
- package/src/util/crc.js +54 -0
- package/src/util/hash.js +64 -0
- package/src/xml.js +205 -0
|
@@ -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
|
+
}
|