Skip to content

Commit b9acc7c

Browse files
authored
feat: add support for GetObject() custom response headers in S3 protocol (#780)
* feat: add support for custom response headers in s3 protocol * add date validation for response-expires header
1 parent 174f294 commit b9acc7c

File tree

3 files changed

+194
-20
lines changed

3 files changed

+194
-20
lines changed

src/http/routes/s3/commands/get-object.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { S3ProtocolHandler } from '@storage/protocols/s3/s3-handler'
22
import { S3Router } from '../router'
33
import { ROUTE_OPERATIONS } from '../../operations'
4+
import { ERRORS } from '@internal/errors'
45

56
const GetObjectInput = {
67
summary: 'Get Object',
@@ -20,7 +21,17 @@ const GetObjectInput = {
2021
'if-modified-since': { type: 'string' },
2122
},
2223
},
23-
Querystring: {},
24+
Querystring: {
25+
type: 'object',
26+
properties: {
27+
'response-content-disposition': { type: 'string' },
28+
'response-content-type': { type: 'string' },
29+
'response-cache-control': { type: 'string' },
30+
'response-content-encoding': { type: 'string' },
31+
'response-content-language': { type: 'string' },
32+
'response-expires': { type: 'string' },
33+
},
34+
},
2435
} as const
2536

2637
const GetObjectTagging = {
@@ -42,6 +53,16 @@ const GetObjectTagging = {
4253
},
4354
} as const
4455

