Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: force downloading file when providing filename #195

Merged
merged 1 commit into from
Sep 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/routes/object/getObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,17 @@ const getObjectParamsSchema = {
},
required: ['bucketName', '*'],
} as const

const getObjectQuerySchema = {
type: 'object',
properties: {
download: { type: 'string', examples: ['filename.jpg', null] },
},
} as const

interface getObjectRequestInterface extends AuthenticatedRangeRequest {
Params: FromSchema<typeof getObjectParamsSchema>
Querystring: FromSchema<typeof getObjectQuerySchema>
}

async function requestHandler(
Expand All @@ -42,6 +51,7 @@ async function requestHandler(
>
) {
const { bucketName } = request.params
const { download } = request.query
const objectName = request.params['*']

if (!isValidKey(objectName) || !isValidKey(bucketName)) {
Expand Down Expand Up @@ -86,6 +96,20 @@ async function requestHandler(
if (data.metadata.contentRange) {
response.header('Content-Range', data.metadata.contentRange)
}

if (typeof download !== 'undefined') {
if (download === '') {
response.header('Content-Disposition', 'attachment;')
} else {
const encodedFileName = encodeURIComponent(download)

response.header(
'Content-Disposition',
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
)
}
}

return response.send(data.body)
} catch (err: any) {
if (err.$metadata?.httpStatusCode === 304) {
Expand Down
24 changes: 24 additions & 0 deletions src/routes/object/getPublicObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,20 @@ const getPublicObjectParamsSchema = {
},
required: ['bucketName', '*'],
} as const

const getObjectQuerySchema = {
type: 'object',
properties: {
download: { type: 'string', examples: ['filename.jpg', null] },
},
} as const

interface getObjectRequestInterface {
Params: FromSchema<typeof getPublicObjectParamsSchema>
Headers: {
range?: string
}
Querystring: FromSchema<typeof getObjectQuerySchema>
}

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

const { error, status } = await request.superUserPostgrest
.from<Bucket>('buckets')
Expand Down Expand Up @@ -81,6 +91,20 @@ export default async function routes(fastify: FastifyInstance) {
if (data.metadata.contentRange) {
response.header('Content-Range', data.metadata.contentRange)
}

if (typeof download !== 'undefined') {
if (download === '') {
response.header('Content-Disposition', 'attachment;')
} else {
const encodedFileName = encodeURIComponent(download)

response.header(
'Content-Disposition',
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
)
}
}

return response.send(data.body)
} catch (err: any) {
if (err.$metadata?.httpStatusCode === 304) {
Expand Down
18 changes: 18 additions & 0 deletions src/routes/object/getSignedObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ const getSignedObjectParamsSchema = {
},
required: ['bucketName', '*'],
} as const

const getSignedObjectQSSchema = {
type: 'object',
properties: {
download: { type: 'string', examples: ['filename.jpg', null] },
token: {
type: 'string',
examples: [
Expand Down Expand Up @@ -64,6 +66,8 @@ export default async function routes(fastify: FastifyInstance) {
},
async (request, response) => {
const { token } = request.query
const { download } = request.query

try {
const jwtSecret = await getJwtSecret(request.tenantId)
const payload = await verifyJWT(token, jwtSecret)
Expand All @@ -87,6 +91,20 @@ export default async function routes(fastify: FastifyInstance) {
if (data.metadata.contentRange) {
response.header('Content-Range', data.metadata.contentRange)
}

if (typeof download !== 'undefined') {
if (download === '') {
response.header('Content-Disposition', 'attachment;')
} else {
const encodedFileName = encodeURIComponent(download)

response.header(
'Content-Disposition',
`attachment; filename=${encodedFileName}; filename*=UTF-8''${encodedFileName};`
)
}
}

return response.send(data.body)
} catch (err: any) {
if (err.$metadata?.httpStatusCode === 304) {
Expand Down
32 changes: 32 additions & 0 deletions src/test/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ describe('testing GET object', () => {
})
})

test('force downloading file with default name', async () => {
const response = await app().inject({
method: 'GET',
url: '/object/authenticated/bucket2/authenticated/casestudy.png?download',
headers: {
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
},
})
expect(S3Backend.prototype.getObject).toBeCalled()
expect(response.headers).toEqual(
expect.objectContaining({
'content-disposition': `attachment;`,
})
)
})

test('force downloading file with a custom name', async () => {
const response = await app().inject({
method: 'GET',
url: '/object/authenticated/bucket2/authenticated/casestudy.png?download=testname.png',
headers: {
authorization: `Bearer ${process.env.AUTHENTICATED_KEY}`,
},
})
expect(S3Backend.prototype.getObject).toBeCalled()
expect(response.headers).toEqual(
expect.objectContaining({
'content-disposition': `attachment; filename=testname.png; filename*=UTF-8''testname.png;`,
})
)
})

test('check if RLS policies are respected: anon user is not able to read authenticated resource', async () => {
const response = await app().inject({
method: 'GET',
Expand Down