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

@tus/server: support Tus-Max-Size #517

Merged
merged 4 commits into from
Dec 21, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ Creates a new tus server with options.

The route to accept requests (`string`).

#### `options.maxSize`

Max file size (in bytes) allowed when uploading (`number`)
fenos marked this conversation as resolved.
Show resolved Hide resolved

#### `options.relativeLocation`

Return a relative URL as the `Location` header to the client (`boolean`).
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 @@ -57,6 +57,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',
},
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
INVALID_LENGTH: {
status_code: 400,
body: 'Upload-Length or Upload-Defer-Length header required\n',
Expand Down
86 changes: 82 additions & 4 deletions packages/server/src/handlers/BaseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import EventEmitter from 'node:events'
import type {ServerOptions} from '../types'
import type {DataStore, CancellationContext} from '../models'
import type http from 'node:http'
import stream from 'node:stream'
import {Upload} from '../models'
import {ERRORS} from '../constants'
import stream from 'node:stream/promises'
import {addAbortSignal, PassThrough} from 'stream'
import {StreamLimiter} from '../models/StreamLimiter'

const reExtractFileID = /([^/]+)\/?$/
const reForwardedHost = /host="?([^";]+)/
Expand Down Expand Up @@ -132,6 +135,7 @@ export class BaseHandler extends EventEmitter {
req: http.IncomingMessage,
id: string,
offset: number,
maxFileSize: number,
context: CancellationContext
) {
return new Promise<number>(async (resolve, reject) => {
Expand All @@ -140,8 +144,8 @@ export class BaseHandler extends EventEmitter {
return
}

const proxy = new stream.PassThrough()
stream.addAbortSignal(context.signal, proxy)
const proxy = new PassThrough()
addAbortSignal(context.signal, proxy)

proxy.on('error', (err) => {
req.unpipe(proxy)
Expand All @@ -158,7 +162,81 @@ export class BaseHandler extends EventEmitter {
}
})

this.store.write(req.pipe(proxy), id, offset).then(resolve).catch(reject)
stream
.pipeline(req.pipe(proxy), new StreamLimiter(maxFileSize), async (stream) => {
fenos marked this conversation as resolved.
Show resolved Hide resolved
return this.store.write(stream as StreamLimiter, id, offset)
})
.then(resolve)
.catch(reject)
})
}

getConfiguredMaxSize(req: http.IncomingMessage, id: string) {
if (typeof this.options.maxSize === 'function') {
return this.options.maxSize(req, id)
}
return this.options.maxSize ?? 0
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
}

/**
* Calculates the maximum allowed size for the body of an upload request.
* This function considers both the server's configured maximum size and
* the specifics of the upload, such as whether the size is deferred or fixed.
*/
async calculateMaxBodySize(
fenos marked this conversation as resolved.
Show resolved Hide resolved
req: http.IncomingMessage,
file: Upload,
configuredMaxSize?: number
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
) {
// Use the server-configured maximum size if it's not explicitly provided.
configuredMaxSize ??= await this.getConfiguredMaxSize(req, file.id)

// Parse the Content-Length header from the request (default to 0 if not set).
const length = parseInt(req.headers['content-length'] || '0', 10)
const offset = file.offset

const hasContentLengthSet = length > 0
fenos marked this conversation as resolved.
Show resolved Hide resolved
const hasConfiguredMaxSizeSet = configuredMaxSize > 0

// Check if the upload fits into the file's size when the size is not deferred.
if (!file.sizeIsDeferred && offset + length > (file.size || 0)) {
throw ERRORS.ERR_SIZE_EXCEEDED
}

// For deferred size uploads, if it's not a chunked transfer, check against the configured maximum size.
if (
file.sizeIsDeferred &&
hasContentLengthSet &&
hasConfiguredMaxSizeSet &&
offset + length > configuredMaxSize
) {
throw ERRORS.ERR_SIZE_EXCEEDED
}

// Calculate the maximum allowable size based on the file's total size and current offset.
let maxSize = (file.size || 0) - offset

// Handle deferred size uploads where no Content-Length is provided (possible with chunked transfers).
if (file.sizeIsDeferred) {
if (hasConfiguredMaxSizeSet) {
// Limit the upload to not exceed the maximum configured upload size.
maxSize = configuredMaxSize - offset
} else {
// Allow arbitrary sizes if no upload limit is configured.
maxSize = Number.MAX_SAFE_INTEGER
}
}

// Override maxSize with the Content-Length if it's provided.
if (hasContentLengthSet) {
maxSize = length
}

// Enforce the server-configured maximum size limit, if applicable.
if (hasConfiguredMaxSizeSet && 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, '')
fenos marked this conversation as resolved.
Show resolved Hide resolved

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

const allowedHeaders = [...HEADERS, ...(this.options.allowedHeaders ?? [])]

res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS)
Expand Down
9 changes: 8 additions & 1 deletion packages/server/src/handlers/PatchHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export class PatchHandler extends BaseHandler {
throw ERRORS.INVALID_CONTENT_TYPE
}

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

