Skip to content

Commit

Permalink
feat: Max file size
Browse files Browse the repository at this point in the history
  • Loading branch information
fenos committed Nov 20, 2023
1 parent 612ac24 commit a987021
Show file tree
Hide file tree
Showing 13 changed files with 252 additions and 8 deletions.
2 changes: 1 addition & 1 deletion packages/s3-store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ export class S3Store extends DataStore {

file.size = upload_length

this.saveMetadata(file, uploadId)
return this.saveMetadata(file, uploadId)
}

public async remove(id: string): Promise<void> {
Expand Down
8 changes: 8 additions & 0 deletions packages/server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export const ERRORS = {
status_code: 410,
body: 'The file for this url no longer exists\n',
},
ERR_SIZE_EXCEEDED: {
status_code: 413,
body: "upload's size exceeded\n",
},
ERR_MAX_SIZE_EXCEEDED: {
status_code: 413,
body: 'Maximum size exceeded\n',
},
INVALID_LENGTH: {
status_code: 400,
body: 'Upload-Length or Upload-Defer-Length header required\n',
Expand Down
51 changes: 51 additions & 0 deletions packages/server/src/handlers/BaseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import EventEmitter from 'node:events'
import type {ServerOptions} from '../types'
import type {DataStore} from '../models'
import type http from 'node:http'
import {Upload} from '../models'
import {ERRORS} from '../constants'

const reExtractFileID = /([^/]+)\/?$/
const reForwardedHost = /host="?([^";]+)/
Expand Down Expand Up @@ -78,4 +80,53 @@ export class BaseHandler extends EventEmitter {

return decodeURIComponent(match[1])
}

getConfiguredMaxSize(req: http.IncomingMessage, id: string) {
if (typeof this.options.maxSize === 'function') {
return this.options.maxSize(req, id)
}
return this.options.maxSize ?? 0
}

