Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/little-balloons-sort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tus/server": minor
---

add Content-Type and Content-Disposition headers on GetHandler.send response
87 changes: 85 additions & 2 deletions packages/server/src/handlers/GetHandler.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,38 @@
import stream from 'node:stream'

import {BaseHandler} from './BaseHandler'
import {ERRORS} from '@tus/utils'
import {ERRORS, Upload} from '@tus/utils'

import type http from 'node:http'
import type {RouteHandler} from '../types'

export class GetHandler extends BaseHandler {
paths: Map<string, RouteHandler> = new Map()

reMimeType = /^[a-z]+\/[a-z0-9\-\+\.]+$/

mimeInlineBrowserWhitelist = new Set([
'text/plain',

'image/png',
'image/jpeg',
'image/gif',
'image/bmp',
'image/webp',

'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wav',
'audio/webm',
'audio/ogg',

'video/webm',
'video/ogg',

'application/ogg',
])

registerPath(path: string, handler: RouteHandler): void {
this.paths.set(path, handler)
}
Expand Down Expand Up @@ -45,12 +69,71 @@ export class GetHandler extends BaseHandler {
throw ERRORS.FILE_NOT_FOUND
}

const {contentType, contentDisposition} = this.filterContentType(stats)

// @ts-expect-error exists if supported
const file_stream = await this.store.read(id)
const headers = {'Content-Length': stats.offset}
const headers = {
'Content-Length': stats.offset,
'Content-Type': contentType,
'Content-Disposition': contentDisposition,
}
res.writeHead(200, headers)
return stream.pipeline(file_stream, res, () => {
// We have no need to handle streaming errors
})
}

/**
* filterContentType returns the values for the Content-Type and
* Content-Disposition headers for a given upload. These values should be used
* in responses for GET requests to ensure that only non-malicious file types
* are shown directly in the browser. It will extract the file name and type
* from the "filename" and "filetype".
* See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
*/
filterContentType(stats: Upload): {
contentType: string
contentDisposition: string
} {
let contentType: string
let contentDisposition: string

const {filetype, filename} = stats.metadata ?? {}

if (filetype && this.reMimeType.test(filetype)) {
// If the filetype from metadata is well formed, we forward use this
// for the Content-Type header. However, only whitelisted mime types
// will be allowed to be shown inline in the browser
contentType = filetype

if (this.mimeInlineBrowserWhitelist.has(filetype)) {
contentDisposition = 'inline'
} else {
contentDisposition = 'attachment'
}
} else {
// If the filetype from the metadata is not well formed, we use a
// default type and force the browser to download the content
contentType = 'application/octet-stream'
contentDisposition = 'attachment'
}

// Add a filename to Content-Disposition if one is available in the metadata
if (filename) {
contentDisposition += `; filename=${this.quote(filename)}`
}

return {
contentType,
contentDisposition,
}
}

/**
* Convert string to quoted string literals
*/
quote(value: string) {
return `"${value.replace(/"/g, '\\"')}"`
}
}
97 changes: 97 additions & 0 deletions packages/server/test/GetHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,108 @@ describe('GetHandler', () => {
assert.equal(res.statusCode, 200)
// TODO: this is the get handler but Content-Length is only send in 204 OPTIONS requests?
// assert.equal(res.getHeader('Content-Length'), size)

assert.equal(res.getHeader('Content-Type'), 'application/octet-stream')
assert.equal(res.getHeader('Content-Disposition'), 'attachment')

assert.equal(store.getUpload.calledOnceWith(fileId), true)
assert.equal(store.read.calledOnceWith(fileId), true)
})
})

describe('filterContentType', () => {
it('should return default headers value without metadata', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)
const size = 512
const upload = new Upload({id: '1234', offset: size, size})

const res = handler.filterContentType(upload)

assert.deepEqual(res, {
contentType: 'application/octet-stream',
contentDisposition: 'attachment',
})
})

it('should return headers allow render in browser when filetype is in whitelist', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)
const size = 512
const upload = new Upload({
id: '1234',
offset: size,
size,
metadata: {filetype: 'image/png', filename: 'pet.png'},
})

const res = handler.filterContentType(upload)

assert.deepEqual(res, {
contentType: 'image/png',
contentDisposition: 'inline; filename="pet.png"',
})
})

it('should return headers force download when filetype is not in whitelist', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)
const size = 512
const upload = new Upload({
id: '1234',
offset: size,
size,
metadata: {filetype: 'application/zip', filename: 'pets.zip'},
})

const res = handler.filterContentType(upload)

assert.deepEqual(res, {
contentType: 'application/zip',
contentDisposition: 'attachment; filename="pets.zip"',
})
})

it('should return headers when filetype is not a valid form', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)
const size = 512
const upload = new Upload({
id: '1234',
offset: size,
size,
metadata: {filetype: 'image_png', filename: 'pet.png'},
})

const res = handler.filterContentType(upload)

assert.deepEqual(res, {
contentType: 'application/octet-stream',
contentDisposition: 'attachment; filename="pet.png"',
})
})
})

describe('quote', () => {
it('should return simple quoted string', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)

const res = handler.quote('pet.png')

assert.equal(res, '"pet.png"')
})

it('should return quoted string when include quotes', () => {
const fakeStore = sinon.stub(new DataStore())
const handler = new GetHandler(fakeStore, serverOptions)

const res = handler.quote('"pet.png"')

assert.equal(res, '"\\"pet.png\\""')
})
})

describe('registerPath()', () => {
it('should call registered path handler', async () => {
const fakeStore = sinon.stub(new DataStore())
Expand Down
Loading