const lock = await this.acquireLock(req, id, context)

let upload: Upload
Expand Down Expand Up @@ -86,11 +88,16 @@ 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
}

newOffset = await this.writeToStore(req, id, offset, context)
const maxBodySize = await this.calculateMaxBodySize(req, upload, maxFileSize)
newOffset = await this.writeToStore(req, id, offset, maxBodySize, context)
} finally {
await lock.unlock()
}
Expand Down
6 changes: 5 additions & 1 deletion packages/server/src/handlers/PostHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,16 @@ export class PostHandler extends BaseHandler {
}
}

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

let isFinal: boolean
let url: string
let headers: {
'Upload-Offset'?: string
'Upload-Expires'?: string
}

try {
await this.store.create(upload)
url = this.generateUrl(req, upload.id)
Expand All @@ -109,7 +112,8 @@ 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'])) {
const newOffset = await this.writeToStore(req, id, 0, context)
const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize)
const newOffset = await this.writeToStore(req, id, 0, bodyMaxSize, context)

headers['Upload-Offset'] = newOffset.toString()
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
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'

// TODO: create HttpError and use it everywhere instead of throwing objects
export class MaxFileExceededError extends Error {
fenos marked this conversation as resolved.
Show resolved Hide resolved
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)
}
}
Murderlon marked this conversation as resolved.
Show resolved Hide resolved

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 {
callback(null, chunk)
}
}
}
7 changes: 7 additions & 0 deletions packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ export type ServerOptions = {
*/
path: string

/**
* Max file size allowed when uploading
*/
maxSize?:
| number
| ((req: http.IncomingMessage, uploadId: string) => Promise<number> | number)

/**
* Return a relative URL as the `Location` header.
*/
Expand Down
64 changes: 64 additions & 0 deletions packages/server/test/PatchHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {EVENTS} from '../src/constants'
import {EventEmitter} from 'node:events'
import {addPipableStreamBody} from './utils'
import {MemoryLocker} from '../src'
import streamP from 'node:stream/promises'
import stream from 'node:stream'

describe('PatchHandler', () => {
const path = '/test/output'
Expand Down Expand Up @@ -182,4 +184,66 @@ 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, locker: new MemoryLocker()})
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, context)
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, locker: new MemoryLocker()})
const req = addPipableStreamBody(
httpMocks.createRequest({
method: 'PATCH',
url: `${path}/1234`,
body: Buffer.alloc(30),
})
)
const res = httpMocks.createResponse({req})
req.headers = {
'upload-offset': '0',
'content-type': 'application/offset+octet-stream',
}
req.url = `${path}/file`

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

try {
await handler.send(req, res, context)
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)
assert.equal(context.signal.aborted, true)
}
})
})
18 changes: 14 additions & 4 deletions packages/server/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@
mockRequest: T
) {
// Create a Readable stream that simulates the request body
const bodyStream = new stream.Readable({
const bodyStream = new stream.Duplex({
read() {
this.push(JSON.stringify(mockRequest.body))
this.push(
mockRequest.body instanceof Buffer
? mockRequest.body
: JSON.stringify(mockRequest.body)
)
this.push(null)
},
})

// Add the pipe method to the mockRequest
// @ts-expect-error pipe exists
// @ts-ignore

Check warning on line 20 in packages/server/test/utils.ts

View workflow job for this annotation

GitHub Actions / lts/hydrogen

Do not use "@ts-ignore" because it alters compilation errors

Check warning on line 20 in packages/server/test/utils.ts

View workflow job for this annotation

GitHub Actions / node

Do not use "@ts-ignore" because it alters compilation errors
mockRequest.pipe = function (dest: stream.Writable) {
bodyStream.pipe(dest)
return bodyStream.pipe(dest)
}

// Add the unpipe method to the mockRequest
// @ts-ignore

Check warning on line 26 in packages/server/test/utils.ts

View workflow job for this annotation

GitHub Actions / lts/hydrogen

Do not use "@ts-ignore" because it alters compilation errors

Check warning on line 26 in packages/server/test/utils.ts

View workflow job for this annotation

GitHub Actions / node

Do not use "@ts-ignore" because it alters compilation errors
mockRequest.unpipe = function (dest: stream.Writable) {
return bodyStream.unpipe(dest)
}
return mockRequest
}