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 1 commit
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
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',
},
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
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
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
}

async getBodyMaxSize(
req: http.IncomingMessage,
info: Upload,
configuredMaxSize?: number
Murderlon marked this conversation as resolved.
Show resolved Hide resolved
) {
configuredMaxSize =
configuredMaxSize ?? (await this.getConfiguredMaxSize(req, info.id))
fenos marked this conversation as resolved.
Show resolved Hide resolved

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
}
Murderlon marked this conversation as resolved.
Show resolved Hide resolved

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 > 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'
fenos marked this conversation as resolved.
Show resolved Hide resolved
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 {
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 {
this.push(chunk)
callback()
}
}
}
7 changes: 7 additions & 0 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,13 @@ export class Server extends EventEmitter {
// @ts-expect-error not explicitly typed but possible
headers['Content-Length'] = Buffer.byteLength(body, 'utf8')
}

if (status === 413) {
// on maxFile exceeded we instruct the client to close the connection
// @ts-expect-error not explicitly typed but possible
headers['Connection'] = 'close'
}

res.writeHead(status, headers)
res.write(body)
return res.end()
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
fenos marked this conversation as resolved.
Show resolved Hide resolved
res = httpMocks.createResponse({req})
})

Expand Down
Loading