56+
function parseDateHeader(input?: string) {
57+
if (input) {
58+
const parsedDate = new Date(input)
59+
if (isNaN(parsedDate.getTime())) {
60+
throw ERRORS.InvalidParameter('response-expires')
61+
}
62+
return parsedDate
63+
}
64+
}
65+
4566
export default function GetObject(s3Router: S3Router) {
4667
s3Router.get(
4768
'/:Bucket/*?tagging',
@@ -63,6 +84,7 @@ export default function GetObject(s3Router: S3Router) {
6384
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
6485
const ifModifiedSince = req.Headers?.['if-modified-since']
6586
const icebergBucket = ctx.req.internalIcebergBucketName
87+
const responseExpires = parseDateHeader(req.Querystring?.['response-expires'])
6688

6789
return s3Protocol.getObject(
6890
{
@@ -71,6 +93,12 @@ export default function GetObject(s3Router: S3Router) {
7193
Range: req.Headers?.['range'],
7294
IfNoneMatch: req.Headers?.['if-none-match'],
7395
IfModifiedSince: ifModifiedSince ? new Date(ifModifiedSince) : undefined,
96+
ResponseContentDisposition: req.Querystring?.['response-content-disposition'],
97+
ResponseContentType: req.Querystring?.['response-content-type'],
98+
ResponseCacheControl: req.Querystring?.['response-cache-control'],
99+
ResponseContentEncoding: req.Querystring?.['response-content-encoding'],
100+
ResponseContentLanguage: req.Querystring?.['response-content-language'],
101+
ResponseExpires: responseExpires,
74102
},
75103
{
76104
skipDbCheck: true,
@@ -86,6 +114,7 @@ export default function GetObject(s3Router: S3Router) {
86114
(req, ctx) => {
87115
const s3Protocol = new S3ProtocolHandler(ctx.storage, ctx.tenantId, ctx.owner)
88116
const ifModifiedSince = req.Headers?.['if-modified-since']
117+
const responseExpires = parseDateHeader(req.Querystring?.['response-expires'])
89118

90119
return s3Protocol.getObject(
91120
{
@@ -94,6 +123,12 @@ export default function GetObject(s3Router: S3Router) {
94123
Range: req.Headers?.['range'],
95124
IfNoneMatch: req.Headers?.['if-none-match'],
96125
IfModifiedSince: ifModifiedSince ? new Date(ifModifiedSince) : undefined,
126+
ResponseContentDisposition: req.Querystring?.['response-content-disposition'],
127+
ResponseContentType: req.Querystring?.['response-content-type'],
128+
ResponseCacheControl: req.Querystring?.['response-cache-control'],
129+
ResponseContentEncoding: req.Querystring?.['response-content-encoding'],
130+
ResponseContentLanguage: req.Querystring?.['response-content-language'],
131+
ResponseExpires: responseExpires,
97132
},
98133
{
99134
signal: ctx.signals.response,

src/storage/protocols/s3/s3-handler.ts

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { PassThrough, Readable } from 'stream'
2424
import stream from 'stream/promises'
2525
import { getFileSizeLimit, mustBeValidBucketName, mustBeValidKey } from '../../limits'
2626
import { ERRORS } from '@internal/errors'
27-
import { S3MultipartUpload, Obj } from '../../schemas'
27+
import { S3MultipartUpload } from '../../schemas'
2828
import { decrypt, encrypt } from '@internal/auth'
2929
import { ByteLimitTransformStream } from './byte-limit-stream'
3030
import { logger, logSchema } from '@internal/monitoring'
@@ -810,7 +810,7 @@ export class S3ProtocolHandler {
810810
throw ERRORS.NoSuchKey(Key)
811811
}
812812

813-
let metadataHeaders: Record<string, any> = {}
813+
let metadataHeaders: Record<string, unknown> = {}
814814

815815
if (object.user_metadata) {
816816
metadataHeaders = toAwsMeatadataHeaders(object.user_metadata)
@@ -873,7 +873,7 @@ export class S3ProtocolHandler {
873873
const key = command.Key as string
874874

875875
let version: string | undefined
876-
let userMetadata: Record<string, any> | undefined | null
876+
let userMetadata: Record<string, unknown> | undefined | null
877877

878878
if (!options?.skipDbCheck) {
879879
const object = await this.storage.from(bucket).findObject(key, 'version,user_metadata')
@@ -897,22 +897,44 @@ export class S3ProtocolHandler {
897897
options?.signal
898898
)
899899

900-
let metadataHeaders: Record<string, any> = {}
900+
let metadataHeaders: Record<string, unknown> = {}
901901

902902
if (userMetadata) {
903903
metadataHeaders = toAwsMeatadataHeaders(userMetadata)
904904
}
905905

906+
const headers: Record<string, string> = {
907+
'cache-control': response.metadata.cacheControl,
908+
'content-length': response.metadata.contentLength?.toString() || '0',
909+
'content-range': response.metadata.contentRange?.toString() || '',
910+
'content-type': response.metadata.mimetype,
911+
etag: response.metadata.eTag,
912+
'last-modified': response.metadata.lastModified?.toUTCString() || '',
913+
...metadataHeaders,
914+
}
915+
916+
// Handle response header overrides
917+
if (command.ResponseContentDisposition) {
918+
headers['content-disposition'] = command.ResponseContentDisposition
919+
}
920+
if (command.ResponseContentType) {
921+
headers['content-type'] = command.ResponseContentType
922+
}
923+
if (command.ResponseCacheControl) {
924+
headers['cache-control'] = command.ResponseCacheControl
925+
}
926+
if (command.ResponseContentEncoding) {
927+
headers['content-encoding'] = command.ResponseContentEncoding
928+
}
929+
if (command.ResponseContentLanguage) {
930+
headers['content-language'] = command.ResponseContentLanguage
931+
}
932+
if (command.ResponseExpires) {
933+
headers['expires'] = command.ResponseExpires.toUTCString()
934+
}
935+
906936
return {
907-
headers: {
908-
'cache-control': response.metadata.cacheControl,
909-
'content-length': response.metadata.contentLength?.toString() || '0',
910-
'content-range': response.metadata.contentRange?.toString() || '',
911-
'content-type': response.metadata.mimetype,
912-
etag: response.metadata.eTag,
913-
'last-modified': response.metadata.lastModified?.toUTCString() || '',
914-
...metadataHeaders,
915-
},
937+
headers,
916938
responseBody: response.body,
917939
statusCode: command.Range ? 206 : 200,
918940
}
@@ -1254,8 +1276,8 @@ export class S3ProtocolHandler {
12541276
}
12551277
}
12561278

1257-
parseMetadataHeaders(headers: Record<string, any>): Record<string, any> | undefined {
1258-
let metadata: Record<string, any> | undefined = undefined
1279+
parseMetadataHeaders(headers: Record<string, unknown>): Record<string, string> | undefined {
1280+
let metadata: Record<string, unknown> | undefined = undefined
12591281

12601282
Object.keys(headers)
12611283
.filter((key) => key.startsWith('x-amz-meta-'))
@@ -1334,14 +1356,14 @@ export function isValidHeader(name: string, value: string | string[]): boolean {
13341356
)
13351357
}
13361358

1337-
function toAwsMeatadataHeaders(records: Record<string, any>) {
1338-
const metadataHeaders: Record<string, any> = {}
1359+
function toAwsMeatadataHeaders(records: Record<string, unknown>) {
1360+
const metadataHeaders: Record<string, unknown> = {}
13391361
let missingCount = 0
13401362

13411363
if (records) {
13421364
Object.keys(records).forEach((key) => {
13431365
const value = records[key]
1344-
if (value && isUSASCII(value) && isValidHeader(key, value)) {
1366+
if (value && typeof value === 'string' && isUSASCII(value) && isValidHeader(key, value)) {
13451367
metadataHeaders['x-amz-meta-' + key.toLowerCase()] = value
13461368
} else {
13471369
missingCount++

src/test/s3-protocol.test.ts

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ async function uploadFile(
6060
bucketName: string,
6161
key: string,
6262
mb: number,
63-
headers?: Record<string, any>
63+
headers?: Record<string, string>
6464
) {
6565
const uploader = new Upload({
6666
client: client,
@@ -1541,6 +1541,123 @@ describe('S3 Protocol', () => {
15411541

15421542
expect(resp.ok).toBeTruthy()
15431543
})
1544+
1545+
it('supports response-content-disposition override', async () => {
1546+
const bucket = await createBucket(client)
1547+
const key = 'test-disposition.jpg'
1548+
1549+
await uploadFile(client, bucket, key, 2)
1550+
1551+
const response = await client.send(
1552+
new GetObjectCommand({
1553+
Bucket: bucket,
1554+
Key: key,
1555+
ResponseContentDisposition: 'attachment; filename="custom-name.txt"',
1556+
})
1557+
)
1558+
1559+
expect(response.ContentDisposition).toBe('attachment; filename="custom-name.txt"')
1560+
})
1561+
1562+
it('supports response-content-disposition override via presigned URL', async () => {
1563+
const bucket = await createBucket(client)
1564+
const key = 'test-presigned-disposition.jpg'
1565+
1566+
await uploadFile(client, bucket, key, 2)
1567+
1568+
const getUrl = await getSignedUrl(
1569+
client,
1570+
new GetObjectCommand({
1571+
Bucket: bucket,
1572+
Key: key,
1573+
ResponseContentDisposition: 'attachment; filename="presigned.pdf"',
1574+
}),
1575+
{ expiresIn: 100 }
1576+
)
1577+
1578+
const resp = await fetch(getUrl)
1579+
1580+
expect(resp.ok).toBeTruthy()
1581+
expect(resp.headers.get('content-disposition')).toBe('attachment; filename="presigned.pdf"')
1582+
})
1583+
1584+
it('supports response-content-type override', async () => {
1585+
const bucket = await createBucket(client)
1586+
const key = 'test-content-type.jpg'
1587+
1588+
await uploadFile(client, bucket, key, 2)
1589+
1590+
const response = await client.send(
1591+
new GetObjectCommand({
1592+
Bucket: bucket,
1593+
Key: key,
1594+
ResponseContentType: 'text/plain',
1595+
})
1596+
)
1597+
1598+
expect(response.ContentType).toBe('text/plain')
1599+
})
1600+
1601+
it('supports response-cache-control override', async () => {
1602+
const bucket = await createBucket(client)
1603+
const key = 'test-cache-control.jpg'
1604+
1605+
await uploadFile(client, bucket, key, 2)
1606+
1607+
const response = await client.send(
1608+
new GetObjectCommand({
1609+
Bucket: bucket,
1610+
Key: key,
1611+
ResponseCacheControl: 'no-cache, no-store',
1612+
})
1613+
)
1614+
1615+
expect(response.CacheControl).toBe('no-cache, no-store')
1616+
})
1617+
1618+
it('supports multiple response overrides simultaneously', async () => {
1619+
const bucket = await createBucket(client)
1620+
const key = 'test-multiple-overrides.jpg'
1621+
1622+
await uploadFile(client, bucket, key, 2)
1623+
1624+
const response = await client.send(
1625+
new GetObjectCommand({
1626+
Bucket: bucket,
1627+
Key: key,
1628+
ResponseContentDisposition: 'inline; filename="test.txt"',
1629+
ResponseContentType: 'application/octet-stream',
1630+
ResponseCacheControl: 'max-age=0',
1631+
ResponseContentLanguage: 'en-US',
1632+
ResponseContentEncoding: 'gzip',
1633+
})
1634+
)
1635+
1636+
expect(response.ContentDisposition).toBe('inline; filename="test.txt"')
1637+
expect(response.ContentType).toBe('application/octet-stream')
1638+
expect(response.CacheControl).toBe('max-age=0')
1639+
expect(response.ContentLanguage).toBe('en-US')
1640+
expect(response.ContentEncoding).toBe('gzip')
1641+
})
1642+
1643+
it('supports response-expires override', async () => {
1644+
const bucket = await createBucket(client)
1645+
const key = 'test-expires.jpg'
1646+
1647+
await uploadFile(client, bucket, key, 2)
1648+
1649+
const expiresDate = new Date('2030-01-01T00:00:00Z')
1650+
1651+
const response = await client.send(
1652+
new GetObjectCommand({
1653+
Bucket: bucket,
1654+
Key: key,
1655+
ResponseExpires: expiresDate,
1656+
})
1657+
)
1658+
1659+
expect(response.ExpiresString).toEqual(expiresDate.toUTCString())
1660+
})
15441661
})
15451662
})
15461663
})

0 commit comments

Comments
 (0)