Skip to content

Commit aafb40d

Browse files
authored
@tus/server: support Tus-Max-Size (#517)
1 parent ba8ef31 commit aafb40d

File tree

11 files changed

+489
-16
lines changed

11 files changed

+489
-16
lines changed

packages/server/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,11 @@ Creates a new tus server with options.
6060

6161
The route to accept requests (`string`).
6262

63+
#### `options.maxSize`
64+
65+
Max file size (in bytes) allowed when uploading (`number` | (`(req, id: string | null) => Promise<number> | number`)).
66+
When providing a function during the OPTIONS request the id will be `null`.
67+
6368
#### `options.relativeLocation`
6469

6570
Return a relative URL as the `Location` header to the client (`boolean`).

packages/server/src/constants.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ export const ERRORS = {
5757
status_code: 410,
5858
body: 'The file for this url no longer exists\n',
5959
},
60+
ERR_SIZE_EXCEEDED: {
61+
status_code: 413,
62+
body: "upload's size exceeded\n",
63+
},
64+
ERR_MAX_SIZE_EXCEEDED: {
65+
status_code: 413,
66+
body: 'Maximum size exceeded\n',
67+
},
6068
INVALID_LENGTH: {
6169
status_code: 400,
6270
body: 'Upload-Length or Upload-Defer-Length header required\n',

packages/server/src/handlers/BaseHandler.ts

Lines changed: 76 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import EventEmitter from 'node:events'
33
import type {ServerOptions} from '../types'
44
import type {DataStore, CancellationContext} from '../models'
55
import type http from 'node:http'
6-
import stream from 'node:stream'
6+
import {Upload} from '../models'
77
import {ERRORS} from '../constants'
8+
import stream from 'node:stream/promises'
9+
import {addAbortSignal, PassThrough} from 'stream'
10+
import {StreamLimiter} from '../models/StreamLimiter'
811

912
const reExtractFileID = /([^/]+)\/?$/
1013
const reForwardedHost = /host="?([^";]+)/
@@ -132,24 +135,24 @@ export class BaseHandler extends EventEmitter {
132135
req: http.IncomingMessage,
133136
id: string,
134137
offset: number,
138+
maxFileSize: number,
135139
context: CancellationContext
136140
) {
137141
return new Promise<number>(async (resolve, reject) => {
142+
// Abort early if the operation has been cancelled.
138143
if (context.signal.aborted) {
139144
reject(ERRORS.ABORTED)
140145
return
141146
}
142147

143-
const proxy = new stream.PassThrough()
144-
stream.addAbortSignal(context.signal, proxy)
148+
// Create a PassThrough stream as a proxy to manage the request stream.
149+
// This allows for aborting the write process without affecting the incoming request stream.
150+
const proxy = new PassThrough()
151+
addAbortSignal(context.signal, proxy)
145152

146153
proxy.on('error', (err) => {
147154
req.unpipe(proxy)
148-
if (err.name === 'AbortError') {
149-
reject(ERRORS.ABORTED)
150-
} else {
151-
reject(err)
152-
}
155+
reject(err.name === 'AbortError' ? ERRORS.ABORTED : err)
153156
})
154157

155158
req.on('error', (err) => {
@@ -158,7 +161,71 @@ export class BaseHandler extends EventEmitter {
158161
}
159162
})
160163

161-
this.store.write(req.pipe(proxy), id, offset).then(resolve).catch(reject)
164+
// Pipe the request stream through the proxy. We use the proxy instead of the request stream directly
165+
// to ensure that errors in the pipeline do not cause the request stream to be destroyed,
166+
// which would result in a socket hangup error for the client.
167+
stream
168+
.pipeline(req.pipe(proxy), new StreamLimiter(maxFileSize), async (stream) => {
169+
return this.store.write(stream as StreamLimiter, id, offset)
170+
})
171+
.then(resolve)
172+
.catch(reject)
162173
})
163174
}
175+
176+
getConfiguredMaxSize(req: http.IncomingMessage, id: string | null) {
177+
if (typeof this.options.maxSize === 'function') {
178+
return this.options.maxSize(req, id)
179+
}
180+
return this.options.maxSize ?? 0
181+
}
182+
183+
/**
184+
* Calculates the maximum allowed size for the body of an upload request.
185+
* This function considers both the server's configured maximum size and
186+
* the specifics of the upload, such as whether the size is deferred or fixed.
187+
*/
188+
async calculateMaxBodySize(
189+
req: http.IncomingMessage,
190+
file: Upload,
191+
configuredMaxSize?: number
192+
) {
193+
// Use the server-configured maximum size if it's not explicitly provided.
194+
configuredMaxSize ??= await this.getConfiguredMaxSize(req, file.id)
195+
196+
// Parse the Content-Length header from the request (default to 0 if not set).
197+
const length = parseInt(req.headers['content-length'] || '0', 10)
198+
const offset = file.offset
199+
200+
const hasContentLengthSet = req.headers['content-length'] !== undefined
201+
const hasConfiguredMaxSizeSet = configuredMaxSize > 0
202+
203+
if (file.sizeIsDeferred) {
204+
// For deferred size uploads, if it's not a chunked transfer, check against the configured maximum size.
205+
if (
206+
hasContentLengthSet &&
207+
hasConfiguredMaxSizeSet &&
208+
offset + length > configuredMaxSize
209+
) {
210+
throw ERRORS.ERR_SIZE_EXCEEDED
211+
}
212+
213+
if (hasConfiguredMaxSizeSet) {
214+
return configuredMaxSize - offset
215+
} else {
216+
return Number.MAX_SAFE_INTEGER
217+
}
218+
}
219+
220+
// Check if the upload fits into the file's size when the size is not deferred.
221+
if (offset + length > (file.size || 0)) {
222+
throw ERRORS.ERR_SIZE_EXCEEDED
223+
}
224+
225+
if (hasContentLengthSet) {
226+
return length
227+
}
228+
229+
return (file.size || 0) - offset
230+
}
164231
}

