diff --git a/packages/file-store/index.ts b/packages/file-store/index.ts index c341f9da..89c53d9e 100644 --- a/packages/file-store/index.ts +++ b/packages/file-store/index.ts @@ -1,5 +1,6 @@ // TODO: use /promises versions import fs from 'node:fs' +import fsProm from 'node:fs/promises' import path from 'node:path' import stream from 'node:stream' import http from 'node:http' @@ -57,26 +58,14 @@ export class FileStore extends DataStore { /** * Create an empty file. */ - create(file: Upload): Promise { - return new Promise((resolve, reject) => { - fs.open(path.join(this.directory, file.id), 'w', async (err, fd) => { - if (err) { - log('[FileStore] create: Error', err) - return reject(err) - } + async create(file: Upload): Promise { + const dirs = file.id.split('/').slice(0, -1) - await this.configstore.set(file.id, file) + await fsProm.mkdir(path.join(this.directory, ...dirs), {recursive: true}) + await fsProm.writeFile(path.join(this.directory, file.id), '') + await this.configstore.set(file.id, file) - return fs.close(fd, (exception) => { - if (exception) { - log('[FileStore] create: Error', exception) - return reject(exception) - } - - return resolve(file) - }) - }) - }) + return file } read(file_id: string) { diff --git a/packages/file-store/test.ts b/packages/file-store/test.ts index dbf2e570..6ba8c5ce 100644 --- a/packages/file-store/test.ts +++ b/packages/file-store/test.ts @@ -53,11 +53,6 @@ describe('FileStore', function () { describe('create', () => { const file = new Upload({id: '1234', size: 1000, offset: 0}) - it('should reject when the directory doesnt exist', function () { - this.datastore.directory = 'some_new_path' - return this.datastore.create(file).should.be.rejected() - }) - it('should resolve when the directory exists', function () { return this.datastore.create(file).should.be.fulfilled() }) diff --git a/packages/server/README.md b/packages/server/README.md index 94ad7b42..298b6bf0 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -17,6 +17,7 @@ - [Example: integrate tus into Fastify](#example-integrate-tus-into-fastify) - [Example: integrate tus into Next.js](#example-integrate-tus-into-nextjs) - [Example: validate metadata when an upload is created](#example-validate-metadata-when-an-upload-is-created) + - [Example: store files in custom nested directories](#example-store-files-in-custom-nested-directories) - [Types](#types) - [Compatibility](#compatibility) - [Contribute](#contribute) @@ -78,18 +79,43 @@ Allow `Forwarded`, `X-Forwarded-Proto`, and `X-Forwarded-Host` headers to overri Additional headers sent in `Access-Control-Allow-Headers` (`string[]`). #### `options.generateUrl` -Control how the upload url is generated (`(req, { proto, host, baseUrl, path, id }) => string)`) + +Control how the upload URL is generated (`(req, { proto, host, path, id }) => string)`) + +This only changes the upload URL (`Location` header). +If you also want to change the file name in storage use `namingFunction`. +Returning `prefix-1234` in `namingFunction` means the `id` argument in `generateUrl` is `prefix-1234`. + +`@tus/server` expects everything in the path after the last `/` to be the upload id. +If you change that you have to use `getFileIdFromRequest` as well. + +A common use case of this function and `getFileIdFromRequest` is to base65 encode a complex id into the URL. + +> [!TIP] +> Checkout the example how to [store files in custom nested directories](#example-store-files-in-custom-nested-directories). #### `options.getFileIdFromRequest` + Control how the Upload-ID is extracted from the request (`(req) => string | void`) +By default, it expects everything in the path after the last `/` to be the upload id. + +> [!TIP] +> Checkout the example how to [store files in custom nested directories](#example-store-files-in-custom-nested-directories). #### `options.namingFunction` Control how you want to name files (`(req) => string`) +In `@tus/server`, the upload ID in the URL is the same as the file name. +This means using a custom `namingFunction` will return a different `Location` header for uploading +and result in a different file name in storage. + 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')`. +> [!TIP] +> Checkout the example how to [store files in custom nested directories](#example-store-files-in-custom-nested-directories). + #### `disableTerminationForFinishedUploads` Disallow the [termination extension](https://tus.io/protocols/resumable-upload#termination) for finished uploads. (`boolean`) @@ -358,30 +384,66 @@ Access control is opinionated and can be done in different ways. This example is psuedo-code for what it could look like with JSON Web Tokens. ```js -const { Server } = require("@tus/server"); +const {Server} = require('@tus/server') // ... const server = new Server({ // .. async onIncomingRequest(req, res) { - const token = req.headers.authorization; + 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'} } }, -}); +}) +``` + +### Example: store files in custom nested directories + +You can use `namingFunction` to change the name of the stored file. +If you’re only adding a prefix or suffix without a slash (`/`), +you don’t need to implement `generateUrl` and `getFileIdFromRequest`. + +Adding a slash means you create a new directory, for which you need +to implement all three functions as we need encode the id with base64 into the URL. + +```js +const path = '/files' +const server = new Server({ + path, + datastore: new FileStore({directory: './test/output'}), + namingFunction(req) { + const id = crypto.randomBytes(16).toString('hex') + const folder = getFolderForUser(req) // your custom logic + return `users/${folder}/${id}` + }, + generateUrl(req, {proto, host, path, id}) { + id = Buffer.from(id, 'utf-8').toString('base64url') + return `${proto}://${host}${path}/${id}` + }, + getFileIdFromRequest(req) { + const reExtractFileID = /([^/]+)\/?$/ + const match = reExtractFileID.exec(req.url as string) + + if (!match || path.includes(match[1])) { + return + } + + return Buffer.from(match[1], 'base64url').toString('utf-8') + }, +}) ``` diff --git a/packages/server/src/handlers/BaseHandler.ts b/packages/server/src/handlers/BaseHandler.ts index a0c719c8..10a8d88e 100644 --- a/packages/server/src/handlers/BaseHandler.ts +++ b/packages/server/src/handlers/BaseHandler.ts @@ -39,8 +39,6 @@ export class BaseHandler extends EventEmitter { } generateUrl(req: http.IncomingMessage, id: string) { - // @ts-expect-error req.baseUrl does exist - const baseUrl = req.baseUrl ?? '' const path = this.options.path === '/' ? '' : this.options.path if (this.options.generateUrl) { @@ -50,8 +48,6 @@ export class BaseHandler extends EventEmitter { return this.options.generateUrl(req, { proto, host, - // @ts-expect-error we can pass undefined - baseUrl: req.baseUrl, path: path, id, }) @@ -59,12 +55,12 @@ export class BaseHandler extends EventEmitter { // Default implementation if (this.options.relativeLocation) { - return `${baseUrl}${path}/${id}` + return `${path}/${id}` } const {proto, host} = this.extractHostAndProto(req) - return `${proto}://${host}${baseUrl}${path}/${id}` + return `${proto}://${host}${path}/${id}` } getFileIdFromRequest(req: http.IncomingMessage) { diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 551cba2f..3ca79c4c 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -41,7 +41,7 @@ export type ServerOptions = { */ generateUrl?: ( req: http.IncomingMessage, - options: {proto: string; host: string; baseUrl: string; path: string; id: string} + options: {proto: string; host: string; path: string; id: string} ) => string /** diff --git a/packages/server/test/BaseHandler.test.ts b/packages/server/test/BaseHandler.test.ts index 7fe2117b..c534d68f 100644 --- a/packages/server/test/BaseHandler.test.ts +++ b/packages/server/test/BaseHandler.test.ts @@ -74,9 +74,9 @@ describe('BaseHandler', () => { const handler = new BaseHandler(store, { path: '/path', locker: new MemoryLocker(), - generateUrl: (req: http.IncomingMessage, info) => { - const {proto, host, baseUrl, path, id} = info - return `${proto}://${host}${baseUrl}${path}/${id}?customParam=1` + generateUrl: (_, info) => { + const {proto, host, path, id} = info + return `${proto}://${host}${path}/${id}?customParam=1` }, }) @@ -84,11 +84,10 @@ describe('BaseHandler', () => { headers: { host: 'localhost', }, - url: '/upload', }) const id = '123' const url = handler.generateUrl(req, id) - assert.equal(url, `http://localhost/upload/path/123?customParam=1`) + assert.equal(url, `http://localhost/path/123?customParam=1`) }) it('should allow extracting the request id with a custom function', () => { diff --git a/packages/server/test/Server.test.ts b/packages/server/test/Server.test.ts index 3c5ee442..8cea26c1 100644 --- a/packages/server/test/Server.test.ts +++ b/packages/server/test/Server.test.ts @@ -277,6 +277,50 @@ describe('Server', () => { .send('test') .expect(403, 'Access denied', done) }) + + it('can use namingFunction to create a nested directory structure', (done) => { + const route = '/test/output' + const server = new Server({ + path: route, + datastore: new FileStore({directory: './test/output'}), + namingFunction() { + return `foo/bar/id` + }, + generateUrl(_, {proto, host, path, id}) { + id = Buffer.from(id, 'utf-8').toString('base64url') + return `${proto}://${host}${path}/${id}` + }, + getFileIdFromRequest(req) { + const reExtractFileID = /([^/]+)\/?$/ + const match = reExtractFileID.exec(req.url as string) + + if (!match || route.includes(match[1])) { + return + } + + return Buffer.from(match[1], 'base64url').toString('utf-8') + }, + }) + const length = Buffer.byteLength('test', 'utf8').toString() + const s = server.listen() + request(s) + .post(server.options.path) + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Length', length) + .then((res) => { + console.log(res.headers.location) + request(s) + .patch(removeProtocol(res.headers.location)) + .send('test') + .set('Tus-Resumable', TUS_RESUMABLE) + .set('Upload-Offset', '0') + .set('Content-Type', 'application/offset+octet-stream') + .expect(204, () => { + s.close() + done() + }) + }) + }) }) describe('hooks', () => {