diff --git a/.changeset/nested-filekvstore-list.md b/.changeset/nested-filekvstore-list.md new file mode 100644 index 00000000..5883edb4 --- /dev/null +++ b/.changeset/nested-filekvstore-list.md @@ -0,0 +1,5 @@ +--- +"@tus/utils": patch +--- + +Fix `FileKvStore.list()` to include nested upload IDs. Expiration cleanup now removes existing nested uploads that meet the expiration rules, which were previously skipped. diff --git a/packages/file-store/src/test/index.ts b/packages/file-store/src/test/index.ts index f22e4654..953ad323 100644 --- a/packages/file-store/src/test/index.ts +++ b/packages/file-store/src/test/index.ts @@ -131,6 +131,30 @@ describe('FileStore', function () { }) }) + describe('deleteExpired', () => { + it('removes expired nested uploads and keeps unexpired uploads', async function () { + this.datastore.expirationPeriodInMilliseconds = 60_000 + const expired = new Upload({ + id: 'nested/path/expired', + size: 10, + offset: 0, + creation_date: new Date(Date.now() - 120_000).toISOString(), + }) + const fresh = new Upload({id: 'nested/path/fresh', size: 10, offset: 0}) + await this.datastore.create(expired) + await this.datastore.create(fresh) + + assert.equal(await this.datastore.deleteExpired(), 1) + await assert.rejects(fsProm.stat(path.join(storePath, expired.id)), { + code: 'ENOENT', + }) + await assert.rejects(fsProm.stat(path.join(storePath, `${expired.id}.json`)), { + code: 'ENOENT', + }) + assert.equal((await this.datastore.getUpload(fresh.id)).id, fresh.id) + }) + }) + describe('FileConfigstore', () => { it('should ignore random files in directory when calling list()', async () => { const store = new FileConfigstore(storePath) diff --git a/packages/utils/src/kvstores/FileKvStore.ts b/packages/utils/src/kvstores/FileKvStore.ts index 39ad0baa..20a92cd6 100644 --- a/packages/utils/src/kvstores/FileKvStore.ts +++ b/packages/utils/src/kvstores/FileKvStore.ts @@ -35,7 +35,20 @@ export class FileKvStore implements KvStore { } async list(): Promise> { - const files = new Set(await fs.readdir(this.directory)) + const entries = await fs.readdir(this.directory, { + recursive: true, + withFileTypes: true, + }) + const files = new Set( + entries + .filter((entry) => entry.isFile()) + .map((entry) => + path + .relative(this.directory, path.join(entry.parentPath, entry.name)) + .split(path.sep) + .join('/') + ) + ) // To only return tus file IDs we check if the file has a corresponding JSON info file return [...files].filter((file) => files.has(`${file}.json`)).sort() } diff --git a/packages/utils/src/test/FileKvStore.list.test.ts b/packages/utils/src/test/FileKvStore.list.test.ts index ef126a72..5094f8e4 100644 --- a/packages/utils/src/test/FileKvStore.list.test.ts +++ b/packages/utils/src/test/FileKvStore.list.test.ts @@ -19,7 +19,13 @@ describe('FileKvStore', () => { }) const touch = (...names: string[]) => - Promise.all(names.map((name) => fs.writeFile(path.join(directory, name), ''))) + Promise.all( + names.map(async (name) => { + const file = path.join(directory, name) + await fs.mkdir(path.dirname(file), {recursive: true}) + await fs.writeFile(file, '') + }) + ) describe('list', () => { it('ignores unpaired files', async () => { @@ -46,5 +52,38 @@ describe('FileKvStore', () => { await touch('b', 'b.json', 'c', 'c.json', 'a', 'a.json') assert.deepEqual(await store.list(), ['a', 'b', 'c']) }) + + it('lists nested ids with their full relative paths', async () => { + await touch( + 'upload', + 'upload.json', + 'a/upload', + 'a/upload.json', + 'b/deep/upload', + 'b/deep/upload.json' + ) + assert.deepEqual(await store.list(), ['a/upload', 'b/deep/upload', 'upload']) + }) + + it('does not pair files across directories', async () => { + await touch('a/upload', 'b/upload.json') + assert.deepEqual(await store.list(), []) + }) + + it('ignores directories with a matching JSON file', async () => { + await fs.mkdir(path.join(directory, 'folder')) + await touch('folder.json') + assert.deepEqual(await store.list(), []) + }) + + it('does not follow directory symlinks', async () => { + await touch('real/upload', 'real/upload.json') + await fs.symlink( + path.join(directory, 'real'), + path.join(directory, 'link'), + 'junction' + ) + assert.deepEqual(await store.list(), ['real/upload']) + }) }) })