packages/server/src/handlers/OptionsHandler.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import type http from 'node:http'
66
// A successful response indicated by the 204 No Content status MUST contain
77
// the Tus-Version header. It MAY include the Tus-Extension and Tus-Max-Size headers.
88
export class OptionsHandler extends BaseHandler {
9-
async send(_: http.IncomingMessage, res: http.ServerResponse) {
9+
async send(req: http.IncomingMessage, res: http.ServerResponse) {
10+
const maxSize = await this.getConfiguredMaxSize(req, null)
11+
12+
if (maxSize) {
13+
res.setHeader('Tus-Max-Size', maxSize)
14+
}
15+
1016
const allowedHeaders = [...HEADERS, ...(this.options.allowedHeaders ?? [])]
1117

1218
res.setHeader('Access-Control-Allow-Methods', ALLOWED_METHODS)

packages/server/src/handlers/PatchHandler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ export class PatchHandler extends BaseHandler {
4040
await this.options.onIncomingRequest(req, res, id)
4141
}
4242

43+
const maxFileSize = await this.getConfiguredMaxSize(req, id)
44+
4345
const lock = await this.acquireLock(req, id, context)
4446

4547
let upload: Upload
@@ -90,11 +92,16 @@ export class PatchHandler extends BaseHandler {
9092
throw ERRORS.INVALID_LENGTH
9193
}
9294

95+
if (maxFileSize > 0 && size > maxFileSize) {
96+
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
97+
}
98+
9399
await this.store.declareUploadLength(id, size)
94100
upload.size = size
95101
}
96102

97-
newOffset = await this.writeToStore(req, id, offset, context)
103+
const maxBodySize = await this.calculateMaxBodySize(req, upload, maxFileSize)
104+
newOffset = await this.writeToStore(req, id, offset, maxBodySize, context)
98105
} finally {
99106
await lock.unlock()
100107
}

packages/server/src/handlers/PostHandler.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ export class PostHandler extends BaseHandler {
6262
throw ERRORS.FILE_WRITE_ERROR
6363
}
6464

65+
const maxFileSize = await this.getConfiguredMaxSize(req, id)
66+
67+
if (
68+
upload_length &&
69+
maxFileSize > 0 &&
70+
Number.parseInt(upload_length, 10) > maxFileSize
71+
) {
72+
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
73+
}
74+
6575
let metadata
6676
if ('upload-metadata' in req.headers) {
6777
try {
@@ -92,12 +102,14 @@ export class PostHandler extends BaseHandler {
92102
}
93103

94104
const lock = await this.acquireLock(req, id, context)
105+
95106
let isFinal: boolean
96107
let url: string
97108
let headers: {
98109
'Upload-Offset'?: string
99110
'Upload-Expires'?: string
100111
}
112+
101113
try {
102114
await this.store.create(upload)
103115
url = this.generateUrl(req, upload.id)
@@ -109,7 +121,8 @@ export class PostHandler extends BaseHandler {
109121

110122
// The request MIGHT include a Content-Type header when using creation-with-upload extension
111123
if (validateHeader('content-type', req.headers['content-type'])) {
112-
const newOffset = await this.writeToStore(req, id, 0, context)
124+
const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize)
125+
const newOffset = await this.writeToStore(req, id, 0, bodyMaxSize, context)
113126

114127
headers['Upload-Offset'] = newOffset.toString()
115128
isFinal = newOffset === Number.parseInt(upload_length as string, 10)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import {Transform, TransformCallback} from 'stream'
2+
import {ERRORS} from '../constants'
3+
4+
// TODO: create HttpError and use it everywhere instead of throwing objects
5+
export class MaxFileExceededError extends Error {
6+
status_code: number
7+
body: string
8+
9+
constructor() {
10+
super(ERRORS.ERR_MAX_SIZE_EXCEEDED.body)
11+
this.status_code = ERRORS.ERR_MAX_SIZE_EXCEEDED.status_code
12+
this.body = ERRORS.ERR_MAX_SIZE_EXCEEDED.body
13+
Object.setPrototypeOf(this, MaxFileExceededError.prototype)
14+
}
15+
}
16+
17+
export class StreamLimiter extends Transform {
18+
private maxSize: number
19+
private currentSize = 0
20+
21+
constructor(maxSize: number) {
22+
super()
23+
this.maxSize = maxSize
24+
}
25+
26+
_transform(chunk: Buffer, encoding: BufferEncoding, callback: TransformCallback): void {
27+
this.currentSize += chunk.length
28+
if (this.currentSize > this.maxSize) {
29+
callback(new MaxFileExceededError())
30+
} else {
31+
callback(null, chunk)
32+
}
33+
}
34+
}

packages/server/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ export type ServerOptions = {
1111
*/
1212
path: string
1313

14+
/**
15+
* Max file size allowed when uploading
16+
*/
17+
maxSize?:
18+
| number
19+
| ((req: http.IncomingMessage, uploadId: string | null) => Promise<number> | number)
20+
1421
/**
1522
* Return a relative URL as the `Location` header.
1623
*/

packages/server/test/PatchHandler.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {EVENTS} from '../src/constants'
1212
import {EventEmitter} from 'node:events'
1313
import {addPipableStreamBody} from './utils'
1414
import {MemoryLocker} from '../src'
15+
import streamP from 'node:stream/promises'
16+
import stream from 'node:stream'
1517

1618
describe('PatchHandler', () => {
1719
const path = '/test/output'
@@ -182,4 +184,66 @@ describe('PatchHandler', () => {
182184
assert.ok(spy.args[0][1])
183185
assert.equal(spy.args[0][2].offset, 10)
184186
})
187+
188+
it('should throw max size exceeded error when upload-length is higher then the maxSize', async () => {
189+
handler = new PatchHandler(store, {path, maxSize: 5, locker: new MemoryLocker()})
190+
req.headers = {
191+
'upload-offset': '0',
192+
'upload-length': '10',
193+
'content-type': 'application/offset+octet-stream',
194+
}
195+
req.url = `${path}/file`
196+
197+
store.hasExtension.withArgs('creation-defer-length').returns(true)
198+
store.getUpload.resolves(new Upload({id: '1234', offset: 0}))
199+
store.write.resolves(5)
200+
store.declareUploadLength.resolves()
201+
202+
try {
203+
await handler.send(req, res, context)
204+
throw new Error('failed test')
205+
} catch (e) {
206+
assert.equal('body' in e, true)
207+
assert.equal('status_code' in e, true)
208+
assert.equal(e.body, 'Maximum size exceeded\n')
209+
assert.equal(e.status_code, 413)
210+
}
211+
})
212+
213+
it('should throw max size exceeded error when the request body is bigger then the maxSize', async () => {
214+
handler = new PatchHandler(store, {path, maxSize: 5, locker: new MemoryLocker()})
215+
const req = addPipableStreamBody(
216+
httpMocks.createRequest({
217+
method: 'PATCH',
218+
url: `${path}/1234`,
219+
body: Buffer.alloc(30),
220+
})
221+
)
222+
const res = httpMocks.createResponse({req})
223+
req.headers = {
224+
'upload-offset': '0',
225+
'content-type': 'application/offset+octet-stream',
226+
}
227+
req.url = `${path}/file`
228+
229+
store.getUpload.resolves(new Upload({id: '1234', offset: 0}))
230+
store.write.callsFake(async (readable: http.IncomingMessage | stream.Readable) => {
231+
const writeStream = new stream.PassThrough()
232+
await streamP.pipeline(readable, writeStream)
233+
return writeStream.readableLength
234+
})
235+
store.declareUploadLength.resolves()
236+
237+
try {
238+
await handler.send(req, res, context)
239+
throw new Error('failed test')
240+
} catch (e) {
241+
assert.equal(e.message !== 'failed test', true, 'failed test')
242+
assert.equal('body' in e, true)
243+
assert.equal('status_code' in e, true)
244+
assert.equal(e.body, 'Maximum size exceeded\n')
245+
assert.equal(e.status_code, 413)
246+
assert.equal(context.signal.aborted, true)
247+
}
248+
})
185249
})

packages/server/test/utils.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,27 @@ export function addPipableStreamBody<T extends httpMocks.MockRequest<unknown>>(
55
mockRequest: T
66
) {
77
// Create a Readable stream that simulates the request body
8-
const bodyStream = new stream.Readable({
8+
const bodyStream = new stream.Duplex({
99
read() {
10-
this.push(JSON.stringify(mockRequest.body))
10+
this.push(
11+
mockRequest.body instanceof Buffer
12+
? mockRequest.body
13+
: JSON.stringify(mockRequest.body)
14+
)
1115
this.push(null)
1216
},
1317
})
1418

1519
// Add the pipe method to the mockRequest
16-
// @ts-expect-error pipe exists
20+
// @ts-ignore
1721
mockRequest.pipe = function (dest: stream.Writable) {
18-
bodyStream.pipe(dest)
22+
return bodyStream.pipe(dest)
23+
}
24+
25+
// Add the unpipe method to the mockRequest
26+
// @ts-ignore
27+
mockRequest.unpipe = function (dest: stream.Writable) {
28+
return bodyStream.unpipe(dest)
1929
}
2030
return mockRequest
2131
}

0 commit comments

Comments
 (0)