Skip to content

Commit

Permalink
Support nested directories with namingFunction & clarify docs (#549)
Browse files Browse the repository at this point in the history
  • Loading branch information
Murderlon authored Jan 17, 2024
1 parent f3f91fb commit 367fdec
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 42 deletions.
25 changes: 7 additions & 18 deletions packages/file-store/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -57,26 +58,14 @@ export class FileStore extends DataStore {
/**
* Create an empty file.
*/
create(file: Upload): Promise<Upload> {
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<Upload> {
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) {
Expand Down
5 changes: 0 additions & 5 deletions packages/file-store/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand Down
76 changes: 69 additions & 7 deletions packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`)
Expand Down Expand Up @@ -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')
},
})

```
Expand Down
8 changes: 2 additions & 6 deletions packages/server/src/handlers/BaseHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -50,21 +48,19 @@ 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,
})
}

// 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) {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down
9 changes: 4 additions & 5 deletions packages/server/test/BaseHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,20 @@ 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`
},
})

const req = httpMocks.createRequest({
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', () => {
Expand Down
44 changes: 44 additions & 0 deletions packages/server/test/Server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down

0 comments on commit 367fdec

Please sign in to comment.