Skip to content

Commit

Permalink
merged: main
Browse files Browse the repository at this point in the history
  • Loading branch information
fenos committed Dec 27, 2023
2 parents 591137e + 093efd7 commit 03f0249
Show file tree
Hide file tree
Showing 18 changed files with 559 additions and 76 deletions.
4 changes: 2 additions & 2 deletions packages/eslint-config-custom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
},
"dependencies": {
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^6.5.0",
"@typescript-eslint/parser": "^6.13.1",
"eslint": "^8.48.0",
"eslint-config-prettier": "^8.10.0",
"eslint-config-turbo": "^1.10.13",
"eslint-config-turbo": "^1.10.16",
"eslint-plugin-prettier": "^4.2.1",
"prettier": "^2.8.8"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/file-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"@tus/server": "workspace:^",
"@types/debug": "^4.1.8",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.7",
"@types/node": "^20.10.4",
"eslint": "^8.48.0",
"eslint-config-custom": "workspace:*",
"mocha": "^10.2.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/gcs-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@tus/server": "workspace:^",
"@types/debug": "^4.1.8",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.7",
"@types/node": "^20.10.4",
"eslint": "^8.48.0",
"eslint-config-custom": "workspace:*",
"mocha": "^10.2.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/s3-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@tus/server": "workspace:^",
"@types/debug": "^4.1.8",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.7",
"@types/node": "^20.10.4",
"eslint": "^8.48.0",
"eslint-config-custom": "workspace:*",
"mocha": "^10.2.0",
Expand Down
11 changes: 8 additions & 3 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ Creates a new tus server with options.

The route to accept requests (`string`).

#### `options.maxSize`

Max file size (in bytes) allowed when uploading (`number` | (`(req, id: string | null) => Promise<number> | number`)).
When providing a function during the OPTIONS request the id will be `null`.

#### `options.relativeLocation`

Return a relative URL as the `Location` header to the client (`boolean`).
Expand Down Expand Up @@ -361,18 +366,18 @@ const server = new Server({
const token = req.headers.authorization;

if (!token) {
throw { status_code: 401, body: 'Unauthorized' })
throw { status_code: 401, body: 'Unauthorized' }
}

try {
const decodedToken = await jwt.verify(token, 'your_secret_key')
req.user = decodedToken
} catch (error) {
throw { status_code: 401, body: 'Invalid token' })
throw { status_code: 401, body: 'Invalid token' }
}

if (req.user.role !== 'admin') {
throw { status_code: 403, body: 'Access denied' })
throw { status_code: 403, body: 'Access denied' }
}
},
});
Expand Down
4 changes: 2 additions & 2 deletions packages/server/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@tus/server",
"version": "1.1.0",
"version": "1.2.0",
"description": "Tus resumable upload protocol in Node.js",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -26,7 +26,7 @@
"devDependencies": {
"@types/debug": "^4.1.8",
"@types/mocha": "^10.0.1",
"@types/node": "^20.5.7",
"@types/node": "^20.10.4",
"@types/sinon": "^10.0.16",
"@types/supertest": "^2.0.12",
"eslint": "^8.48.0",
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 @@ -61,6 +61,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
85 changes: 76 additions & 9 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,24 +135,24 @@ export class BaseHandler extends EventEmitter {
req: http.IncomingMessage,
id: string,
offset: number,
maxFileSize: number,
context: CancellationContext
) {
return new Promise<number>(async (resolve, reject) => {
// Abort early if the operation has been cancelled.
if (context.signal.aborted) {
reject(ERRORS.ABORTED)
return
}

const proxy = new stream.PassThrough()
stream.addAbortSignal(context.signal, proxy)
// Create a PassThrough stream as a proxy to manage the request stream.
// This allows for aborting the write process without affecting the incoming request stream.
const proxy = new PassThrough()
addAbortSignal(context.signal, proxy)

proxy.on('error', (err) => {
req.unpipe(proxy)
if (err.name === 'AbortError') {
reject(ERRORS.ABORTED)
} else {
reject(err)
}
reject(err.name === 'AbortError' ? ERRORS.ABORTED : err)
})

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

this.store.write(req.pipe(proxy), id, offset).then(resolve).catch(reject)
// Pipe the request stream through the proxy. We use the proxy instead of the request stream directly
// to ensure that errors in the pipeline do not cause the request stream to be destroyed,
// which would result in a socket hangup error for the client.
stream
.pipeline(req.pipe(proxy), new StreamLimiter(maxFileSize), async (stream) => {
return this.store.write(stream as StreamLimiter, id, offset)
})
.then(resolve)
.catch(reject)
})
}

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

/**
* 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(
req: http.IncomingMessage,
file: Upload,
configuredMaxSize?: number
) {
// 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 = req.headers['content-length'] !== undefined
const hasConfiguredMaxSizeSet = configuredMaxSize > 0

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

if (hasConfiguredMaxSizeSet) {
return configuredMaxSize - offset
} else {
return Number.MAX_SAFE_INTEGER
}
}

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

if (hasContentLengthSet) {
return length
}

return (file.size || 0) - offset
}
}
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, null)

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

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

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

if (this.options.onIncomingRequest) {
await this.options.onIncomingRequest(req, res, id)
}

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

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

let upload: Upload
Expand Down Expand Up @@ -86,11 +92,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
15 changes: 14 additions & 1 deletion packages/server/src/handlers/PostHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ export class PostHandler extends BaseHandler {
throw ERRORS.FILE_WRITE_ERROR
}

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

if (
upload_length &&
maxFileSize > 0 &&
Number.parseInt(upload_length, 10) > maxFileSize
) {
throw ERRORS.ERR_MAX_SIZE_EXCEEDED
}

let metadata
if ('upload-metadata' in req.headers) {
try {
Expand Down Expand Up @@ -92,12 +102,14 @@ export class PostHandler extends BaseHandler {
}

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 +121,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 {
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 {
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 | null) => Promise<number> | number)

/**
* Return a relative URL as the `Location` header.
*/
Expand Down
Loading

0 comments on commit 03f0249

Please sign in to comment.