@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,129 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { collectBody, sendEmpty, sendXml } from '../http.js'
3
+ import { corsXml, parseCorsXml } from '../features/cors.js'
4
+ import { lifecycleXml, parseLifecycleXml } from '../features/lifecycle.js'
5
+ import { notificationXml, parseNotificationXml } from '../features/notifications.js'
6
+ import { parsePolicy } from '../features/policy.js'
7
+ import { MAX_BUCKET_TAGS, parseTaggingXml, taggingXml } from '../features/tagging.js'
8
+ import { childText, document, parseXml, text } from '../xml.js'
9
+
10
+ /* ----------------------------- versioning ----------------------------- */
11
+
12
+ export function getBucketVersioning(ctx, res, { store }) {
13
+ const status = store.bucketVersioning(ctx.bucket)
14
+ sendXml(ctx, res, 200, document('VersioningConfiguration',
15
+ status === 'Unset' ? '' : text('Status', status)))
16
+ }
17
+
18
+ export async function putBucketVersioning(ctx, res, { store }) {
19
+ store.requireBucket(ctx.bucket)
20
+ const root = parseXml(await collectBody(ctx.bodyStreams))
21
+ if (root.name !== 'VersioningConfiguration') {
22
+ throw new S3Error('MalformedXML', 'Expected a VersioningConfiguration element')
23
+ }
24
+ const status = childText(root, 'Status')
25
+ if (status !== 'Enabled' && status !== 'Suspended') {
26
+ throw new S3Error('IllegalVersioningConfigurationException',
27
+ 'The versioning status must be Enabled or Suspended')
28
+ }
29
+ store.putBucketConfig(ctx.bucket, 'versioning', { status })
30
+ sendEmpty(ctx, res, 200)
31
+ }
32
+
33
+ /* ------------------------------- policy ------------------------------- */
34
+
35
+ export function getBucketPolicy(ctx, res, { store }) {
36
+ const policy = store.getBucketConfig(ctx.bucket, 'policy')
37
+ if (!policy) throw new S3Error('NoSuchBucketPolicy')
38
+ const body = Buffer.from(JSON.stringify(policy), 'utf8')
39
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': body.length })
40
+ res.end(body)
41
+ }
42
+
43
+ export async function putBucketPolicy(ctx, res, { store }) {
44
+ store.requireBucket(ctx.bucket)
45
+ const policy = parsePolicy(await collectBody(ctx.bodyStreams))
46
+ store.putBucketConfig(ctx.bucket, 'policy', policy)
47
+ sendEmpty(ctx, res, 204)
48
+ }
49
+
50
+ export function deleteBucketPolicy(ctx, res, { store }) {
51
+ store.deleteBucketConfig(ctx.bucket, 'policy')
52
+ sendEmpty(ctx, res, 204)
53
+ }
54
+
55
+ /* -------------------------------- CORS -------------------------------- */
56
+
57
+ export function getBucketCors(ctx, res, { store }) {
58
+ const config = store.getBucketConfig(ctx.bucket, 'cors')
59
+ if (!config) throw new S3Error('NoSuchCORSConfiguration')
60
+ sendXml(ctx, res, 200, corsXml(config))
61
+ }
62
+
63
+ export async function putBucketCors(ctx, res, { store }) {
64
+ store.requireBucket(ctx.bucket)
65
+ store.putBucketConfig(ctx.bucket, 'cors', parseCorsXml(await collectBody(ctx.bodyStreams)))
66
+ sendEmpty(ctx, res, 200)
67
+ }
68
+
69
+ export function deleteBucketCors(ctx, res, { store }) {
70
+ store.deleteBucketConfig(ctx.bucket, 'cors')
71
+ sendEmpty(ctx, res, 204)
72
+ }
73
+
74
+ /* ----------------------------- lifecycle ------------------------------ */
75
+
76
+ export function getBucketLifecycle(ctx, res, { store }) {
77
+ const config = store.getBucketConfig(ctx.bucket, 'lifecycle')
78
+ if (!config) throw new S3Error('NoSuchLifecycleConfiguration')
79
+ sendXml(ctx, res, 200, lifecycleXml(config))
80
+ }
81
+
82
+ export async function putBucketLifecycle(ctx, res, { store }) {
83
+ store.requireBucket(ctx.bucket)
84
+ store.putBucketConfig(ctx.bucket, 'lifecycle', parseLifecycleXml(await collectBody(ctx.bodyStreams)))
85
+ sendEmpty(ctx, res, 200)
86
+ }
87
+
88
+ export function deleteBucketLifecycle(ctx, res, { store }) {
89
+ store.deleteBucketConfig(ctx.bucket, 'lifecycle')
90
+ sendEmpty(ctx, res, 204)
91
+ }
92
+
93
+ /* ------------------------------ tagging ------------------------------- */
94
+
95
+ export function getBucketTagging(ctx, res, { store }) {
96
+ const tags = store.getBucketConfig(ctx.bucket, 'tagging')
97
+ if (!tags || Object.keys(tags).length === 0) throw new S3Error('NoSuchTagSet')
98
+ sendXml(ctx, res, 200, taggingXml(tags))
99
+ }
100
+
101
+ export async function putBucketTagging(ctx, res, { store }) {
102
+ store.requireBucket(ctx.bucket)
103
+ const tags = parseTaggingXml(await collectBody(ctx.bodyStreams), { max: MAX_BUCKET_TAGS })
104
+ store.putBucketConfig(ctx.bucket, 'tagging', tags)
105
+ sendEmpty(ctx, res, 204)
106
+ }
107
+
108
+ export function deleteBucketTagging(ctx, res, { store }) {
109
+ store.deleteBucketConfig(ctx.bucket, 'tagging')
110
+ sendEmpty(ctx, res, 204)
111
+ }
112
+
113
+ /* --------------------------- notifications ---------------------------- */
114
+
115
+ export function getBucketNotification(ctx, res, { store }) {
116
+ const config = store.getBucketConfig(ctx.bucket, 'notification')
117
+ sendXml(ctx, res, 200, notificationXml(config ?? { targets: [] }))
118
+ }
119
+
120
+ export async function putBucketNotification(ctx, res, { store }) {
121
+ store.requireBucket(ctx.bucket)
122
+ const config = parseNotificationXml(await collectBody(ctx.bodyStreams))
123
+ if (config.targets.length === 0) {
124
+ store.deleteBucketConfig(ctx.bucket, 'notification')
125
+ } else {
126
+ store.putBucketConfig(ctx.bucket, 'notification', config)
127
+ }
128
+ sendEmpty(ctx, res, 200)
129
+ }
@@ -0,0 +1,6 @@
1
+ export * from './service.js'
2
+ export * from './bucket.js'
3
+ export * from './config.js'
4
+ export * from './object.js'
5
+ export * from './multipart.js'
6
+ export * from './post.js'
@@ -0,0 +1,109 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { encryptionResponseHeaders } from '../features/encryption.js'
3
+ import { parseTaggingHeader } from '../features/tagging.js'
4
+ import { collectBody, isoDate, sendEmpty, sendXml, userMetadata } from '../http.js'
5
+ import { childText, childrenNamed, document, parseXml, text } from '../xml.js'
6
+ import {
7
+ checksumHeaders, integerParam, integrityOptions, notify, ownerXml, sseRequest, versionHeaders,
8
+ } from './shared.js'
9
+
10
+ export function createMultipartUpload(ctx, res, { store }) {
11
+ const { uploadId, encryption } = store.createMultipartUpload({
12
+ bucket: ctx.bucket,
13
+ key: ctx.key,
14
+ contentType: ctx.headers['content-type'],
15
+ metadata: userMetadata(ctx.headers),
16
+ tags: parseTaggingHeader(ctx.headers['x-amz-tagging']),
17
+ encryptionRequest: sseRequest(ctx, store),
18
+ })
19
+ sendXml(ctx, res, 200, document('InitiateMultipartUploadResult',
20
+ text('Bucket', ctx.bucket) + text('Key', ctx.key) + text('UploadId', uploadId)),
21
+ encryptionResponseHeaders(encryption))
22
+ }
23
+
24
+ export async function uploadPart(ctx, res, { store }) {
25
+ const partNumber = Number.parseInt(ctx.query.get('partNumber'), 10)
26
+ const result = await store.uploadPart({
27
+ bucket: ctx.bucket,
28
+ key: ctx.key,
29
+ uploadId: ctx.query.get('uploadId'),
30
+ partNumber,
31
+ body: ctx.bodyStreams,
32
+ encryptionRequest: sseRequest(ctx, store),
33
+ ...integrityOptions(ctx),
34
+ })
35
+ sendEmpty(ctx, res, 200, { ETag: result.etag, ...encryptionResponseHeaders(result.encryption) })
36
+ }
37
+
38
+ export async function completeMultipartUpload(ctx, res, { store, server }) {
39
+ const uploadId = ctx.query.get('uploadId')
40
+ const root = parseXml(await collectBody(ctx.bodyStreams))
41
+ if (root.name !== 'CompleteMultipartUpload') {
42
+ throw new S3Error('MalformedXML', 'Expected a CompleteMultipartUpload element')
43
+ }
44
+
45
+ const requestedParts = childrenNamed(root, 'Part').map((part) => {
46
+ const partNumber = Number.parseInt(childText(part, 'PartNumber') ?? '', 10)
47
+ const etag = childText(part, 'ETag')
48
+ if (!Number.isInteger(partNumber) || !etag) {
49
+ throw new S3Error('MalformedXML', 'Each Part requires PartNumber and ETag')
50
+ }
51
+ return { partNumber, etag }
52
+ })
53
+
54
+ const result = await store.completeMultipartUpload({
55
+ bucket: ctx.bucket, key: ctx.key, uploadId, requestedParts,
56
+ })
57
+
58
+ notify(server, {
59
+ bucket: ctx.bucket, eventName: 'ObjectCreated:CompleteMultipartUpload', key: ctx.key,
60
+ size: result.size, etag: result.etag, versionId: result.versionId,
61
+ })
62
+
63
+ const location = `http://${ctx.headers.host ?? 'localhost'}/${ctx.bucket}/${encodeURIComponent(ctx.key)}`
64
+ sendXml(ctx, res, 200, document('CompleteMultipartUploadResult',
65
+ text('Location', location) + text('Bucket', ctx.bucket) +
66
+ text('Key', ctx.key) + text('ETag', result.etag)),
67
+ { ...versionHeaders(result), ...encryptionResponseHeaders(result.encryption) })
68
+ }
69
+
70
+ export async function abortMultipartUpload(ctx, res, { store }) {
71
+ await store.abortMultipartUpload({
72
+ bucket: ctx.bucket, key: ctx.key, uploadId: ctx.query.get('uploadId'),
73
+ })
74
+ sendEmpty(ctx, res, 204)
75
+ }
76
+
77
+ export function listParts(ctx, res, { store }) {
78
+ const uploadId = ctx.query.get('uploadId')
79
+ const partNumberMarker = integerParam(ctx.query, 'part-number-marker', 0)
80
+ const maxParts = integerParam(ctx.query, 'max-parts', 1000, { min: 0, max: 1000 })
81
+ const parts = store.listParts(ctx.bucket, ctx.key, uploadId, { partNumberMarker, maxParts })
82
+
83
+ const body =
84
+ text('Bucket', ctx.bucket) + text('Key', ctx.key) + text('UploadId', uploadId) +
85
+ text('PartNumberMarker', partNumberMarker) + text('MaxParts', maxParts) +
86
+ text('IsTruncated', String(parts.length === maxParts)) +
87
+ text('StorageClass', 'STANDARD') + ownerXml +
88
+ parts.map((part) =>
89
+ `<Part>${text('PartNumber', part.partNumber)}${text('LastModified', isoDate(part.uploadedAt))
90
+ }${text('ETag', part.etag)}${text('Size', part.size)}</Part>`
91
+ ).join('')
92
+
93
+ sendXml(ctx, res, 200, document('ListPartsResult', body))
94
+ }
95
+
96
+ export function listMultipartUploads(ctx, res, { store }) {
97
+ const maxUploads = integerParam(ctx.query, 'max-uploads', 1000, { min: 0, max: 1000 })
98
+ const uploads = store.listMultipartUploads(ctx.bucket, maxUploads)
99
+ const body =
100
+ text('Bucket', ctx.bucket) + text('MaxUploads', maxUploads) +
101
+ text('IsTruncated', String(uploads.length === maxUploads)) +
102
+ uploads.map((upload) =>
103
+ `<Upload>${text('Key', upload.key.toString('utf8'))}${text('UploadId', upload.uploadId)
104
+ }${ownerXml}${text('StorageClass', 'STANDARD')}${text('Initiated', isoDate(upload.initiatedAt))}</Upload>`
105
+ ).join('')
106
+ sendXml(ctx, res, 200, document('ListMultipartUploadsResult', body))
107
+ }
108
+
109
+ export { checksumHeaders }
@@ -0,0 +1,211 @@
1
+ import { pipeline } from 'node:stream/promises'
2
+ import { S3Error } from '../errors.js'
3
+ import { encryptionResponseHeaders } from '../features/encryption.js'
4
+ import { parseTaggingHeader, parseTaggingXml, taggingXml } from '../features/tagging.js'
5
+ import {
6
+ baseHeaders,
7
+ collectBody,
8
+ evaluatePreconditions,
9
+ httpDate,
10
+ isoDate,
11
+ parseRange,
12
+ sendEmpty,
13
+ sendXml,
14
+ userMetadata,
15
+ } from '../http.js'
16
+ import { document, text } from '../xml.js'
17
+ import { checksumHeaders, integrityOptions, notify, sseRequest, versionHeaders } from './shared.js'
18
+
19
+ function requestedVersionId(ctx) {
20
+ const versionId = ctx.query.get('versionId')
21
+ return versionId || null
22
+ }
23
+
24
+ export async function putObject(ctx, res, { store, server }) {
25
+ const result = await store.putObject({
26
+ bucket: ctx.bucket,
27
+ key: ctx.key,
28
+ body: ctx.bodyStreams,
29
+ contentType: ctx.headers['content-type'],
30
+ metadata: userMetadata(ctx.headers),
31
+ tags: parseTaggingHeader(ctx.headers['x-amz-tagging']),
32
+ encryptionRequest: sseRequest(ctx, store),
33
+ ...integrityOptions(ctx),
34
+ })
35
+
36
+ notify(server, {
37
+ bucket: ctx.bucket, eventName: 'ObjectCreated:Put', key: ctx.key,
38
+ size: result.size, etag: result.etag, versionId: result.versionId,
39
+ })
40
+
41
+ sendEmpty(ctx, res, 200, {
42
+ ETag: result.etag,
43
+ ...checksumHeaders(result.checksums),
44
+ ...versionHeaders(result),
45
+ ...encryptionResponseHeaders(result.encryption),
46
+ })
47
+ }
48
+
49
+ export async function copyObject(ctx, res, { store, server }) {
50
+ const raw = String(ctx.headers['x-amz-copy-source'])
51
+ const [pathPart, queryPart] = raw.split('?')
52
+ const normalized = pathPart.startsWith('/') ? pathPart.slice(1) : pathPart
53
+ const slash = normalized.indexOf('/')
54
+ if (slash === -1) throw new S3Error('InvalidArgument', 'Invalid x-amz-copy-source')
55
+
56
+ let sourceBucket
57
+ let sourceKey
58
+ try {
59
+ sourceBucket = decodeURIComponent(normalized.slice(0, slash))
60
+ sourceKey = decodeURIComponent(normalized.slice(slash + 1))
61
+ } catch {
62
+ throw new S3Error('InvalidArgument', 'Invalid x-amz-copy-source encoding')
63
+ }
64
+ const sourceVersionId = queryPart
65
+ ? new URLSearchParams(queryPart).get('versionId')
66
+ : null
67
+
68
+ const metadataDirective = String(ctx.headers['x-amz-metadata-directive'] ?? 'COPY').toUpperCase()
69
+ const taggingDirective = String(ctx.headers['x-amz-tagging-directive'] ?? 'COPY').toUpperCase()
70
+
71
+ const result = await store.copyObject({
72
+ sourceBucket,
73
+ sourceKey,
74
+ sourceVersionId,
75
+ bucket: ctx.bucket,
76
+ key: ctx.key,
77
+ replaceMetadata: metadataDirective === 'REPLACE',
78
+ metadata: userMetadata(ctx.headers),
79
+ contentType: ctx.headers['content-type'],
80
+ replaceTags: taggingDirective === 'REPLACE',
81
+ tags: parseTaggingHeader(ctx.headers['x-amz-tagging']),
82
+ sourceEncryptionRequest: sseRequest(ctx, store, { copySource: true }),
83
+ encryptionRequest: sseRequest(ctx, store),
84
+ })
85
+
86
+ notify(server, {
87
+ bucket: ctx.bucket, eventName: 'ObjectCreated:Copy', key: ctx.key,
88
+ size: result.size, etag: result.etag, versionId: result.versionId,
89
+ })
90
+
91
+ sendXml(ctx, res, 200, document('CopyObjectResult',
92
+ text('LastModified', isoDate(result.lastModified)) + text('ETag', result.etag)),
93
+ {
94
+ ...versionHeaders(result),
95
+ ...encryptionResponseHeaders(result.encryption),
96
+ ...(sourceVersionId ? { 'x-amz-copy-source-version-id': sourceVersionId } : {}),
97
+ })
98
+ }
99
+
100
+ function objectResponseHeaders(ctx, record) {
101
+ const headers = {
102
+ ...baseHeaders(ctx),
103
+ 'Content-Type': record.contentType ?? 'application/octet-stream',
104
+ ETag: record.etag,
105
+ 'Last-Modified': httpDate(record.lastModified),
106
+ 'Accept-Ranges': 'bytes',
107
+ ...checksumHeaders(record.checksums),
108
+ ...encryptionResponseHeaders(record.encryption),
109
+ }
110
+ if (record.versionId && record.versionId !== 'null') headers['x-amz-version-id'] = record.versionId
111
+ if (Object.keys(record.tags ?? {}).length) {
112
+ headers['x-amz-tagging-count'] = String(Object.keys(record.tags).length)
113
+ }
114
+ for (const [name, value] of Object.entries(record.metadata ?? {})) {
115
+ headers[`x-amz-meta-${name}`] = value
116
+ }
117
+ // Response header overrides for presigned download links.
118
+ const overrides = {
119
+ 'response-content-type': 'Content-Type',
120
+ 'response-content-disposition': 'Content-Disposition',
121
+ 'response-content-encoding': 'Content-Encoding',
122
+ 'response-content-language': 'Content-Language',
123
+ 'response-cache-control': 'Cache-Control',
124
+ 'response-expires': 'Expires',
125
+ }
126
+ for (const [param, header] of Object.entries(overrides)) {
127
+ const value = ctx.query.get(param)
128
+ if (value !== undefined) headers[header] = value
129
+ }
130
+ return headers
131
+ }
132
+
133
+ export async function getObject(ctx, res, { store }) {
134
+ const record = store.getObject(ctx.bucket, ctx.key, requestedVersionId(ctx))
135
+ const encryptionKey = store.resolveEncryptionKey(record, sseRequest(ctx, store))
136
+
137
+ if (evaluatePreconditions(ctx.headers, record) === 'not-modified') {
138
+ sendEmpty(ctx, res, 304, { ETag: record.etag, 'Last-Modified': httpDate(record.lastModified) })
139
+ return
140
+ }
141
+
142
+ const range = parseRange(ctx.headers.range, record.size)
143
+ const start = range ? range.start : 0
144
+ const end = range ? range.end : record.size - 1
145
+ const length = record.size === 0 ? 0 : end - start + 1
146
+
147
+ const headers = { ...objectResponseHeaders(ctx, record), 'Content-Length': length }
148
+ if (range) {
149
+ headers['Content-Range'] = `bytes ${start}-${end}/${record.size}`
150
+ // The stored checksum covers the whole object, not the slice being sent.
151
+ // Returning it makes SDK-side validation fail on every ranged read.
152
+ for (const name of Object.keys(headers)) {
153
+ if (name.startsWith('x-amz-checksum-')) delete headers[name]
154
+ }
155
+ }
156
+
157
+ res.writeHead(range ? 206 : 200, headers)
158
+ if (length === 0) {
159
+ res.end()
160
+ return
161
+ }
162
+ await pipeline(store.createObjectStream(record, start, end, { encryptionKey }), res)
163
+ }
164
+
165
+ export function headObject(ctx, res, { store }) {
166
+ const record = store.getObject(ctx.bucket, ctx.key, requestedVersionId(ctx))
167
+ store.resolveEncryptionKey(record, sseRequest(ctx, store))
168
+ if (evaluatePreconditions(ctx.headers, record) === 'not-modified') {
169
+ sendEmpty(ctx, res, 304, { ETag: record.etag })
170
+ return
171
+ }
172
+ res.writeHead(200, { ...objectResponseHeaders(ctx, record), 'Content-Length': record.size })
173
+ res.end()
174
+ }
175
+
176
+ export async function deleteObject(ctx, res, { store, server }) {
177
+ const result = await store.deleteObject(ctx.bucket, ctx.key, requestedVersionId(ctx))
178
+
179
+ if (result.deleted) {
180
+ notify(server, {
181
+ bucket: ctx.bucket,
182
+ eventName: result.deleteMarker ? 'ObjectRemoved:DeleteMarkerCreated' : 'ObjectRemoved:Delete',
183
+ key: ctx.key, size: 0, versionId: result.versionId,
184
+ })
185
+ }
186
+
187
+ const headers = {}
188
+ if (result.versionId && result.versionId !== 'null') headers['x-amz-version-id'] = result.versionId
189
+ if (result.deleteMarker) headers['x-amz-delete-marker'] = 'true'
190
+ // S3 deletes are idempotent: a missing key still reports success.
191
+ sendEmpty(ctx, res, 204, headers)
192
+ }
193
+
194
+ /* ------------------------------ tagging ------------------------------- */
195
+
196
+ export function getObjectTagging(ctx, res, { store }) {
197
+ const versionId = requestedVersionId(ctx)
198
+ const tags = store.getObjectTags(ctx.bucket, ctx.key, versionId)
199
+ sendXml(ctx, res, 200, taggingXml(tags), versionId ? { 'x-amz-version-id': versionId } : {})
200
+ }
201
+
202
+ export async function putObjectTagging(ctx, res, { store }) {
203
+ const tags = parseTaggingXml(await collectBody(ctx.bodyStreams))
204
+ const versionId = store.setObjectTags(ctx.bucket, ctx.key, requestedVersionId(ctx), tags)
205
+ sendEmpty(ctx, res, 200, versionId !== 'null' ? { 'x-amz-version-id': versionId } : {})
206
+ }
207
+
208
+ export function deleteObjectTagging(ctx, res, { store }) {
209
+ const versionId = store.setObjectTags(ctx.bucket, ctx.key, requestedVersionId(ctx), {})
210
+ sendEmpty(ctx, res, 204, versionId !== 'null' ? { 'x-amz-version-id': versionId } : {})
211
+ }
@@ -0,0 +1,106 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { parseBoundary, parseFormData } from '../features/formdata.js'
3
+ import { resolveKey, verifyPostPolicy } from '../features/postpolicy.js'
4
+ import { baseHeaders, sendEmpty, sendXml } from '../http.js'
5
+ import { document, text } from '../xml.js'
6
+ import { notify } from './shared.js'
7
+
8
+ /**
9
+ * Browser form upload: `POST /{bucket}` with multipart/form-data.
10
+ *
11
+ * Unlike every other route this one authenticates itself, because the
12
+ * credential and signature arrive as form fields rather than headers. The
13
+ * router therefore reaches it before the normal SigV4 path runs.
14
+ */
15
+ export async function postObject(ctx, res, { store, server }) {
16
+ const contentType = String(ctx.headers['content-type'] ?? '')
17
+ if (!contentType.toLowerCase().startsWith('multipart/form-data')) {
18
+ throw new S3Error('MalformedPOSTRequest', 'POST uploads require multipart/form-data')
19
+ }
20
+ store.requireBucket(ctx.bucket)
21
+
22
+ const { fields, file } = await parseFormData(ctx.bodyStreams, {
23
+ boundary: parseBoundary(contentType),
24
+ })
25
+ if (!file) throw new S3Error('MalformedPOSTRequest', 'The POST request is missing a file field')
26
+
27
+ let verified
28
+ try {
29
+ verified = verifyPostPolicy({
30
+ fields,
31
+ bucket: ctx.bucket,
32
+ lookupCredential: server.credentials.lookup,
33
+ })
34
+ } catch (err) {
35
+ file.stream.destroy()
36
+ throw err
37
+ }
38
+
39
+ const key = resolveKey(fields.get('key'), file.filename)
40
+ try {
41
+ // The form told us who is uploading; a bucket policy still gets its say.
42
+ server.authorizePostUpload(ctx, key, verified.accessKeyId)
43
+ } catch (err) {
44
+ file.stream.destroy()
45
+ throw err
46
+ }
47
+
48
+ const metadata = {}
49
+ for (const [name, value] of fields) {
50
+ if (name.startsWith('x-amz-meta-')) metadata[name.slice('x-amz-meta-'.length)] = value
51
+ }
52
+
53
+ const result = await store.putObject({
54
+ bucket: ctx.bucket,
55
+ key,
56
+ body: [file.stream],
57
+ contentType: fields.get('content-type') ?? file.contentType,
58
+ metadata,
59
+ })
60
+
61
+ // content-length-range is only knowable once the body has been read.
62
+ if (verified.range) {
63
+ const { min, max } = verified.range
64
+ if (result.size < min || result.size > max) {
65
+ await store.deleteObject(ctx.bucket, key, result.versioned ? result.versionId : null)
66
+ throw new S3Error('EntityTooLarge',
67
+ `The uploaded body must be between ${min} and ${max} bytes`)
68
+ }
69
+ }
70
+
71
+ notify(server, {
72
+ bucket: ctx.bucket, eventName: 'ObjectCreated:Post', key,
73
+ size: result.size, etag: result.etag, versionId: result.versionId,
74
+ })
75
+
76
+ const location = `http://${ctx.headers.host ?? 'localhost'}/${ctx.bucket}/${encodeURIComponent(key)}`
77
+ const extraHeaders = {
78
+ ETag: result.etag,
79
+ ...(result.versioned && result.versionId ? { 'x-amz-version-id': result.versionId } : {}),
80
+ }
81
+
82
+ const redirect = fields.get('success_action_redirect') ?? fields.get('redirect')
83
+ if (redirect) {
84
+ let target
85
+ try {
86
+ target = new URL(redirect)
87
+ } catch {
88
+ throw new S3Error('MalformedPOSTRequest', 'success_action_redirect is not a valid URL')
89
+ }
90
+ target.searchParams.set('bucket', ctx.bucket)
91
+ target.searchParams.set('key', key)
92
+ target.searchParams.set('etag', result.etag)
93
+ res.writeHead(303, { ...baseHeaders(ctx), ...extraHeaders, Location: target.toString(), 'Content-Length': 0 })
94
+ res.end()
95
+ return
96
+ }
97
+
98
+ const status = Number.parseInt(fields.get('success_action_status') ?? '204', 10)
99
+ if (status === 201) {
100
+ sendXml(ctx, res, 201, document('PostResponse',
101
+ text('Location', location) + text('Bucket', ctx.bucket) +
102
+ text('Key', key) + text('ETag', result.etag)), extraHeaders)
103
+ return
104
+ }
105
+ sendEmpty(ctx, res, status === 200 ? 200 : 204, extraHeaders)
106
+ }
@@ -0,0 +1,10 @@
1
+ import { isoDate, sendXml } from '../http.js'
2
+ import { document, text } from '../xml.js'
3
+ import { ownerXml } from './shared.js'
4
+
5
+ export function listBuckets(ctx, res, { store }) {
6
+ const buckets = store.listBuckets()
7
+ .map((bucket) => `<Bucket>${text('Name', bucket.name)}${text('CreationDate', isoDate(bucket.createdAt))}</Bucket>`)
8
+ .join('')
9
+ sendXml(ctx, res, 200, document('ListAllMyBucketsResult', `${ownerXml}<Buckets>${buckets}</Buckets>`))
10
+ }
@@ -0,0 +1,84 @@
1
+ import { S3Error } from '../errors.js'
2
+ import { CHECKSUM_ALGORITHMS } from '../util/hash.js'
3
+ import { text } from '../xml.js'
4
+
5
+ export const MAX_DELETE_KEYS = 1000
6
+ export const OWNER_ID = 's3node'
7
+ export const OWNER_DISPLAY_NAME = 's3node'
8
+
9
+ export const ownerXml = `<Owner>${text('ID', OWNER_ID)}${text('DisplayName', OWNER_DISPLAY_NAME)}</Owner>`
10
+
11
+ export function maybeEncode(value, encodingType) {
12
+ return encodingType === 'url' ? encodeURIComponent(value) : value
13
+ }
14
+
15
+ export function integerParam(query, name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
16
+ const raw = query.get(name)
17
+ if (raw === undefined || raw === '') return fallback
18
+ const parsed = Number.parseInt(raw, 10)
19
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
20
+ throw new S3Error('InvalidArgument', `Invalid value for ${name}`)
21
+ }
22
+ return parsed
23
+ }
24
+
25
+ /**
26
+ * Collects everything the request declared about body integrity: Content-MD5,
27
+ * the literal payload SHA-256 (when not streaming/unsigned) and the
28
+ * `x-amz-checksum-*` family, which modern SDKs send by default as a trailer
29
+ * (docs/plan.md section 4.2).
30
+ */
31
+ export function integrityOptions(ctx) {
32
+ const headers = ctx.headers
33
+ const payloadHash = ctx.auth?.payloadHash
34
+ const literalSha256 = /^[0-9a-f]{64}$/.test(payloadHash ?? '') ? payloadHash : null
35
+
36
+ let checksumAlgorithm = null
37
+ let expectedChecksum = null
38
+ for (const algorithm of CHECKSUM_ALGORITHMS) {
39
+ const value = headers[`x-amz-checksum-${algorithm}`]
40
+ if (value !== undefined) {
41
+ checksumAlgorithm = algorithm
42
+ expectedChecksum = String(value)
43
+ break
44
+ }
45
+ }
46
+ if (!checksumAlgorithm) {
47
+ const declared = headers['x-amz-trailer'] ?? headers['x-amz-sdk-checksum-algorithm']
48
+ const match = /(crc32c|crc32|sha256|sha1)/i.exec(String(declared ?? ''))
49
+ if (match) checksumAlgorithm = match[1].toLowerCase()
50
+ }
51
+
52
+ return {
53
+ contentMd5: headers['content-md5'] ? String(headers['content-md5']) : null,
54
+ expectedSha256: literalSha256,
55
+ checksumAlgorithm,
56
+ expectedChecksum,
57
+ trailerProvider: ctx.trailers ? (name) => ctx.trailers[name] ?? null : null,
58
+ }
59
+ }
60
+
61
+ export function checksumHeaders(checksums) {
62
+ const headers = {}
63
+ for (const [algorithm, value] of Object.entries(checksums ?? {})) {
64
+ headers[`x-amz-checksum-${algorithm}`] = value
65
+ }
66
+ return headers
67
+ }
68
+
69
+ /** `x-amz-version-id` is only meaningful once the bucket has been versioned. */
70
+ export function versionHeaders(result) {
71
+ return result?.versioned && result.versionId ? { 'x-amz-version-id': result.versionId } : {}
72
+ }
73
+
74
+ export function sseRequest(ctx, store, { copySource = false } = {}) {
75
+ const prefix = copySource
76
+ ? 'x-amz-copy-source-server-side-encryption'
77
+ : 'x-amz-server-side-encryption'
78
+ return store.encryption.parseRequest(ctx.headers, { prefix })
79
+ }
80
+
81
+ /** Fire-and-forget event delivery; never allowed to fail the request. */
82
+ export function notify(server, event) {
83
+ server?.notifications?.dispatch(event)
84
+ }