diff --git a/packages/server/README.md b/packages/server/README.md index a0305f96..62872cf1 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -85,6 +85,9 @@ Control how you want to name files (`(req) => string`) It is important to make these unique to prevent data loss. Only use it if you need to. Default uses `crypto.randomBytes(16).toString('hex')`. +#### `disableTerminationForFinishedUploads` +Disallow termination for finished uploads. (`boolean`) + #### `options.onUploadCreate` `onUploadCreate` will be invoked before a new upload is created. (`(req, res, upload) => Promise`). diff --git a/packages/server/src/constants.ts b/packages/server/src/constants.ts index c9247711..bc5018dd 100644 --- a/packages/server/src/constants.ts +++ b/packages/server/src/constants.ts @@ -37,6 +37,10 @@ export const ERRORS = { status_code: 400, body: 'Request aborted due to lock acquired', }, + INVALID_TERMINATION: { + status_code: 400, + body: 'Cannot terminate an already completed upload', + }, ERR_LOCK_TIMEOUT: { status_code: 500, body: 'failed to acquire lock before timeout', diff --git a/packages/server/src/handlers/DeleteHandler.ts b/packages/server/src/handlers/DeleteHandler.ts index cb685070..e36cdf18 100644 --- a/packages/server/src/handlers/DeleteHandler.ts +++ b/packages/server/src/handlers/DeleteHandler.ts @@ -21,6 +21,13 @@ export class DeleteHandler extends BaseHandler { const lock = await this.acquireLock(req, id, context) try { + if (this.options.disableTerminationForFinishedUploads) { + const upload = await this.store.getUpload(id) + if (upload.offset === upload.size) { + throw ERRORS.INVALID_TERMINATION + } + } + await this.store.remove(id) } finally { await lock.unlock() diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 7fcbfd21..a94ad329 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -61,6 +61,11 @@ export type ServerOptions = { | Promise | ((req: http.IncomingMessage) => Locker | Promise) + /** + * Disallow termination for finished uploads. + */ + disableTerminationForFinishedUploads?: boolean + /** * `onUploadCreate` will be invoked before a new upload is created. * If the function returns the (modified) response, the upload will be created. diff --git a/packages/server/test/DeleteHandler.test.ts b/packages/server/test/DeleteHandler.test.ts index a785df3a..d255073d 100644 --- a/packages/server/test/DeleteHandler.test.ts +++ b/packages/server/test/DeleteHandler.test.ts @@ -63,4 +63,25 @@ describe('DeleteHandler', () => { }) handler.send(req, res, context) }) + + it('must not allow terminating an upload if already completed', async () => { + const handler = new DeleteHandler(fake_store, { + relativeLocation: true, + disableTerminationForFinishedUploads: true, + path, + locker: new MemoryLocker(), + }) + + fake_store.getUpload.resolves({ + id: 'abc', + metadata: undefined, + get sizeIsDeferred(): boolean { + return false + }, + creation_date: undefined, + offset: 1000, + size: 1000, + }) + await assert.rejects(() => handler.send(req, res, context), {status_code: 400}) + }) }) diff --git a/test/e2e.test.ts b/test/e2e.test.ts index b915b373..8ef7089c 100644 --- a/test/e2e.test.ts +++ b/test/e2e.test.ts @@ -16,6 +16,7 @@ import http from 'node:http' import sinon from 'sinon' import Throttle from 'throttle' import {Agent} from 'http' +import {Buffer} from 'buffer' const STORE_PATH = '/output' const PROJECT_ID = 'tus-node-server' @@ -270,6 +271,117 @@ describe('EndToEnd', () => { .end(done) }) }) + + describe('DELETE', () => { + let server: Server + let listener: http.Server + + before(() => { + server = new Server({ + path: STORE_PATH, + datastore: new FileStore({directory: `./${STORE_PATH}`}), + }) + listener = server.listen() + agent = request.agent(listener) + }) + + after((done) => { + // Remove the files directory + rimraf(FILES_DIRECTORY, (err) => { + if (err) { + return done(err) + } + + // Clear the config + // @ts-expect-error we can consider a generic to pass to + // datastore to narrow down the store type + const uploads = (server.datastore.configstore as Configstore).list?.() ?? [] + for (const upload in uploads) { + // @ts-expect-error we can consider a generic to pass to + // datastore to narrow down the store type + await(server.datastore.configstore as Configstore).delete(upload) + } + listener.close() + return done() + }) + }) + + it('will allow terminating finished uploads', async () => { + const body = Buffer.alloc(parseInt(TEST_FILE_SIZE, 10)) + const res = await agent + .post(STORE_PATH) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Length', TEST_FILE_SIZE) + .set('Upload-Metadata', TEST_METADATA) + .set('Tus-Resumable', TUS_RESUMABLE) + .expect(201) + + assert.equal('location' in res.headers, true) + assert.equal(res.headers['tus-resumable'], TUS_RESUMABLE) + // Save the id for subsequent tests + const file_id = res.headers.location.split('/').pop() + + await agent + .patch(`${STORE_PATH}/${file_id}`) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Offset', '0') + .set('Content-Type', 'application/offset+octet-stream') + .send(body) + + // try terminating the upload + await agent + .delete(`${STORE_PATH}/${file_id}`) + .set('Tus-Resumable', TUS_RESUMABLE) + .expect(204) + }) + + it('will disallow terminating an upload if the upload is already completed', async () => { + const server = new Server({ + path: STORE_PATH, + disableTerminationForFinishedUploads: true, + datastore: new FileStore({directory: `./${STORE_PATH}`}), + }) + const listener = server.listen() + const agent = request.agent(listener) + + const body = Buffer.alloc(parseInt(TEST_FILE_SIZE, 10)) + const res = await agent + .post(STORE_PATH) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Length', TEST_FILE_SIZE) + .set('Upload-Metadata', TEST_METADATA) + .set('Tus-Resumable', TUS_RESUMABLE) + .expect(201) + + assert.equal('location' in res.headers, true) + assert.equal(res.headers['tus-resumable'], TUS_RESUMABLE) + // Save the id for subsequent tests + const file_id = res.headers.location.split('/').pop() + + await agent + .patch(`${STORE_PATH}/${file_id}`) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Offset', '0') + .set('Content-Type', 'application/offset+octet-stream') + .send(body) + + // try terminating the upload + await agent + .delete(`${STORE_PATH}/${file_id}`) + .set('Tus-Resumable', TUS_RESUMABLE) + .expect(400) + + await new Promise((resolve, reject) => { + listener.close((err) => { + if (err) { + reject(err) + return + } + resolve() + }) + }) + }) + }) }) describe('FileStore with relativeLocation', () => {