Skip to content

Commit

Permalink
feat: optionally disable termination extension for finished uploads
Browse files Browse the repository at this point in the history
  • Loading branch information
fenos committed Dec 13, 2023
1 parent 6d0ecc8 commit 248fef8
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/server/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions packages/server/src/handlers/DeleteHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ export class DeleteHandler extends BaseHandler {

const lock = await this.acquireLock(req, id, context)
try {
const upload = await this.store.getUpload(id)

if (
this.options.disableTerminationForFinishedUploads &&
upload.offset === upload.size
) {
throw ERRORS.INVALID_TERMINATION
}

await this.store.remove(id)
} finally {
await lock.unlock()
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export type ServerOptions = {
| Promise<Locker>
| ((req: http.IncomingMessage) => Locker | Promise<Locker>)

/**
* 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.
Expand Down
21 changes: 21 additions & 0 deletions packages/server/test/DeleteHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})
})
})
112 changes: 112 additions & 0 deletions test/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<void>((resolve, reject) => {
listener.close((err) => {
if (err) {
reject(err)
return
}
resolve()
})
})
})
})
})

describe('FileStore with relativeLocation', () => {
Expand Down

0 comments on commit 248fef8

Please sign in to comment.