diff --git a/.changeset/small-pandas-laugh.md b/.changeset/small-pandas-laugh.md new file mode 100644 index 00000000..4daf94fa --- /dev/null +++ b/.changeset/small-pandas-laugh.md @@ -0,0 +1,5 @@ +--- +'@tus/server': minor +--- + +Allow onUploadFinish hook to override response data diff --git a/packages/server/README.md b/packages/server/README.md index 3abbbbe1..4ca9f2ee 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -157,11 +157,13 @@ This can be used to implement validation of upload metadata or add headers. #### `options.onUploadFinish` `onUploadFinish` will be invoked after an upload is completed but before a response is -returned to the client (`(req, res, upload) => Promise`). +returned to the client (`(req, res, upload) => Promise<{ res: http.ServerResponse, status_code?: number, headers?: Record, body?: string }>`). -If the function returns the (modified) response, the upload will finish. You can `throw` -an Object and the HTTP request will be aborted with the provided `body` and `status_code` -(or their fallbacks). +- You can optionally return `status_code`, `headers` and `body` to modify the response. + Note that the tus specification does not allow sending response body nor status code + other than 204, but most clients support it. Use at your own risk. +- You can `throw` an Object and the HTTP request will be aborted with the provided `body` + and `status_code` (or their fallbacks). This can be used to implement post-processing validation. diff --git a/packages/server/src/handlers/PatchHandler.ts b/packages/server/src/handlers/PatchHandler.ts index 278eabf8..acb8a6be 100644 --- a/packages/server/src/handlers/PatchHandler.ts +++ b/packages/server/src/handlers/PatchHandler.ts @@ -107,22 +107,42 @@ export class PatchHandler extends BaseHandler { upload.offset = newOffset this.emit(EVENTS.POST_RECEIVE, req, res, upload) + + //Recommended response defaults + const responseData = { + status: 204, + headers: { + 'Upload-Offset': newOffset, + } as Record, + body: '', + } + if (newOffset === upload.size && this.options.onUploadFinish) { try { - res = await this.options.onUploadFinish(req, res, upload) + const resOrObject = await this.options.onUploadFinish(req, res, upload) + // Backwards compatibility, remove in next major + // Ugly check because we can't use `instanceof` because we mock the instance in tests + if ( + typeof (resOrObject as http.ServerResponse).write === 'function' && + typeof (resOrObject as http.ServerResponse).writeHead === 'function' + ) { + res = resOrObject as http.ServerResponse + } else { + // Ugly types because TS only understands instanceof + type ExcludeServerResponse = T extends http.ServerResponse ? never : T + const obj = resOrObject as ExcludeServerResponse + res = obj.res + if (obj.status_code) responseData.status = obj.status_code + if (obj.body) responseData.body = obj.body + if (obj.headers) + responseData.headers = Object.assign(obj.headers, responseData.headers) + } } catch (error) { log(`onUploadFinish: ${error.body}`) throw error } } - const headers: { - 'Upload-Offset': number - 'Upload-Expires'?: string - } = { - 'Upload-Offset': newOffset, - } - if ( this.store.hasExtension('expiration') && this.store.getExpiration() > 0 && @@ -134,11 +154,16 @@ export class PatchHandler extends BaseHandler { const dateString = new Date( creation.getTime() + this.store.getExpiration() ).toUTCString() - headers['Upload-Expires'] = dateString + responseData.headers['Upload-Expires'] = dateString } // The Server MUST acknowledge successful PATCH requests with the 204 - const writtenRes = this.write(res, 204, headers) + const writtenRes = this.write( + res, + responseData.status, + responseData.headers, + responseData.body + ) if (newOffset === upload.size) { this.emit(EVENTS.POST_FINISH, req, writtenRes, upload) diff --git a/packages/server/src/handlers/PostHandler.ts b/packages/server/src/handlers/PostHandler.ts index 8a812dfe..f6cb47ed 100644 --- a/packages/server/src/handlers/PostHandler.ts +++ b/packages/server/src/handlers/PostHandler.ts @@ -127,9 +127,12 @@ export class PostHandler extends BaseHandler { let isFinal: boolean let url: string - let headers: { - 'Upload-Offset'?: string - 'Upload-Expires'?: string + + //Recommended response defaults + const responseData = { + status: 201, + headers: {} as Record, + body: '', } try { @@ -139,14 +142,13 @@ export class PostHandler extends BaseHandler { this.emit(EVENTS.POST_CREATE, req, res, upload, url) isFinal = upload.size === 0 && !upload.sizeIsDeferred - headers = {} // The request MIGHT include a Content-Type header when using creation-with-upload extension if (validateHeader('content-type', req.headers['content-type'])) { const bodyMaxSize = await this.calculateMaxBodySize(req, upload, maxFileSize) const newOffset = await this.writeToStore(req, upload, bodyMaxSize, context) - headers['Upload-Offset'] = newOffset.toString() + responseData.headers['Upload-Offset'] = newOffset.toString() isFinal = newOffset === Number.parseInt(upload_length as string, 10) upload.offset = newOffset } @@ -159,7 +161,24 @@ export class PostHandler extends BaseHandler { if (isFinal && this.options.onUploadFinish) { try { - res = await this.options.onUploadFinish(req, res, upload) + const resOrObject = await this.options.onUploadFinish(req, res, upload) + // Backwards compatibility, remove in next major + // Ugly check because we can't use `instanceof` because we mock the instance in tests + if ( + typeof (resOrObject as http.ServerResponse).write === 'function' && + typeof (resOrObject as http.ServerResponse).writeHead === 'function' + ) { + res = resOrObject as http.ServerResponse + } else { + // Ugly types because TS only understands instanceof + type ExcludeServerResponse = T extends http.ServerResponse ? never : T + const obj = resOrObject as ExcludeServerResponse + res = obj.res + if (obj.status_code) responseData.status = obj.status_code + if (obj.body) responseData.body = obj.body + if (obj.headers) + responseData.headers = Object.assign(obj.headers, responseData.headers) + } } catch (error) { log(`onUploadFinish: ${error.body}`) throw error @@ -178,13 +197,26 @@ export class PostHandler extends BaseHandler { if (created.offset !== Number.parseInt(upload_length as string, 10)) { const creation = new Date(upload.creation_date) // Value MUST be in RFC 7231 datetime format - headers['Upload-Expires'] = new Date( + responseData.headers['Upload-Expires'] = new Date( creation.getTime() + this.store.getExpiration() ).toUTCString() } } - const writtenRes = this.write(res, 201, {Location: url, ...headers}) + //Only append Location header if its valid for the final http status (201 or 3xx) + if ( + responseData.status === 201 || + (responseData.status >= 300 && responseData.status < 400) + ) { + responseData.headers['Location'] = url + } + + const writtenRes = this.write( + res, + responseData.status, + responseData.headers, + responseData.body + ) if (isFinal) { this.emit(EVENTS.POST_FINISH, req, writtenRes, upload) diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index fa8b05c5..83b01ca8 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -107,7 +107,8 @@ export type ServerOptions = { /** * `onUploadFinish` will be invoked after an upload is completed but before a response is returned to the client. - * If the function returns the (modified) response, the upload will finish. + * You can optionally return `status_code`, `headers` and `body` to modify the response. + * Note that the tus specification does not allow sending response body nor status code other than 204, but most clients support it. * If an error is thrown, the HTTP request will be aborted, and the provided `body` and `status_code` * (or their fallbacks) will be sent to the client. This can be used to implement post-processing validation. * @param req - The incoming HTTP request. @@ -118,7 +119,16 @@ export type ServerOptions = { req: http.IncomingMessage, res: http.ServerResponse, upload: Upload - ) => Promise + ) => Promise< + // TODO: change in the next major + | http.ServerResponse + | { + res: http.ServerResponse + status_code?: number + headers?: Record + body?: string + } + > /** * `onIncomingRequest` will be invoked when an incoming request is received. diff --git a/packages/server/test/PostHandler.test.ts b/packages/server/test/PostHandler.test.ts index bbca9cbd..b4790de8 100644 --- a/packages/server/test/PostHandler.test.ts +++ b/packages/server/test/PostHandler.test.ts @@ -347,6 +347,24 @@ describe('PostHandler', () => { assert.equal(upload.offset, 0) assert.equal(upload.size, 0) }) + + it('does not set Location header if onUploadFinish hook returned a not eligible status code', async function () { + const store = sinon.createStubInstance(DataStore) + const handler = new PostHandler(store, { + path: '/test/output', + locker: new MemoryLocker(), + onUploadFinish: async (req, res) => ({res, status_code: 200}), + }) + + req.headers = { + 'upload-length': '0', + host: 'localhost:3000', + } + store.create.resolvesArg(0) + + await handler.send(req, res, context) + assert.equal('location' in res._getHeaders(), false) + }) }) }) }) diff --git a/packages/server/test/Server.test.ts b/packages/server/test/Server.test.ts index 11376f58..129794be 100644 --- a/packages/server/test/Server.test.ts +++ b/packages/server/test/Server.test.ts @@ -533,6 +533,40 @@ describe('Server', () => { .expect(500, 'no', done) }) + it('should allow response to be changed in onUploadFinish', (done) => { + const server = new Server({ + path: '/test/output', + datastore: new FileStore({directory}), + async onUploadFinish(_, res) { + return { + res, + status_code: 200, + body: '{ fileProcessResult: 12 }', + headers: {'X-TestHeader': '1'}, + } + }, + }) + + request(server.listen()) + .post(server.options.path) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Length', '4') + .then((res) => { + request(server.listen()) + .patch(removeProtocol(res.headers.location)) + .send('test') + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Offset', '0') + .set('Content-Type', 'application/offset+octet-stream') + .expect(200, '{ fileProcessResult: 12 }') + .then((r) => { + assert.equal(r.headers['upload-offset'], '4') + assert.equal(r.headers['x-testheader'], '1') + done() + }) + }) + }) + it('should fire when an upload is finished with upload-defer-length', (done) => { const length = Buffer.byteLength('test', 'utf8').toString() server.on(EVENTS.POST_FINISH, (req, res, upload) => {