async getBodyMaxSize(
req: http.IncomingMessage,
info: Upload,
configuredMaxSize?: number
) {
configuredMaxSize =
configuredMaxSize ?? (await this.getConfiguredMaxSize(req, info.id))

const length = parseInt(req.headers['content-length'] || '0', 10)
const offset = info.offset

// Test if this upload fits into the file's size
if (!info.sizeIsDeferred && offset + length > (info.size || 0)) {
throw ERRORS.ERR_SIZE_EXCEEDED
}

let maxSize = (info.size || 0) - offset
// If the upload's length is deferred and the PATCH request does not contain the Content-Length
// header (which is allowed if 'Transfer-Encoding: chunked' is used), we still need to set limits for
// the body size.
if (info.sizeIsDeferred) {
if (configuredMaxSize > 0) {
// Ensure that the upload does not exceed the maximum upload size
maxSize = configuredMaxSize - offset
} else {
// If no upload limit is given, we allow arbitrary sizes
maxSize = Number.MAX_SAFE_INTEGER
}
}

if (length > 0) {
maxSize = length
}

// limit the request body to the maxSize if provided
if (configuredMaxSize > 0 && maxSize > configuredMaxSize) {
maxSize = configuredMaxSize
}

return maxSize
}
}
8 changes: 7 additions & 1 deletion packages/server/src/handlers/OptionsHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import type http from 'node:http'
// A successful response indicated by the 204 No Content status MUST contain
// the Tus-Version header. It MAY include the Tus-Extension and Tus-Max-Size headers.
export class OptionsHandler extends BaseHandler {
async send(_: http.IncomingMessage, res: http.ServerResponse) {
async send(req: http.IncomingMessage, res: http.ServerResponse) {
const maxSize = await this.getConfiguredMaxSize(req, '')

if (maxSize > 0) {
res.setHeader('Tus-Max-Size', maxSize)
}

res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS)
res.setHeader('Access-Control-Allow-Headers', ALLOWED_HEADERS)
res.setHeader('Access-Control-Max-Age', MAX_AGE)
Expand Down
16 changes: 15 additions & 1 deletion packages/server/src/handlers/PatchHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {BaseHandler} from './BaseHandler'
import {ERRORS, EVENTS} from '../constants'

import type http from 'node:http'
import stream from 'node:stream/promises'
import {StreamLimiter} from '../models/StreamLimiter'

const log = debug('tus-node-server:handlers:patch')

Expand Down Expand Up @@ -55,6 +57,8 @@ export class PatchHandler extends BaseHandler {
throw ERRORS.INVALID_OFFSET
}

const maxFileSize = await this.getConfiguredMaxSize(req, id)

// The request MUST validate upload-length related headers
const upload_length = req.headers['upload-length'] as string | undefined
if (upload_length !== undefined) {
Expand All @@ -73,11 +77,21 @@ export class PatchHandler extends BaseHandler {
throw ERRORS.INVALID_LENGTH
}

if (maxFileSize > 0 && size > maxFileSize) {
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
}

await this.store.declareUploadLength(id, size)
upload.size = size
}

const newOffset = await this.store.write(req, id, offset)
let newOffset = 0
const bodyMaxSize = await this.getBodyMaxSize(req, upload, maxFileSize)

await stream.pipeline(req, new StreamLimiter(bodyMaxSize), async (stream) => {
newOffset = await this.store.write(stream as StreamLimiter, id, offset)
})

upload.offset = newOffset
this.emit(EVENTS.POST_RECEIVE, req, res, upload)
if (newOffset === upload.size && this.options.onUploadFinish) {
Expand Down
15 changes: 13 additions & 2 deletions packages/server/src/handlers/PostHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {EVENTS, ERRORS} from '../constants'
import type http from 'node:http'
import type {ServerOptions} from '../types'
import type {DataStore} from '../models'
import stream from 'node:stream/promises'
import {StreamLimiter} from '../models/StreamLimiter'

const log = debug('tus-node-server:handlers:post')

Expand Down Expand Up @@ -75,6 +77,11 @@ export class PostHandler extends BaseHandler {
metadata,
})

const maxFileSize = await this.getConfiguredMaxSize(req, id)
if (maxFileSize > 0 && (upload.size || 0) > maxFileSize) {
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
}

if (this.options.onUploadCreate) {
try {
res = await this.options.onUploadCreate(req, res, upload)
Expand All @@ -89,7 +96,7 @@ export class PostHandler extends BaseHandler {

this.emit(EVENTS.POST_CREATE, req, res, upload, url)

let newOffset
let newOffset = 0
let isFinal = upload.size === 0 && !upload.sizeIsDeferred
const headers: {
'Upload-Offset'?: string
Expand All @@ -98,7 +105,11 @@ export class PostHandler extends BaseHandler {

// The request MIGHT include a Content-Type header when using creation-with-upload extension
if (validateHeader('content-type', req.headers['content-type'])) {
newOffset = await this.store.write(req, upload.id, 0)
const bodyMaxSize = await this.getBodyMaxSize(req, upload, maxFileSize)
await stream.pipeline(req, new StreamLimiter(bodyMaxSize), async (stream) => {
newOffset = await this.store.write(stream as StreamLimiter, upload.id, 0)
})

headers['Upload-Offset'] = newOffset.toString()
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
upload.offset = newOffset
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/models/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import EventEmitter from 'node:events'
import {Upload} from './Upload'

import type stream from 'node:stream'
import type streamP from 'node:stream/promises'
import type http from 'node:http'

export class DataStore extends EventEmitter {
Expand Down
34 changes: 34 additions & 0 deletions packages/server/src/models/StreamLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {Transform, TransformCallback} from 'stream'
import {ERRORS} from '../constants'

export class MaxFileExceededError extends Error {
status_code: number
body: string

constructor() {
super(ERRORS.ERR_MAX_SIZE_EXCEEDED.body)
this.status_code = ERRORS.ERR_MAX_SIZE_EXCEEDED.status_code
this.body = ERRORS.ERR_MAX_SIZE_EXCEEDED.body
Object.setPrototypeOf(this, MaxFileExceededError.prototype)
}
}

export class StreamLimiter extends Transform {
private maxSize: number
private currentSize = 0

constructor(maxSize: number) {
super()
this.maxSize = maxSize
}

_transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
this.currentSize += chunk.length
if (this.currentSize > this.maxSize) {
callback(new MaxFileExceededError())
} else {
this.push(chunk)
callback()
}
}
}
8 changes: 7 additions & 1 deletion packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,13 @@ export class Server extends EventEmitter {
const onError = (error: {status_code?: number; body?: string; message: string}) => {
const status_code = error.status_code || ERRORS.UNKNOWN_ERROR.status_code
const body = error.body || `${ERRORS.UNKNOWN_ERROR.body}${error.message || ''}\n`
return this.write(res, status_code, body)
const writtenResp = this.write(res, status_code, body)

// maxFile Exceeded
if (error.status_code === 413) {
req.destroy()
}
return writtenResp
}

try {
Expand Down
4 changes: 4 additions & 0 deletions packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type {Upload} from './models'
export type ServerOptions = {
// The route to accept requests.
path: string
// Number of
maxSize?:
| number
| ((req: http.IncomingMessage, uploadId: string) => Promise<number> | number)
// Return a relative URL as the `Location` header.
relativeLocation?: boolean
// Allow `Forwarded`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers
Expand Down
62 changes: 61 additions & 1 deletion packages/server/test/PatchHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import httpMocks from 'node-mocks-http'
import {PatchHandler} from '../src/handlers/PatchHandler'
import {Upload, DataStore} from '../src/models'
import {EVENTS} from '../src/constants'
import {MockIncomingMessage} from './utils'
import streamP from 'node:stream/promises'
import stream from 'node:stream'

describe('PatchHandler', () => {
const path = '/test/output'
Expand All @@ -20,7 +23,10 @@ describe('PatchHandler', () => {
beforeEach(() => {
store = sinon.createStubInstance(DataStore)
handler = new PatchHandler(store, {path})
req = {method: 'PATCH', url: `${path}/1234`} as http.IncomingMessage
req = new MockIncomingMessage({
method: 'PATCH',
url: `${path}/1234`,
}) as unknown as http.IncomingMessage
res = httpMocks.createResponse({req})
})

Expand Down Expand Up @@ -165,4 +171,58 @@ describe('PatchHandler', () => {
assert.ok(spy.args[0][1])
assert.equal(spy.args[0][2].offset, 10)
})

it('should throw max size exceeded error when upload-length is higher then the maxSize', async () => {
handler = new PatchHandler(store, {path, maxSize: 5})
req.headers = {
'upload-offset': '0',
'upload-length': '10',
'content-type': 'application/offset+octet-stream',
}
req.url = `${path}/file`

store.hasExtension.withArgs('creation-defer-length').returns(true)
store.getUpload.resolves(new Upload({id: '1234', offset: 0}))
store.write.resolves(5)
store.declareUploadLength.resolves()

try {
await handler.send(req, res)
throw new Error('failed test')
} catch (e) {
assert.equal('body' in e, true)
assert.equal('status_code' in e, true)
assert.equal(e.body, 'Maximum size exceeded\n')
assert.equal(e.status_code, 413)
}
})

it('should throw max size exceeded error when the request body is bigger then the maxSize', async () => {
handler = new PatchHandler(store, {path, maxSize: 5})
req.headers = {
'upload-offset': '0',
'content-type': 'application/offset+octet-stream',
}
req.url = `${path}/file`
;(req as unknown as MockIncomingMessage).addBodyChunk(Buffer.alloc(30))

store.getUpload.resolves(new Upload({id: '1234', offset: 0}))
store.write.callsFake(async (readable: http.IncomingMessage | stream.Readable) => {
const writeStream = new stream.Duplex()
await streamP.pipeline(readable, writeStream)
return writeStream.readableLength
})
store.declareUploadLength.resolves()

try {
await handler.send(req, res)
throw new Error('failed test')
} catch (e) {
assert.equal(e.message !== 'failed test', true, 'failed test')
assert.equal('body' in e, true)
assert.equal('status_code' in e, true)
assert.equal(e.body, 'Maximum size exceeded\n')
assert.equal(e.status_code, 413)
}
})
})
6 changes: 5 additions & 1 deletion packages/server/test/PostHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import sinon from 'sinon'
import {Upload, DataStore} from '../src/models'
import {PostHandler} from '../src/handlers/PostHandler'
import {EVENTS} from '../src/constants'
import {MockIncomingMessage} from './utils'

const SERVER_OPTIONS = {
path: '/test',
Expand All @@ -24,7 +25,10 @@ describe('PostHandler', () => {
fake_store.hasExtension.withArgs('creation-defer-length').returns(true)

beforeEach(() => {
req = {url: '/files', method: 'POST'} as http.IncomingMessage
req = new MockIncomingMessage({
url: '/files',
method: 'POST',
}) as unknown as http.IncomingMessage
res = httpMocks.createResponse({req})
})

Expand Down

0 comments on commit a987021

Please sign in to comment.