Skip to content

Commit dbc75b1

Browse files
authored
feat: force downloading file when providing filename (#195)
1 parent 6e2ae7a commit dbc75b1

File tree

4 files changed

+98
-0
lines changed

4 files changed

+98
-0
lines changed

src/routes/object/getObject.ts

+24
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,17 @@ const getObjectParamsSchema = {
2727
},
2828
required: ['bucketName', '*'],
2929
} as const
30+
31+
const getObjectQuerySchema = {
32+
type: 'object',
33+
properties: {
34+
download: { type: 'string', examples: ['filename.jpg', null] },
35+
},
36+
} as const
37+
3038
interface getObjectRequestInterface extends AuthenticatedRangeRequest {
3139
Params: FromSchema<typeof getObjectParamsSchema>
40+
Querystring: FromSchema<typeof getObjectQuerySchema>
3241
}
3342

3443
async function requestHandler(
@@ -42,6 +51,7 @@ async function requestHandler(
4251
>
4352
) {
4453
const { bucketName } = request.params
54+
const { download } = request.query
4555
const objectName = request.params['*']
4656

4757
if (!isValidKey(objectName) || !isValidKey(bucketName)) {
@@ -86,6 +96,20 @@ async function requestHandler(
8696
if (data.metadata.contentRange) {
8797
response.header('Content-Range', data.metadata.contentRange)
8898
}
99+
100+
if (typeof download !== 'undefined') {
101+
if (download === '') {
102+
response.header('Content-Disposition', 'attachment;')
103+
} else {
104+
const encodedFileName = encodeURIComponent(download)
105+
106+
response.header(
107+
'Content-Disposition',
108+
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
109+
)
110+
}
111+
}
112+
89113
return response.send(data.body)
90114
} catch (err: any) {
91115
if (err.$metadata?.httpStatusCode === 304) {

src/routes/object/getPublicObject.ts

+24
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,20 @@ const getPublicObjectParamsSchema = {
2525
},
2626
required: ['bucketName', '*'],
2727
} as const
28+
29+
const getObjectQuerySchema = {
30+
type: 'object',
31+
properties: {
32+
download: { type: 'string', examples: ['filename.jpg', null] },
33+
},
34+
} as const
35+
2836
interface getObjectRequestInterface {
2937
Params: FromSchema<typeof getPublicObjectParamsSchema>
3038
Headers: {
3139
range?: string
3240
}
41+
Querystring: FromSchema<typeof getObjectQuerySchema>
3342
}
3443

3544
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
@@ -49,6 +58,7 @@ export default async function routes(fastify: FastifyInstance) {
4958
async (request, response) => {
5059
const { bucketName } = request.params
5160
const objectName = request.params['*']
61+
const { download } = request.query
5262

5363
const { error, status } = await request.superUserPostgrest
5464
.from<Bucket>('buckets')
@@ -81,6 +91,20 @@ export default async function routes(fastify: FastifyInstance) {
8191
if (data.metadata.contentRange) {
8292
response.header('Content-Range', data.metadata.contentRange)
8393
}
94+
95+
if (typeof download !== 'undefined') {
96+
if (download === '') {
97+
response.header('Content-Disposition', 'attachment;')
98+
} else {
99+
const encodedFileName = encodeURIComponent(download)
100+
101+
response.header(
102+
'Content-Disposition',
103+
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
104+
)
105+
}
106+
}
107+
84108
return response.send(data.body)
85109
} catch (err: any) {
86110
if (err.$metadata?.httpStatusCode === 304) {

src/routes/object/getSignedObject.ts

+18
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ const getSignedObjectParamsSchema = {
2626
},
2727
required: ['bucketName', '*'],
2828
} as const
29+
2930
const getSignedObjectQSSchema = {
3031
type: 'object',
3132
properties: {
33+
download: { type: 'string', examples: ['filename.jpg', null] },
3234
token: {
3335
type: 'string',
3436
examples: [
@@ -64,6 +66,8 @@ export default async function routes(fastify: FastifyInstance) {
6466
},
6567
async (request, response) => {
6668
const { token } = request.query
69+
const { download } = request.query
70+
6771
try {
6872
const jwtSecret = await getJwtSecret(request.tenantId)
6973
const payload = await verifyJWT(token, jwtSecret)
@@ -87,6 +91,20 @@ export default async function routes(fastify: FastifyInstance) {
8791
if (data.metadata.contentRange) {
8892
response.header('Content-Range', data.metadata.contentRange)
8993
}
94+
95+
if (typeof download !== 'undefined') {
96+
if (download === '') {
97+
response.header('Content-Disposition', 'attachment;')
98+
} else {
99+
const encodedFileName = encodeURIComponent(download)
100+
101+
response.header(
102+
'Content-Disposition',
103+
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
104+
)
105+
}
106+
}
107+
90108
return response.send(data.body)
91109
} catch (err: any) {
92110
if (err.$metadata?.httpStatusCode === 304) {

src/test/object.test.ts

+32
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,38 @@ describe('testing GET object', () => {
102102
})
103103
})
104104

105+
test('force downloading file with default name', async () => {
106+
const response = await app().inject({
107+
method: 'GET',
108+
url: '/object/authenticated/bucket2/authenticated/casestudy.png?download',
109+
headers: {
110+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
111+
},
112+
})
113+
expect(S3Backend.prototype.getObject).toBeCalled()
114+
expect(response.headers).toEqual(
115+
expect.objectContaining({
116+
'content-disposition': `attachment;`,
117+
})
118+
)
119+
})
120+
121+
test('force downloading file with a custom name', async () => {
122+
const response = await app().inject({
123+
method: 'GET',
124+
url: '/object/authenticated/bucket2/authenticated/casestudy.png?download=testname.png',
125+
headers: {
126+
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
127+
},
128+
})
129+
expect(S3Backend.prototype.getObject).toBeCalled()
130+
expect(response.headers).toEqual(
131+
expect.objectContaining({
132+
'content-disposition': `attachment; filename=testname.png; filename*=UTF-8''testname.png;`,
133+
})
134+
)
135+
})
136+
105137
test('check if RLS policies are respected: anon user is not able to read authenticated resource', async () => {
106138
const response = await app().inject({
107139
method: 'GET',

0 commit comments

Comments
 (0)