@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,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,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
|
+
}
|