├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .npmignore ├── .roarr.js ├── .travis.yml ├── LICENSE ├── README.md ├── package.json ├── src ├── Logger.js ├── errors.js ├── factories │ ├── createConfiguration.js │ ├── createMemoryStorage.js │ ├── createTusMiddleware.js │ └── index.js ├── index.js ├── types.js └── utilities │ ├── formatUploadMetadataHeader.js │ ├── index.js │ ├── isValidUploadMetadataHeader.js │ ├── parseUploadChecksumHeader.js │ ├── parseUploadLengthHeader.js │ ├── parseUploadMetadataHeader.js │ └── parseUploadOffsetHeader.js └── test ├── express-tus ├── factories │ ├── createMemoryStorage.js │ └── createTusMiddleware.js ├── integration.js └── utilities │ └── parseUploadMetadata.js └── helpers ├── createHttpServerWithRandomPort.js └── createTestServer.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "plugins": [ 5 | "istanbul" 6 | ] 7 | } 8 | }, 9 | "plugins": [ 10 | "transform-export-default-name", 11 | "@babel/transform-flow-strip-types" 12 | ], 13 | "presets": [ 14 | [ 15 | "@babel/env", 16 | { 17 | "targets": { 18 | "node": "14" 19 | } 20 | } 21 | ] 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gajus/express-tus/ab6d8634f300b91c3e065e985f466902a8033781/.eslintignore -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "canonical", 4 | "canonical/flowtype" 5 | ], 6 | "root": true, 7 | "rules": { 8 | "flowtype/no-flow-fix-me-comments": 0, 9 | "flowtype/no-weak-types": 1, 10 | "flowtype/require-compound-type-alias": 0, 11 | "max-len": 0, 12 | "no-duplicate-imports": 0, 13 | "require-await": 0 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/.*/test/.* 3 | /dist/.* 4 | /test/.* 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | *.log 5 | .* 6 | !.babelrc 7 | !.editorconfig 8 | !.eslintignore 9 | !.eslintrc 10 | !.flowconfig 11 | !.gitignore 12 | !.npmignore 13 | !.travis.yml 14 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | coverage 3 | .* 4 | *.log 5 | !.flowconfig 6 | -------------------------------------------------------------------------------- /.roarr.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | filterFunction: (message) => { 3 | if ( 4 | message.message.includes('client connection is closed and removed from the client pool') || 5 | message.message.includes('created a new client connection') || 6 | message.message.includes('client is checked out from the pool') 7 | ) { 8 | return false; 9 | } 10 | 11 | return true; 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | script: 5 | - npm run lint 6 | - npm run test 7 | - nyc --silent npm run test 8 | - nyc report --reporter=text-lcov | coveralls 9 | - nyc check-coverage --lines 60 10 | after_success: 11 | - NODE_ENV=production npm run build 12 | - semantic-release 13 | notifications: 14 | email: false 15 | sudo: false 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Gajus Kuizinas (http://gajus.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of the Gajus Kuizinas (http://gajus.com/) nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL ANUARY BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## express-tus 🏋️ 2 | 3 | [![Travis build status](http://img.shields.io/travis/gajus/express-tus/master.svg?style=flat-square)](https://travis-ci.org/gajus/express-tus) 4 | [![Coveralls](https://img.shields.io/coveralls/gajus/express-tus.svg?style=flat-square)](https://coveralls.io/github/gajus/express-tus) 5 | [![NPM version](http://img.shields.io/npm/v/express-tus.svg?style=flat-square)](https://www.npmjs.org/package/express-tus) 6 | [![Canonical Code Style](https://img.shields.io/badge/code%20style-canonical-blue.svg?style=flat-square)](https://github.com/gajus/canonical) 7 | [![Twitter Follow](https://img.shields.io/twitter/follow/kuizinas.svg?style=social&label=Follow)](https://twitter.com/kuizinas) 8 | 9 | [Express](https://expressjs.com/) middleware for [tus protocol](https://tus.io/) (v1.0.0). 10 | 11 | --- 12 | 13 | * [Motivation](#motivation) 14 | * [API](#api) 15 | * [Rejecting file uploads](#rejecting-file-uploads) 16 | * [Storage](#storage) 17 | * [Memory Storage](#memory-storage) 18 | * [CORS](#cors) 19 | * [Supported extensions](#supported-extensions) 20 | * [Checksum](#checksum) 21 | * [Creation](#creation) 22 | * [Expiration](#expiration) 23 | * [Termination](#termination) 24 | * [Implementation considerations](#implementation-considerations) 25 | * [Resumable uploads using Google Storage](#resumable-uploads-using-google-storage) 26 | * [Restrict minimum chunk size](#restrict-minimum-chunk-size) 27 | 28 | ## Motivation 29 | 30 | Conceptually, [tus](https://tus.io/) is a great initiative. However, the existing implementations are lacking: 31 | 32 | * [tus-node-server](https://github.com/tus/tus-node-server) has a big warning stating that usage is discouraged in favour of tusd. 33 | * [tusd](https://github.com/tus/tusd) has bugs and opinionated limitations (just browse [issues](https://github.com/tus/tusd/issues)). 34 | 35 | `express-tus` provides a high-level abstraction that implements tus protocol, but leaves the actual handling of uploads to the implementer. This approach has the benefit of granular control over the file uploads while being compatible with the underlying (tus) protocol. 36 | 37 | ## API 38 | 39 | ```js 40 | // @flow 41 | 42 | import { 43 | createTusMiddleware, 44 | formatUploadMetadataHeader, 45 | } from 'express-tus'; 46 | import type { 47 | ConfigurationInputType, 48 | IncomingMessageType, 49 | ResponseType, 50 | StorageType, 51 | UploadInputType, 52 | UploadMetadataType, 53 | UploadType, 54 | UploadUpdateInputType, 55 | } from 'express-tus'; 56 | 57 | /** 58 | * Formats Tus compliant metadata header. 59 | */ 60 | formatUploadMetadataHeader(uploadMetadata: UploadMetadataType): string; 61 | 62 | /** 63 | * @property uploadExpires UNIX timestamp (in milliseconds) after which the upload will be deleted. 64 | * @property uploadLength Indicates the size of the entire upload in bytes. 65 | * @property uploadMetadata Key-value meta-data about the upload. 66 | * @property uploadOffset Indicates a byte offset within a resource. 67 | */ 68 | type UploadType = {| 69 | +uploadExpires?: number, 70 | +uploadLength: number, 71 | +uploadMetadata: UploadMetadataType, 72 | +uploadOffset: number, 73 | |}; 74 | 75 | /** 76 | * @property createUpload Approves file upload. Defaults to allowing all uploads. 77 | * @property delete Deletes upload. 78 | * @property getUpload Retrieves progress information about an existing upload. 79 | * @property upload Applies bytes contained in the incoming message at the given offset. 80 | */ 81 | type StorageType = {| 82 | +createUpload: (input: UploadInputType) => MaybePromiseType, 83 | +delete: (uid: string) => MaybePromiseType, 84 | +getUpload: (uid: string) => MaybePromiseType, 85 | +upload: (input: UploadUpdateInputType) => MaybePromiseType, 86 | |}; 87 | 88 | /** 89 | * @property basePath Path to where the tus middleware is mounted. Used for redirects. Defaults to `/`. 90 | * @property createUid Generates unique identifier for each upload request. Defaults to UUID v4. 91 | */ 92 | type ConfigurationInputType = {| 93 | +basePath?: string, 94 | +createUid?: () => Promise, 95 | ...StorageType, 96 | |}; 97 | 98 | createTusMiddleware(configuration: ConfigurationInputType); 99 | 100 | ``` 101 | 102 | ### Rejecting file uploads 103 | 104 | `createUpload`, `upload` and `getUpload` can throw an error at any point to reject an upload. The error will propagate through usual express error handling path. 105 | 106 | As an example, this is what [Memory Storage](#memory-storage) errors handler could be implemented: 107 | 108 | ```js 109 | import { 110 | createMemoryStorage, 111 | createTusMiddleware, 112 | ExpressTusError, 113 | NotFoundError, 114 | UserError, 115 | } from 'express-tus'; 116 | 117 | app.use(createTusMiddleware({ 118 | ...createMemoryStorage(), 119 | })); 120 | 121 | app.use((error, incomingMessage, outgoingMessage, next) => { 122 | // `incomingMessage.tus.uid` contains the upload UID. 123 | incomingMessage.tus.uid; 124 | 125 | if (error instanceof ExpressTusError) { 126 | if (error instanceof NotFoundError) { 127 | outgoingMessage 128 | .status(404) 129 | .end('Upload not found.'); 130 | 131 | return; 132 | } 133 | 134 | if (error instanceof UserError) { 135 | outgoingMessage 136 | .status(500) 137 | .end(error.message); 138 | 139 | return; 140 | } 141 | 142 | outgoingMessage 143 | .status(500) 144 | .end('Internal server error.'); 145 | } else { 146 | next(); 147 | 148 | return; 149 | } 150 | }); 151 | 152 | ``` 153 | 154 | ## Storage 155 | 156 | `express-tus` does not provide any default storage engines. 157 | 158 | ### Memory Storage 159 | 160 | Refer to the [example](./src/factories/createMemoryStorage.js), in-memory, storage engine. 161 | 162 | ## CORS 163 | 164 | `express-tus` configures [`access-control-allow-headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) and [`access-control-expose-headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers), but does not configure [`access-control-allow-origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin). 165 | 166 | Use [`cors`](https://www.npmjs.com/package/cors) to configure the necessary headers for cross-site communication. 167 | 168 | ## Supported extensions 169 | 170 | ### Checksum 171 | 172 | [creation](https://tus.io/protocols/resumable-upload.html#checksum) 173 | 174 | Supported algorithms: 175 | 176 | * crc32 177 | * md5 178 | * sha1 179 | * sha256 180 | 181 | ### Creation 182 | 183 | [creation](https://tus.io/protocols/resumable-upload.html#creation) 184 | 185 | ### Expiration 186 | 187 | [expiration](https://tus.io/protocols/resumable-upload.html#expiration) 188 | 189 | Note that it is the responsibility of the storage engine to detect and delete expired uploads. 190 | 191 | ### Termination 192 | 193 | [termination](https://tus.io/protocols/resumable-upload.html#termination) 194 | 195 | ## Implementation considerations 196 | 197 | ### Resumable uploads using Google Storage 198 | 199 | One of the original goals for writing `express-tus` was to have an abstraction that will allow to uploads files to Google Storage using their [resumable uploads protocol](https://cloud.google.com/storage/docs/performing-resumable-uploads). However, it turns out that, due to [arbitrary restrictions](https://github.com/googleapis/nodejs-storage/issues/1192#issuecomment-629042176) imposed by their API, this is not possible. 200 | 201 | Specifically, the challenge is that Google resumable uploads (1) do not guarantee that they will upload the entire chunk that you send to the server and (2) do not allow to upload individual chunks lesser than 256 KB. Therefore, if you receive upload chunks on a different service instances, then individual instances are not going to be able to complete their upload without being aware of the chunks submitted to the other instances. 202 | 203 | The only [workaround](https://github.com/googleapis/nodejs-storage/issues/1192#issuecomment-628873851) is to upload chunks individually (as separate files) and then using Google Cloud API to concatenate files. However, this approach results in [significant cost increase](https://github.com/googleapis/gcs-resumable-upload/issues/132#issuecomment-603493772). 204 | 205 | ### Restrict minimum chunk size 206 | 207 | tus protocol does not dictate any restrictions about individual chunk size. However, this leaves your service open to DDoS attack. 208 | 209 | When implementing `upload` method, restrict each chunk to a desired minimum size (except the last one), e.g. 210 | 211 | ```js 212 | { 213 | // [..] 214 | 215 | upload: async (input) => { 216 | if (input.uploadOffset + input.chunkLength < input.uploadLength && input.chunkLength < MINIMUM_CHUNK_SIZE) { 217 | throw new UserError('Each chunk must be at least ' + filesize(MINIMUM_CHUNK_SIZE) + ' (except the last one).'); 218 | } 219 | 220 | // [..] 221 | }, 222 | } 223 | 224 | ``` 225 | 226 | Google restricts their uploads to a [minimum of 256 KB per chunk](https://cloud.google.com/storage/docs/performing-resumable-uploads#json-api), which is a reasonable default. However, even with 256 KB restriction, a 1 GB upload would result in 3906 write operations. Therefore, if you are allowing large file uploads, adjust the minimum chunk size dynamically based on the input size. 227 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "ava": { 3 | "babel": { 4 | "compileAsTests": [ 5 | "test/helpers/**/*" 6 | ] 7 | }, 8 | "files": [ 9 | "test/express-tus/**/*" 10 | ], 11 | "require": [ 12 | "@babel/register" 13 | ] 14 | }, 15 | "dependencies": { 16 | "body-parser": "^1.19.0", 17 | "delay": "^4.3.0", 18 | "es6-error": "^4.1.1", 19 | "express": "^4.17.1", 20 | "express-promise-router": "^3.0.3", 21 | "is-base64": "^1.1.0", 22 | "roarr": "^2.15.3", 23 | "serialize-error": "^6.0.0", 24 | "tmp": "^0.2.1", 25 | "uuid": "^8.0.0", 26 | "yargs": "^15.3.1" 27 | }, 28 | "description": "Express middleware for tus protocol.", 29 | "devDependencies": { 30 | "@ava/babel": "^1.0.1", 31 | "@babel/cli": "^7.8.4", 32 | "@babel/core": "^7.9.6", 33 | "@babel/node": "^7.8.7", 34 | "@babel/plugin-transform-flow-strip-types": "^7.9.0", 35 | "@babel/preset-env": "^7.9.6", 36 | "@babel/register": "^7.9.0", 37 | "ava": "^3.8.2", 38 | "babel-plugin-istanbul": "^6.0.0", 39 | "babel-plugin-transform-export-default-name": "^2.0.4", 40 | "clone-buffer": "^1.0.0", 41 | "coveralls": "^3.1.0", 42 | "eslint": "^7.0.0", 43 | "eslint-config-canonical": "^19.0.4", 44 | "flow-bin": "^0.124.0", 45 | "flow-copy-source": "^2.0.9", 46 | "got": "^11.1.3", 47 | "http-terminator": "^2.0.3", 48 | "husky": "^4.2.5", 49 | "nyc": "^15.0.1", 50 | "semantic-release": "^17.0.7", 51 | "sinon": "^9.0.2" 52 | }, 53 | "engines": { 54 | "node": ">=14" 55 | }, 56 | "husky": { 57 | "hooks": { 58 | "pre-commit": "npm run lint && npm run test" 59 | } 60 | }, 61 | "keywords": [ 62 | "tus", 63 | "file", 64 | "upload", 65 | "resumable" 66 | ], 67 | "license": "BSD-3-Clause", 68 | "main": "./dist/index.js", 69 | "name": "express-tus", 70 | "nyc": { 71 | "all": true, 72 | "exclude": [ 73 | "src/bin/**/*" 74 | ], 75 | "include": [ 76 | "src/**/*.js" 77 | ], 78 | "instrument": false, 79 | "reporter": [ 80 | "html", 81 | "text-summary" 82 | ], 83 | "require": [ 84 | "@babel/register" 85 | ], 86 | "silent": true, 87 | "sourceMap": false 88 | }, 89 | "repository": { 90 | "type": "git", 91 | "url": "https://github.com/gajus/express-tus" 92 | }, 93 | "scripts": { 94 | "build": "rm -fr ./dist && NODE_ENV=production babel ./src --out-dir ./dist --copy-files --source-maps && flow-copy-source src dist", 95 | "lint": "eslint --fix ./src && flow", 96 | "test": "NODE_ENV=test ava --verbose --serial" 97 | }, 98 | "version": "0.0.0-dev" 99 | } 100 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Roarr from 'roarr'; 4 | 5 | export default Roarr.child({ 6 | program: 'express-tus', 7 | }); 8 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /* eslint-disable fp/no-class, fp/no-this */ 4 | 5 | import ExtendableError from 'es6-error'; 6 | 7 | export class ExpressTusError extends ExtendableError {} 8 | 9 | export class UnexpectedStateError extends ExpressTusError { 10 | code: string; 11 | 12 | constructor (message: string, code: string = 'UNEXPECTED_STATE_ERROR') { 13 | super(message); 14 | 15 | this.code = code; 16 | } 17 | } 18 | 19 | export class UserError extends ExpressTusError { 20 | code: string; 21 | 22 | constructor (message: string, code: string = 'USER_ERROR') { 23 | super(message); 24 | 25 | this.code = code; 26 | } 27 | } 28 | 29 | export class NotFoundError extends UserError { 30 | constructor () { 31 | super('Resource not found.', 'RESOURCE_NOT_FOUND'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/factories/createConfiguration.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | v4 as uuid, 5 | } from 'uuid'; 6 | import type { 7 | ConfigurationInputType, 8 | ConfigurationType, 9 | } from '../types'; 10 | 11 | export default (input: ConfigurationInputType): ConfigurationType => { 12 | const configuration: Object = { 13 | createUpload: input.createUpload, 14 | delete: input.delete, 15 | getUpload: input.getUpload, 16 | upload: input.upload, 17 | }; 18 | 19 | configuration.basePath = input.basePath || '/'; 20 | 21 | if (input.createUid) { 22 | configuration.createUid = input.createUid; 23 | } else { 24 | configuration.createUid = uuid; 25 | } 26 | 27 | return configuration; 28 | }; 29 | -------------------------------------------------------------------------------- /src/factories/createMemoryStorage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs'; 4 | import { 5 | NotFoundError, 6 | } from '../errors'; 7 | import type { 8 | StorageType, 9 | } from '../types'; 10 | 11 | type ConfigurationType = {| 12 | +storage?: Object, 13 | |}; 14 | 15 | const formatUpload = (upload) => { 16 | return { 17 | uid: upload.uid, 18 | uploadLength: upload.uploadLength, 19 | uploadMetadata: upload.uploadMetadata, 20 | uploadOffset: upload.uploadOffset, 21 | }; 22 | }; 23 | 24 | export default (configuration?: ConfigurationType): StorageType => { 25 | const storage = configuration && configuration.storage || {}; 26 | 27 | const getUpload = (uid) => { 28 | const upload = storage[uid]; 29 | 30 | if (upload) { 31 | return upload; 32 | } 33 | 34 | throw new NotFoundError(); 35 | }; 36 | 37 | const deleteUpload = (uid) => { 38 | // eslint-disable-next-line fp/no-delete 39 | delete storage[uid]; 40 | }; 41 | 42 | return { 43 | createUpload: async (input) => { 44 | storage[input.uid] = { 45 | buffer: Buffer.alloc(input.uploadLength), 46 | uid: input.uid, 47 | uploadLength: input.uploadLength, 48 | uploadMetadata: input.uploadMetadata || {}, 49 | uploadOffset: 0, 50 | }; 51 | 52 | return formatUpload(getUpload(input.uid)); 53 | }, 54 | delete: (uid) => { 55 | // Triggers error if upload does not exist. 56 | if (getUpload(uid)) { 57 | deleteUpload(uid); 58 | } 59 | }, 60 | getUpload: (uid) => { 61 | const upload = getUpload(uid); 62 | 63 | if (upload.uploadExpires && Date.now() > upload.uploadExpires) { 64 | deleteUpload(uid); 65 | 66 | // Triggers 404 error. 67 | getUpload(uid); 68 | } 69 | 70 | return formatUpload(upload); 71 | }, 72 | upload: (input) => { 73 | const upload = getUpload(input.uid); 74 | 75 | const buffer = fs.readFileSync(input.filePath); 76 | 77 | buffer.copy( 78 | upload.buffer, 79 | upload.uploadOffset, 80 | ); 81 | 82 | upload.uploadOffset += buffer.length; 83 | }, 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/factories/createTusMiddleware.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import stream from 'stream'; 4 | import { 5 | promisify, 6 | } from 'util'; 7 | import { 8 | createWriteStream, 9 | } from 'fs'; 10 | import { 11 | createHash, 12 | } from 'crypto'; 13 | import { 14 | resolve as resolveUrl, 15 | } from 'url'; 16 | import { 17 | tmpNameSync, 18 | } from 'tmp'; 19 | import { 20 | serializeError, 21 | } from 'serialize-error'; 22 | import createRouter from 'express-promise-router'; 23 | import type { 24 | ConfigurationInputType, 25 | } from '../types'; 26 | import { 27 | formatUploadMetadataHeader, 28 | parseUploadChecksumHeader, 29 | parseUploadLengthHeader, 30 | parseUploadMetadataHeader, 31 | parseUploadOffsetHeader, 32 | } from '../utilities'; 33 | import Logger from '../Logger'; 34 | import createConfiguration from './createConfiguration'; 35 | 36 | const pipeline = promisify(stream.pipeline); 37 | 38 | const ALLOW_HEADERS = [ 39 | 'authorization', 40 | 'content-type', 41 | 'origin', 42 | 'tus-resumable', 43 | 'upload-concat,', 44 | 'upload-defer-length', 45 | 'upload-length', 46 | 'upload-metadata', 47 | 'upload-offset', 48 | 'x-http-method-override', 49 | 'x-request-id', 50 | 'x-requested-with', 51 | ]; 52 | 53 | const EXPOSE_HEADERS = [ 54 | 'location', 55 | 'tus-checksum-algorithm', 56 | 'tus-extension', 57 | 'tus-max-size', 58 | 'tus-resumable', 59 | 'tus-version', 60 | 'upload-concat', 61 | 'upload-defer-length', 62 | 'upload-length', 63 | 'upload-metadata', 64 | 'upload-offset', 65 | ]; 66 | 67 | const SUPPORTED_CHECKSUM_ALGORITHMS = [ 68 | 'crc32', 69 | 'md5', 70 | 'sha1', 71 | 'sha256', 72 | ]; 73 | 74 | const SUPPORTED_EXTENSIONS = [ 75 | 'checksum', 76 | 'creation', 77 | 'expiration', 78 | 'termination', 79 | ]; 80 | 81 | const log = Logger.child({ 82 | namespace: 'createTusMiddleware', 83 | }); 84 | 85 | export default (configurationInput: ConfigurationInputType) => { 86 | const configuration = createConfiguration(configurationInput); 87 | 88 | const router = createRouter(); 89 | 90 | router.use('/', (incomingMessage, outgoingMessage, next) => { 91 | outgoingMessage.set({ 92 | 'access-control-allow-headers': ALLOW_HEADERS.join(', '), 93 | 'access-control-expose-headers': EXPOSE_HEADERS.join(', '), 94 | 'cache-control': 'no-store', 95 | connection: 'close', 96 | 'tus-checksum-algorithm': SUPPORTED_CHECKSUM_ALGORITHMS.join(', '), 97 | 'tus-extension': SUPPORTED_EXTENSIONS.join(', '), 98 | 'tus-resumable': '1.0.0', 99 | 'tus-version': '1.0.0', 100 | 'x-content-type-options': 'nosniff', 101 | }); 102 | 103 | if (incomingMessage.headers['x-http-method-override']) { 104 | outgoingMessage 105 | .status(501) 106 | .end('Not implemented.'); 107 | 108 | return; 109 | } 110 | 111 | if (incomingMessage.method !== 'OPTIONS' && incomingMessage.headers['tus-resumable'] === undefined) { 112 | outgoingMessage 113 | .status(412) 114 | .end('tus-resumable header must be present.'); 115 | 116 | return; 117 | } 118 | 119 | next(); 120 | }); 121 | 122 | router.post('/', async (incomingMessage, outgoingMessage) => { 123 | if (incomingMessage.headers['upload-defer-length']) { 124 | outgoingMessage 125 | .status(501) 126 | .end('Not implemented.'); 127 | 128 | return; 129 | } 130 | 131 | if (!incomingMessage.headers['upload-length']) { 132 | outgoingMessage 133 | .status(400) 134 | .end(); 135 | 136 | return; 137 | } 138 | 139 | const uploadLength = parseUploadLengthHeader(incomingMessage.headers['upload-length']); 140 | const uploadMetadata = parseUploadMetadataHeader(incomingMessage.headers['upload-metadata'] || ''); 141 | 142 | const uid = await configuration.createUid(); 143 | 144 | incomingMessage.tus = { 145 | uid, 146 | }; 147 | 148 | try { 149 | await configuration.createUpload({ 150 | incomingMessage, 151 | uid, 152 | uploadLength, 153 | uploadMetadata, 154 | }); 155 | } catch (error) { 156 | log.error({ 157 | error: serializeError(error), 158 | }, 'upload rejected'); 159 | 160 | throw error; 161 | } 162 | 163 | const upload = await configuration.getUpload(uid); 164 | 165 | if (upload.uploadExpires) { 166 | outgoingMessage 167 | .set('upload-expires', new Date(upload.uploadExpires).toUTCString()); 168 | } 169 | 170 | outgoingMessage 171 | .set({ 172 | location: resolveUrl(configuration.basePath.replace(/\/$/g, '') + '/', uid), 173 | 'upload-uid': upload.uid, 174 | }) 175 | .status(201) 176 | .end(); 177 | }); 178 | 179 | router.options('/', (incomingMessage, outgoingMessage) => { 180 | outgoingMessage 181 | .status(204) 182 | .end(); 183 | }); 184 | 185 | router.patch('/:uid', async (incomingMessage, outgoingMessage) => { 186 | if (incomingMessage.headers['content-type'] !== 'application/offset+octet-stream') { 187 | outgoingMessage 188 | .status(415) 189 | .end(); 190 | 191 | return; 192 | } 193 | 194 | if (incomingMessage.headers['upload-length'] !== undefined) { 195 | outgoingMessage 196 | .status(400) 197 | .end('upload-length has been already sent.'); 198 | 199 | return; 200 | } 201 | 202 | if (incomingMessage.headers['upload-offset'] === undefined) { 203 | outgoingMessage 204 | .status(400) 205 | .end('upload-offset must be present.'); 206 | 207 | return; 208 | } 209 | 210 | const uploadOffset = parseUploadOffsetHeader(incomingMessage.headers['upload-offset']); 211 | 212 | let upload; 213 | 214 | try { 215 | upload = await configuration.getUpload(incomingMessage.params.uid); 216 | } catch (error) { 217 | log.error({ 218 | error: serializeError(error), 219 | }, 'upload not found'); 220 | 221 | throw error; 222 | } 223 | 224 | incomingMessage.tus = { 225 | uid: upload.uid, 226 | }; 227 | 228 | if (upload.uploadOffset !== uploadOffset) { 229 | outgoingMessage 230 | .status(409) 231 | .end('Conflict.'); 232 | 233 | return; 234 | } 235 | 236 | const temporaryFilePath = tmpNameSync(); 237 | 238 | const temporaryFileStream = createWriteStream(temporaryFilePath); 239 | 240 | let chunkLength = 0; 241 | 242 | if (incomingMessage.headers['upload-checksum']) { 243 | const checksum = parseUploadChecksumHeader(incomingMessage.headers['upload-checksum']); 244 | 245 | if (!SUPPORTED_CHECKSUM_ALGORITHMS.includes(checksum.algorithm)) { 246 | outgoingMessage 247 | .status(400) 248 | .end('Unsupported checksum algorithm.'); 249 | 250 | return; 251 | } 252 | 253 | const hash = createHash(checksum.algorithm); 254 | 255 | await pipeline( 256 | incomingMessage, 257 | async function *(chunks) { 258 | for await (const chunk of chunks) { 259 | chunkLength += chunk.length; 260 | 261 | hash.update(chunk); 262 | 263 | yield chunk; 264 | } 265 | }, 266 | temporaryFileStream, 267 | ); 268 | 269 | if (hash.digest('base64') !== checksum.checksum) { 270 | outgoingMessage 271 | .status(460) 272 | .end('Checksum mismatch.'); 273 | 274 | return; 275 | } 276 | } else { 277 | await pipeline( 278 | incomingMessage, 279 | async function *(chunks) { 280 | for await (const chunk of chunks) { 281 | chunkLength += chunk.length; 282 | 283 | yield chunk; 284 | } 285 | }, 286 | temporaryFileStream, 287 | ); 288 | } 289 | 290 | try { 291 | await configuration.upload({ 292 | chunkLength, 293 | filePath: temporaryFilePath, 294 | uid: incomingMessage.params.uid, 295 | uploadLength: upload.uploadLength, 296 | uploadOffset: upload.uploadOffset, 297 | }); 298 | } catch (error) { 299 | log.error({ 300 | error: serializeError(error), 301 | }, 'upload rejected'); 302 | 303 | throw error; 304 | } 305 | 306 | upload = await configuration.getUpload(incomingMessage.params.uid); 307 | 308 | if (upload.uploadExpires) { 309 | outgoingMessage 310 | .set('upload-expires', new Date(upload.uploadExpires).toUTCString()); 311 | } 312 | 313 | outgoingMessage 314 | .set({ 315 | 'upload-offset': upload.uploadOffset, 316 | 'upload-uid': upload.uid, 317 | }) 318 | .status(204) 319 | .end(); 320 | }); 321 | 322 | router.delete('/:uid', async (incomingMessage, outgoingMessage) => { 323 | let upload; 324 | 325 | try { 326 | upload = await configuration.getUpload(incomingMessage.params.uid); 327 | } catch (error) { 328 | log.error({ 329 | error: serializeError(error), 330 | }, 'upload not found'); 331 | 332 | throw error; 333 | } 334 | 335 | incomingMessage.tus = { 336 | uid: upload.uid, 337 | }; 338 | 339 | try { 340 | await configuration.delete(incomingMessage.params.uid); 341 | } catch (error) { 342 | log.error({ 343 | error: serializeError(error), 344 | }, 'upload cannot be deleted'); 345 | 346 | throw error; 347 | } 348 | 349 | outgoingMessage 350 | .status(204) 351 | .end(); 352 | }); 353 | 354 | router.head('/:uid', async (incomingMessage, outgoingMessage) => { 355 | let upload; 356 | 357 | try { 358 | upload = await configuration.getUpload(incomingMessage.params.uid); 359 | } catch (error) { 360 | log.error({ 361 | error: serializeError(error), 362 | }, 'upload not found'); 363 | 364 | throw error; 365 | } 366 | 367 | incomingMessage.tus = { 368 | uid: upload.uid, 369 | }; 370 | 371 | if (upload.uploadExpires) { 372 | outgoingMessage 373 | .set('upload-expires', new Date(upload.uploadExpires).toUTCString()); 374 | } 375 | 376 | if (upload.uploadMetadata) { 377 | outgoingMessage 378 | .set('upload-metadata', formatUploadMetadataHeader(upload.uploadMetadata)); 379 | } 380 | 381 | outgoingMessage 382 | .set({ 383 | 'upload-length': upload.uploadLength, 384 | 'upload-offset': upload.uploadOffset, 385 | }) 386 | .status(200) 387 | .end(); 388 | }); 389 | 390 | return router; 391 | }; 392 | -------------------------------------------------------------------------------- /src/factories/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export {default as createConfiguration} from './createConfiguration'; 4 | export {default as createMemoryStorage} from './createMemoryStorage'; 5 | export {default as createTusMiddleware} from './createTusMiddleware'; 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export { 4 | createTusMiddleware, 5 | createMemoryStorage, 6 | } from './factories'; 7 | export { 8 | formatUploadMetadataHeader, 9 | } from './utilities'; 10 | export { 11 | ExpressTusError, 12 | NotFoundError, 13 | UserError, 14 | } from './errors'; 15 | export type { 16 | ConfigurationInputType, 17 | ConfigurationType, 18 | IncomingMessageType, 19 | StorageType, 20 | UploadInputType, 21 | UploadMetadataType, 22 | UploadType, 23 | UploadUpdateInputType, 24 | } from './types'; 25 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | /* eslint-disable import/exports-last */ 4 | 5 | import { 6 | IncomingMessage as HttpIncomingMessage, 7 | } from 'http'; 8 | import { 9 | IncomingMessage as HttpsIncomingMessage, 10 | } from 'https'; 11 | 12 | export type UploadMetadataType = { 13 | +[key: string]: string, 14 | ... 15 | }; 16 | 17 | export type IncomingMessageType = HttpIncomingMessage | HttpsIncomingMessage; 18 | 19 | export type UploadInputType = {| 20 | +incomingMessage: IncomingMessageType, 21 | +uid: string, 22 | +uploadLength: number, 23 | +uploadMetadata: UploadMetadataType, 24 | |}; 25 | 26 | /** 27 | * @property chunkLength Size of the uploaded chunk (in bytes). 28 | * @property filePath Path to a temporary file containing the uploaded chunk. 29 | * @property uploadLength Indicates the size of the entire upload (in bytes). 30 | */ 31 | export type UploadUpdateInputType = {| 32 | +chunkLength: number, 33 | +filePath: string, 34 | +uid: string, 35 | +uploadLength: number, 36 | +uploadOffset: number, 37 | |}; 38 | 39 | type MaybePromiseType = Promise | T; 40 | 41 | /** 42 | * @property uid Upload UID. 43 | * @property uploadExpires UNIX timestamp (in milliseconds) after which the upload will be deleted. 44 | * @property uploadLength Indicates the size of the entire upload (in bytes). 45 | * @property uploadMetadata Key-value meta-data about the upload. 46 | * @property uploadOffset Indicates a byte offset within a resource. 47 | */ 48 | export type UploadType = {| 49 | +uid: string, 50 | +uploadExpires?: number, 51 | +uploadLength: number, 52 | +uploadMetadata: UploadMetadataType, 53 | +uploadOffset: number, 54 | |}; 55 | 56 | /** 57 | * @property createUpload Approves file upload. Defaults to allowing all uploads. 58 | * @property delete Deletes upload. 59 | * @property getUpload Retrieves progress information about an existing upload. 60 | * @property upload Applies bytes contained in the incoming message at the given offset. 61 | */ 62 | export type StorageType = {| 63 | +createUpload: (input: UploadInputType) => MaybePromiseType, 64 | +delete: (uid: string) => MaybePromiseType, 65 | +getUpload: (uid: string) => MaybePromiseType, 66 | +upload: (input: UploadUpdateInputType) => MaybePromiseType, 67 | |}; 68 | 69 | /** 70 | * @property basePath Path to where the tus middleware is mounted. Used for redirects. Defaults to `/`. 71 | * @property createUid Generates unique identifier for each upload request. Defaults to UUID v4. 72 | */ 73 | export type ConfigurationInputType = {| 74 | +basePath?: string, 75 | +createUid?: () => MaybePromiseType, 76 | ...StorageType, 77 | |}; 78 | 79 | export type ConfigurationType = {| 80 | +basePath: string, 81 | +createUid: () => MaybePromiseType, 82 | ...StorageType, 83 | |}; 84 | -------------------------------------------------------------------------------- /src/utilities/formatUploadMetadataHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { 4 | UploadMetadataType, 5 | } from '../types'; 6 | 7 | export default (uploadMetadata: UploadMetadataType): string => { 8 | const keys = Object 9 | .keys(uploadMetadata) 10 | .sort(); 11 | 12 | const tokens = []; 13 | 14 | for (const key of keys) { 15 | const value = uploadMetadata[key]; 16 | 17 | if (value) { 18 | tokens.push(key + ' ' + Buffer.from(uploadMetadata[key]).toString('base64')); 19 | } else { 20 | tokens.push(key); 21 | } 22 | } 23 | 24 | return tokens.join(', '); 25 | }; 26 | -------------------------------------------------------------------------------- /src/utilities/index.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export {default as formatUploadMetadataHeader} from './formatUploadMetadataHeader'; 4 | export {default as isValidUploadMetadataHeader} from './isValidUploadMetadataHeader'; 5 | export {default as parseUploadChecksumHeader} from './parseUploadChecksumHeader'; 6 | export {default as parseUploadLengthHeader} from './parseUploadLengthHeader'; 7 | export {default as parseUploadMetadataHeader} from './parseUploadMetadataHeader'; 8 | export {default as parseUploadOffsetHeader} from './parseUploadOffsetHeader'; 9 | -------------------------------------------------------------------------------- /src/utilities/isValidUploadMetadataHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import parseUploadMetadataHeader from './parseUploadMetadataHeader'; 4 | 5 | export default (subject: string): boolean => { 6 | try { 7 | parseUploadMetadataHeader(subject); 8 | 9 | return true; 10 | } catch (error) { 11 | return false; 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/utilities/parseUploadChecksumHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import isBase64 from 'is-base64'; 4 | import { 5 | UserError, 6 | } from '../errors'; 7 | 8 | type ChecksumType = {| 9 | +algorithm: string, 10 | +checksum: string, 11 | |}; 12 | 13 | export default (header: string): ChecksumType => { 14 | const tokens = header.trim().split(' '); 15 | 16 | if (tokens.length !== 2) { 17 | throw new UserError('upload-checksum header must consist only of the name of the used checksum algorithm and the base64 encoded checksum separated by a space.'); 18 | } 19 | 20 | if (!isBase64(tokens[1])) { 21 | throw new UserError('checksum must be base64 encoded.'); 22 | } 23 | 24 | return { 25 | algorithm: tokens[0], 26 | checksum: tokens[1], 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/utilities/parseUploadLengthHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | UserError, 5 | } from '../errors'; 6 | 7 | export default (header: string): number => { 8 | if (!/^\s*\d+\s*$/.test(header)) { 9 | throw new UserError('upload-length header value must be an integer.'); 10 | } 11 | 12 | const uploadLength = parseInt(header, 10); 13 | 14 | if (uploadLength < 0) { 15 | throw new UserError('upload-length must be a positive integer.'); 16 | } 17 | 18 | return uploadLength; 19 | }; 20 | -------------------------------------------------------------------------------- /src/utilities/parseUploadMetadataHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import isBase64 from 'is-base64'; 4 | import { 5 | UserError, 6 | } from '../errors'; 7 | import type { 8 | UploadMetadataType, 9 | } from '../types'; 10 | 11 | export default (header: string): UploadMetadataType => { 12 | const pairs = header.split(','); 13 | 14 | const uploadMetadata: Object = {}; 15 | 16 | for (const pair of pairs) { 17 | const tokens = pair 18 | .trim() 19 | .split(' '); 20 | 21 | if (tokens.length > 2) { 22 | throw new UserError('Each upload-metadata key-value pair must have at most 1 space separating key and value.'); 23 | } 24 | 25 | const key = tokens[0]; 26 | 27 | if (uploadMetadata[key]) { 28 | throw new UserError('upload-metadata key must be unique.'); 29 | } 30 | 31 | let value; 32 | 33 | if (!tokens[1]) { 34 | value = ''; 35 | } else if (isBase64(tokens[1])) { 36 | value = Buffer.from(tokens[1], 'base64').toString(); 37 | } else { 38 | throw new UserError('upload-metadata value must be base64 encoded.'); 39 | } 40 | 41 | uploadMetadata[key] = value; 42 | } 43 | 44 | return uploadMetadata; 45 | }; 46 | -------------------------------------------------------------------------------- /src/utilities/parseUploadOffsetHeader.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | UserError, 5 | } from '../errors'; 6 | 7 | export default (header: string): number => { 8 | if (!/^\s*\d+\s*$/.test(header)) { 9 | throw new UserError('upload-offset header value must be an integer.'); 10 | } 11 | 12 | const uploadOffset = parseInt(header, 10); 13 | 14 | if (uploadOffset < 0) { 15 | throw new UserError('upload-offset must be a positive integer.'); 16 | } 17 | 18 | return uploadOffset; 19 | }; 20 | -------------------------------------------------------------------------------- /test/express-tus/factories/createMemoryStorage.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs'; 4 | import { 5 | tmpNameSync, 6 | } from 'tmp'; 7 | import test from 'ava'; 8 | import createMemoryStorage from '../../../src/factories/createMemoryStorage'; 9 | 10 | test('createUpload creates an empty upload', async (t) => { 11 | const storage = {}; 12 | 13 | const memoryStorage = createMemoryStorage({ 14 | storage, 15 | }); 16 | 17 | const upload = await memoryStorage.createUpload({ 18 | uid: 'foo', 19 | uploadLength: 100, 20 | }); 21 | 22 | t.true(storage.foo.buffer.equals(Buffer.alloc(100))); 23 | 24 | t.deepEqual(upload, { 25 | uid: 'foo', 26 | uploadLength: 100, 27 | uploadMetadata: {}, 28 | uploadOffset: 0, 29 | }); 30 | }); 31 | 32 | const createChunk = (buffer) => { 33 | const temporaryFileName = tmpNameSync(); 34 | 35 | fs.writeFileSync(temporaryFileName, buffer); 36 | 37 | return temporaryFileName; 38 | }; 39 | 40 | test('upload applies bytes contained in the incoming message at the given offset', async (t) => { 41 | const storage = {}; 42 | 43 | const memoryStorage = createMemoryStorage({ 44 | storage, 45 | }); 46 | 47 | await memoryStorage.createUpload({ 48 | uid: 'foo', 49 | uploadLength: 6, 50 | }); 51 | 52 | await memoryStorage.upload({ 53 | filePath: createChunk(Buffer.from('bar')), 54 | uid: 'foo', 55 | uploadOffset: 0, 56 | }); 57 | 58 | t.deepEqual(await memoryStorage.getUpload('foo'), { 59 | uid: 'foo', 60 | uploadLength: 6, 61 | uploadMetadata: {}, 62 | uploadOffset: 3, 63 | }); 64 | 65 | await memoryStorage.upload({ 66 | filePath: createChunk(Buffer.from('bar')), 67 | uid: 'foo', 68 | uploadOffset: 3, 69 | }); 70 | 71 | t.deepEqual(await memoryStorage.getUpload('foo'), { 72 | uid: 'foo', 73 | uploadLength: 6, 74 | uploadMetadata: {}, 75 | uploadOffset: 6, 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /test/express-tus/factories/createTusMiddleware.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import sinon from 'sinon'; 5 | import got from 'got'; 6 | import createMemoryStorage from '../../../src/factories/createMemoryStorage'; 7 | import createTestServer from '../../helpers/createTestServer'; 8 | 9 | test('OPTIONS successful response produces 204', async (t) => { 10 | const server = await createTestServer({}); 11 | 12 | const response = await got(server.url, { 13 | method: 'OPTIONS', 14 | }); 15 | 16 | t.is(response.statusCode, 204); 17 | t.is(response.body, ''); 18 | }); 19 | 20 | test('OPTIONS describes tus-version', async (t) => { 21 | const server = await createTestServer({}); 22 | 23 | const response = await got(server.url, { 24 | method: 'OPTIONS', 25 | }); 26 | 27 | t.is(response.headers['tus-version'], '1.0.0'); 28 | }); 29 | 30 | test('OPTIONS describes tus-extension', async (t) => { 31 | const server = await createTestServer({}); 32 | 33 | const response = await got(server.url, { 34 | method: 'OPTIONS', 35 | }); 36 | 37 | t.is(response.headers['tus-extension'], 'checksum, creation, expiration, termination'); 38 | }); 39 | 40 | test('OPTIONS describes tus-checksum-algorithm', async (t) => { 41 | const server = await createTestServer({}); 42 | 43 | const response = await got(server.url, { 44 | method: 'OPTIONS', 45 | }); 46 | 47 | t.is(response.headers['tus-checksum-algorithm'], 'crc32, md5, sha1, sha256'); 48 | }); 49 | 50 | test('empty POST creates a new upload resource', async (t) => { 51 | const server = await createTestServer({ 52 | ...createMemoryStorage(), 53 | createUid: () => { 54 | return 'foo'; 55 | }, 56 | }); 57 | 58 | const response = await got(server.url, { 59 | headers: { 60 | 'tus-resumable': '1.0.0', 61 | 'upload-length': '100', 62 | }, 63 | method: 'POST', 64 | }); 65 | 66 | t.is(response.headers.location, '/foo'); 67 | t.is(response.statusCode, 201); 68 | t.is(response.body, ''); 69 | }); 70 | 71 | test('location is resolved using base-path configuration', async (t) => { 72 | const server = await createTestServer({ 73 | ...createMemoryStorage(), 74 | basePath: '/foo', 75 | createUid: () => { 76 | return 'bar'; 77 | }, 78 | }); 79 | 80 | const response = await got(server.url, { 81 | headers: { 82 | 'tus-resumable': '1.0.0', 83 | 'upload-length': '100', 84 | }, 85 | method: 'POST', 86 | }); 87 | 88 | t.is(response.headers.location, '/foo/bar'); 89 | t.is(response.statusCode, 201); 90 | t.is(response.body, ''); 91 | }); 92 | 93 | test('x-http-method-override produces 501 (not implemented)', async (t) => { 94 | const server = await createTestServer({}); 95 | 96 | const response = await got(server.url, { 97 | headers: { 98 | 'tus-resumable': '1.0.0', 99 | 'x-http-method-override': 'PATCH', 100 | }, 101 | throwHttpErrors: false, 102 | }); 103 | 104 | t.is(response.statusCode, 501); 105 | t.is(response.body, 'Not implemented.'); 106 | }); 107 | 108 | test('upload-defer-length produces 501 (not implemented)', async (t) => { 109 | const server = await createTestServer({}); 110 | 111 | const response = await got(server.url, { 112 | headers: { 113 | 'tus-resumable': '1.0.0', 114 | 'upload-defer-length': '1', 115 | }, 116 | method: 'POST', 117 | throwHttpErrors: false, 118 | }); 119 | 120 | t.is(response.statusCode, 501); 121 | t.is(response.body, 'Not implemented.'); 122 | }); 123 | 124 | test('createUpload is called with the original incomingMessage', async (t) => { 125 | const createUpload = sinon.stub().returns(null); 126 | 127 | const server = await createTestServer({ 128 | createUpload, 129 | getUpload: () => { 130 | return { 131 | uploadLength: 100, 132 | uploadMetadata: {}, 133 | uploadOffset: 0, 134 | }; 135 | }, 136 | }); 137 | 138 | await got(server.url, { 139 | headers: { 140 | foo: 'bar', 141 | 'tus-resumable': '1.0.0', 142 | 'upload-length': '100', 143 | }, 144 | method: 'POST', 145 | }); 146 | 147 | t.is(createUpload.firstCall.firstArg.incomingMessage.headers.foo, 'bar'); 148 | t.is(createUpload.firstCall.firstArg.incomingMessage.url, '/'); 149 | }); 150 | 151 | test('createUpload is called with the original upload-metadata', async (t) => { 152 | const createUpload = sinon.stub().returns(null); 153 | 154 | const server = await createTestServer({ 155 | createUpload, 156 | getUpload: () => { 157 | return { 158 | uploadLength: 100, 159 | uploadMetadata: {}, 160 | uploadOffset: 0, 161 | }; 162 | }, 163 | }); 164 | 165 | await got(server.url, { 166 | headers: { 167 | foo: 'bar', 168 | 'tus-resumable': '1.0.0', 169 | 'upload-length': '100', 170 | 'upload-metadata': 'foo YmFy, baz cXV4', 171 | }, 172 | method: 'POST', 173 | }); 174 | 175 | t.deepEqual(createUpload.firstCall.firstArg.uploadMetadata, { 176 | baz: 'qux', 177 | foo: 'bar', 178 | }); 179 | }); 180 | 181 | test('createUpload is called with the original upload-length', async (t) => { 182 | const createUpload = sinon.stub().returns(null); 183 | 184 | const server = await createTestServer({ 185 | createUpload, 186 | getUpload: () => { 187 | return { 188 | uploadLength: 100, 189 | uploadMetadata: {}, 190 | uploadOffset: 0, 191 | }; 192 | }, 193 | }); 194 | 195 | await got(server.url, { 196 | headers: { 197 | foo: 'bar', 198 | 'tus-resumable': '1.0.0', 199 | 'upload-length': '100', 200 | }, 201 | method: 'POST', 202 | }); 203 | 204 | t.is(createUpload.firstCall.firstArg.uploadLength, 100); 205 | }); 206 | 207 | test('PATCH request with unsupported content-type produces 415', async (t) => { 208 | const server = await createTestServer({}); 209 | 210 | const response = await got(server.url + '/foo', { 211 | headers: { 212 | 'content-type': 'application/json', 213 | 'tus-resumable': '1.0.0', 214 | }, 215 | method: 'PATCH', 216 | throwHttpErrors: false, 217 | }); 218 | 219 | t.is(response.statusCode, 415); 220 | }); 221 | 222 | test('PATCH with an unexpected upload-offset produces 409 conflict', async (t) => { 223 | const server = await createTestServer({ 224 | getUpload: () => { 225 | return { 226 | uploadOffset: 0, 227 | }; 228 | }, 229 | }); 230 | 231 | const response = await got(server.url + '/foo', { 232 | headers: { 233 | 'content-type': 'application/offset+octet-stream', 234 | 'tus-resumable': '1.0.0', 235 | 'upload-offset': '50', 236 | }, 237 | method: 'PATCH', 238 | throwHttpErrors: false, 239 | }); 240 | 241 | t.is(response.statusCode, 409); 242 | }); 243 | 244 | test('produces 400 if PATCH request is made without upload-offset', async (t) => { 245 | const server = await createTestServer({ 246 | getUpload: () => { 247 | return { 248 | uploadOffset: 0, 249 | }; 250 | }, 251 | }); 252 | 253 | const response = await got(server.url + '/foo', { 254 | headers: { 255 | 'content-type': 'application/offset+octet-stream', 256 | 'tus-resumable': '1.0.0', 257 | }, 258 | method: 'PATCH', 259 | throwHttpErrors: false, 260 | }); 261 | 262 | t.is(response.statusCode, 400); 263 | }); 264 | 265 | test('successful PATCH produces 204', async (t) => { 266 | const upload = sinon.stub(); 267 | 268 | const server = await createTestServer({ 269 | getUpload: () => { 270 | return { 271 | uploadLength: 100, 272 | uploadOffset: 0, 273 | }; 274 | }, 275 | upload, 276 | }); 277 | 278 | const response = await got(server.url + '/foo', { 279 | body: Buffer.from('bar'), 280 | headers: { 281 | 'content-type': 'application/offset+octet-stream', 282 | 'tus-resumable': '1.0.0', 283 | 'upload-offset': '0', 284 | }, 285 | method: 'PATCH', 286 | }); 287 | 288 | t.is(response.statusCode, 204); 289 | t.is(upload.firstCall.firstArg.uploadLength, 100); 290 | t.is(upload.firstCall.firstArg.chunkLength, 3); 291 | }); 292 | 293 | test('successful HEAD produces 200', async (t) => { 294 | const server = await createTestServer({ 295 | getUpload: () => { 296 | return { 297 | uploadLength: 100, 298 | uploadMetadata: {}, 299 | uploadOffset: 100, 300 | }; 301 | }, 302 | }); 303 | 304 | const response = await got(server.url + '/foo', { 305 | headers: { 306 | 'tus-resumable': '1.0.0', 307 | }, 308 | method: 'HEAD', 309 | }); 310 | 311 | t.is(response.statusCode, 200); 312 | }); 313 | 314 | test('successful HEAD describes upload-length', async (t) => { 315 | const server = await createTestServer({ 316 | getUpload: () => { 317 | return { 318 | uploadLength: 100, 319 | uploadMetadata: {}, 320 | uploadOffset: 50, 321 | }; 322 | }, 323 | }); 324 | 325 | const response = await got(server.url + '/foo', { 326 | headers: { 327 | 'tus-resumable': '1.0.0', 328 | }, 329 | method: 'HEAD', 330 | }); 331 | 332 | t.is(response.headers['upload-length'], '100'); 333 | }); 334 | 335 | test('successful HEAD describes upload-offset', async (t) => { 336 | const server = await createTestServer({ 337 | getUpload: () => { 338 | return { 339 | uploadLength: 100, 340 | uploadMetadata: {}, 341 | uploadOffset: 50, 342 | }; 343 | }, 344 | }); 345 | 346 | const response = await got(server.url + '/foo', { 347 | headers: { 348 | 'tus-resumable': '1.0.0', 349 | }, 350 | method: 'HEAD', 351 | }); 352 | 353 | t.is(response.headers['upload-offset'], '50'); 354 | }); 355 | 356 | test('successful HEAD describes meta-data', async (t) => { 357 | const server = await createTestServer({ 358 | getUpload: () => { 359 | return { 360 | uploadLength: 100, 361 | uploadMetadata: { 362 | baz: 'qux', 363 | foo: 'bar', 364 | }, 365 | uploadOffset: 50, 366 | }; 367 | }, 368 | }); 369 | 370 | const response = await got(server.url + '/foo', { 371 | headers: { 372 | 'tus-resumable': '1.0.0', 373 | }, 374 | method: 'HEAD', 375 | }); 376 | 377 | t.is(response.headers['upload-metadata'], 'baz cXV4, foo YmFy'); 378 | }); 379 | 380 | test('successful DELETE responds with 204', async (t) => { 381 | const deleteUpload = sinon.stub(); 382 | 383 | const server = await createTestServer({ 384 | delete: deleteUpload, 385 | getUpload: () => { 386 | return { 387 | uploadLength: 100, 388 | uploadOffset: 0, 389 | }; 390 | }, 391 | }); 392 | 393 | const response = await got(server.url + '/foo', { 394 | headers: { 395 | 'tus-resumable': '1.0.0', 396 | }, 397 | method: 'DELETE', 398 | }); 399 | 400 | t.is(response.statusCode, 204); 401 | t.is(deleteUpload.firstCall.firstArg, 'foo'); 402 | }); 403 | 404 | test('POST describes upload-expires', async (t) => { 405 | const uploadExpires = Date.now() + 30 * 1000; 406 | 407 | const server = await createTestServer({ 408 | createUpload: () => { 409 | return null; 410 | }, 411 | getUpload: () => { 412 | return { 413 | uploadExpires, 414 | uploadLength: 100, 415 | uploadOffset: 0, 416 | }; 417 | }, 418 | }); 419 | 420 | const response = await got(server.url, { 421 | headers: { 422 | 'tus-resumable': '1.0.0', 423 | 'upload-length': '100', 424 | }, 425 | method: 'POST', 426 | }); 427 | 428 | t.is(new Date(response.headers['upload-expires']).getTime(), Math.floor(uploadExpires / 1000) * 1000); 429 | }); 430 | 431 | test('PATCH describes upload-expires', async (t) => { 432 | const uploadExpires = Date.now() + 30 * 1000; 433 | 434 | const server = await createTestServer({ 435 | getUpload: () => { 436 | return { 437 | uploadExpires, 438 | uploadOffset: 0, 439 | }; 440 | }, 441 | upload: () => { 442 | 443 | }, 444 | }); 445 | 446 | const response = await got(server.url + '/foo', { 447 | headers: { 448 | 'content-type': 'application/offset+octet-stream', 449 | 'tus-resumable': '1.0.0', 450 | 'upload-offset': '0', 451 | }, 452 | method: 'PATCH', 453 | }); 454 | 455 | t.is(new Date(response.headers['upload-expires']).getTime(), Math.floor(uploadExpires / 1000) * 1000); 456 | }); 457 | 458 | test('HEAD describes upload-expires', async (t) => { 459 | const uploadExpires = Date.now() + 30 * 1000; 460 | 461 | const server = await createTestServer({ 462 | getUpload: () => { 463 | return { 464 | uploadExpires, 465 | uploadLength: 100, 466 | uploadMetadata: {}, 467 | uploadOffset: 50, 468 | }; 469 | }, 470 | }); 471 | 472 | const response = await got(server.url + '/foo', { 473 | headers: { 474 | 'tus-resumable': '1.0.0', 475 | }, 476 | method: 'HEAD', 477 | }); 478 | 479 | t.is(new Date(response.headers['upload-expires']).getTime(), Math.floor(uploadExpires / 1000) * 1000); 480 | }); 481 | 482 | test('validates checksum', async (t) => { 483 | const server = await createTestServer({ 484 | getUpload: () => { 485 | return { 486 | uploadOffset: 0, 487 | }; 488 | }, 489 | upload: () => { 490 | 491 | }, 492 | }); 493 | 494 | const response = await got(server.url + '/foo', { 495 | body: Buffer.from('bar'), 496 | headers: { 497 | 'content-type': 'application/offset+octet-stream', 498 | 'tus-resumable': '1.0.0', 499 | 'upload-checksum': 'sha1 Ys23Ag/5IOWqZCw9QGaVDdHwH00=', 500 | 'upload-offset': '0', 501 | }, 502 | method: 'PATCH', 503 | }); 504 | 505 | t.is(response.statusCode, 204); 506 | }); 507 | 508 | test('produces 400 error if checksum algorithm is not supported', async (t) => { 509 | const server = await createTestServer({ 510 | getUpload: () => { 511 | return { 512 | uploadOffset: 0, 513 | }; 514 | }, 515 | upload: () => { 516 | 517 | }, 518 | }); 519 | 520 | const response = await got(server.url + '/foo', { 521 | body: Buffer.from('bar'), 522 | headers: { 523 | 'content-type': 'application/offset+octet-stream', 524 | 'tus-resumable': '1.0.0', 525 | 'upload-checksum': 'sha512 Ys23Ag/5IOWqZCw9QGaVDdHwH00=', 526 | 'upload-offset': '0', 527 | }, 528 | method: 'PATCH', 529 | throwHttpErrors: false, 530 | }); 531 | 532 | t.is(response.statusCode, 400); 533 | }); 534 | 535 | test('discards chunk if checksum does not match', async (t) => { 536 | const server = await createTestServer({ 537 | ...createMemoryStorage(), 538 | createUid: () => { 539 | return 'foo'; 540 | }, 541 | }); 542 | 543 | await got(server.url, { 544 | headers: { 545 | 'tus-resumable': '1.0.0', 546 | 'upload-length': '6', 547 | }, 548 | method: 'POST', 549 | }); 550 | 551 | const response1 = await got(server.url + '/foo', { 552 | body: Buffer.from('bar'), 553 | headers: { 554 | 'content-type': 'application/offset+octet-stream', 555 | 'tus-resumable': '1.0.0', 556 | 'upload-checksum': 'sha1 Ys23Ag/5IOWqZCw9QGaVDdHwH00=', 557 | 'upload-offset': '0', 558 | }, 559 | method: 'PATCH', 560 | }); 561 | 562 | t.is(response1.statusCode, 204); 563 | 564 | const response2 = await got(server.url + '/foo', { 565 | body: Buffer.from('baz'), 566 | headers: { 567 | 'content-type': 'application/offset+octet-stream', 568 | 'tus-resumable': '1.0.0', 569 | 'upload-checksum': 'sha1 Ys23Ag/5IOWqZCw9QGaVDdHwH00=', 570 | 'upload-offset': '3', 571 | }, 572 | method: 'PATCH', 573 | throwHttpErrors: false, 574 | }); 575 | 576 | t.is(response2.statusCode, 460); 577 | 578 | const response3 = await got(server.url + '/foo', { 579 | headers: { 580 | 'tus-resumable': '1.0.0', 581 | }, 582 | method: 'HEAD', 583 | }); 584 | 585 | t.is(response3.statusCode, 200); 586 | t.is(response3.headers['upload-length'], '6'); 587 | t.is(response3.headers['upload-offset'], '3'); 588 | }); 589 | -------------------------------------------------------------------------------- /test/express-tus/integration.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { 4 | resolve as resolveUrl, 5 | } from 'url'; 6 | import test from 'ava'; 7 | import got from 'got'; 8 | import cloneBuffer from 'clone-buffer'; 9 | import createMemoryStorage from '../../src/factories/createMemoryStorage'; 10 | import createTestServer from '../helpers/createTestServer'; 11 | 12 | test('uploads file', async (t) => { 13 | const storage = {}; 14 | 15 | const server = await createTestServer({ 16 | ...createMemoryStorage({ 17 | storage, 18 | }), 19 | createUid: () => { 20 | return 'foo'; 21 | }, 22 | }); 23 | 24 | const response0 = await got(server.url, { 25 | headers: { 26 | 'tus-resumable': '1.0.0', 27 | 'upload-length': '3', 28 | }, 29 | method: 'POST', 30 | }); 31 | 32 | t.is(response0.headers['upload-uid'], 'foo'); 33 | 34 | const response1 = await got(resolveUrl(server.url, response0.headers.location), { 35 | body: Buffer.from('bar'), 36 | headers: { 37 | 'content-type': 'application/offset+octet-stream', 38 | 'tus-resumable': '1.0.0', 39 | 'upload-offset': '0', 40 | }, 41 | method: 'PATCH', 42 | }); 43 | 44 | t.is(response1.headers['upload-uid'], 'foo'); 45 | 46 | t.is(response1.statusCode, 204); 47 | t.is(response1.headers['upload-offset'], '3'); 48 | 49 | t.is(storage.foo.uploadLength, 3); 50 | t.is(storage.foo.uploadOffset, 3); 51 | 52 | t.true(storage.foo.buffer.equals(Buffer.from('bar'))); 53 | }); 54 | 55 | test('uploads file (multiple patch requests)', async (t) => { 56 | const storage = {}; 57 | 58 | const server = await createTestServer({ 59 | ...createMemoryStorage({ 60 | storage, 61 | }), 62 | createUid: () => { 63 | return 'foo'; 64 | }, 65 | }); 66 | 67 | const response0 = await got(server.url, { 68 | headers: { 69 | 'tus-resumable': '1.0.0', 70 | 'upload-length': '6', 71 | }, 72 | method: 'POST', 73 | }); 74 | 75 | await got(resolveUrl(server.url, response0.headers.location), { 76 | body: Buffer.from('bar'), 77 | headers: { 78 | 'content-type': 'application/offset+octet-stream', 79 | 'tus-resumable': '1.0.0', 80 | 'upload-offset': '0', 81 | }, 82 | method: 'PATCH', 83 | }); 84 | 85 | const response2 = await got(resolveUrl(server.url, response0.headers.location), { 86 | body: Buffer.from('baz'), 87 | headers: { 88 | 'content-type': 'application/offset+octet-stream', 89 | 'tus-resumable': '1.0.0', 90 | 'upload-offset': '3', 91 | }, 92 | method: 'PATCH', 93 | }); 94 | 95 | t.is(response2.statusCode, 204); 96 | t.is(response2.headers['upload-offset'], '6'); 97 | 98 | t.is(storage.foo.uploadLength, 6); 99 | t.is(storage.foo.uploadOffset, 6); 100 | 101 | t.true(storage.foo.buffer.equals(Buffer.from('barbaz'))); 102 | }); 103 | 104 | test('does not modify source (wrong offset)', async (t) => { 105 | const storage = {}; 106 | 107 | const server = await createTestServer({ 108 | ...createMemoryStorage({ 109 | storage, 110 | }), 111 | createUid: () => { 112 | return 'foo'; 113 | }, 114 | }); 115 | 116 | const response0 = await got(server.url, { 117 | headers: { 118 | 'tus-resumable': '1.0.0', 119 | 'upload-length': '6', 120 | }, 121 | method: 'POST', 122 | }); 123 | 124 | await got(resolveUrl(server.url, response0.headers.location), { 125 | body: Buffer.from('bar'), 126 | headers: { 127 | 'content-type': 'application/offset+octet-stream', 128 | 'tus-resumable': '1.0.0', 129 | 'upload-offset': '0', 130 | }, 131 | method: 'PATCH', 132 | }); 133 | 134 | const buffer = cloneBuffer(storage.foo.buffer); 135 | 136 | const response2 = await got(resolveUrl(server.url, response0.headers.location), { 137 | body: Buffer.from('baz'), 138 | headers: { 139 | 'content-type': 'application/offset+octet-stream', 140 | 'tus-resumable': '1.0.0', 141 | 'upload-offset': '1', 142 | }, 143 | method: 'PATCH', 144 | throwHttpErrors: false, 145 | }); 146 | 147 | t.is(response2.statusCode, 409); 148 | 149 | t.is(storage.foo.uploadLength, 6); 150 | t.is(storage.foo.uploadOffset, 3); 151 | 152 | t.true(storage.foo.buffer.equals(buffer)); 153 | }); 154 | -------------------------------------------------------------------------------- /test/express-tus/utilities/parseUploadMetadata.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import test from 'ava'; 4 | import parseUploadMetadataHeader from '../../../src/utilities/parseUploadMetadataHeader'; 5 | 6 | test('parses upload-metadata (one value)', (t) => { 7 | t.deepEqual(parseUploadMetadataHeader('foo YmFy'), { 8 | foo: 'bar', 9 | }); 10 | }); 11 | 12 | test('parses upload-metadata (multiple values)', (t) => { 13 | t.deepEqual(parseUploadMetadataHeader('foo YmFy, baz cXV4'), { 14 | baz: 'qux', 15 | foo: 'bar', 16 | }); 17 | }); 18 | 19 | test('parses keys without value', (t) => { 20 | t.deepEqual(parseUploadMetadataHeader('foo, baz'), { 21 | baz: '', 22 | foo: '', 23 | }); 24 | }); 25 | 26 | test('throws (multiple spaces)', (t) => { 27 | t.throws(() => { 28 | parseUploadMetadataHeader('foo baz'); 29 | }); 30 | }); 31 | 32 | test('throws (duplicate keys)', (t) => { 33 | t.throws(() => { 34 | parseUploadMetadataHeader('foo foo'); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/helpers/createHttpServerWithRandomPort.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import http, { 4 | IncomingMessage as HttpIncomingMessage, 5 | ServerResponse as HttpServerResponse, 6 | } from 'http'; 7 | import { 8 | createHttpTerminator, 9 | } from 'http-terminator'; 10 | 11 | type RequestHandlerType = (incomingMessage: HttpIncomingMessage, outgoingMessage: HttpServerResponse, stop: () => Promise) => Promise | void; 12 | 13 | type HttpServerType = {| 14 | +stop: () => Promise, 15 | +url: string, 16 | |}; 17 | 18 | export default async (responseBody: string | Buffer | RequestHandlerType): Promise => { 19 | const router = (request, response) => { 20 | if (typeof responseBody === 'function') { 21 | // eslint-disable-next-line no-use-before-define 22 | responseBody(request, response, stop); 23 | } else if (responseBody) { 24 | response.end(responseBody); 25 | } 26 | }; 27 | 28 | const server = http.createServer(router); 29 | 30 | const httpTerminator = createHttpTerminator({ 31 | server, 32 | }); 33 | 34 | const stop = async () => { 35 | await httpTerminator.terminate(); 36 | }; 37 | 38 | return new Promise((resolve, reject) => { 39 | server.once('error', reject); 40 | 41 | // eslint-disable-next-line no-undefined 42 | server.listen(0, undefined, undefined, () => { 43 | const port = server.address().port; 44 | const url = 'http://localhost:' + port; 45 | 46 | resolve({ 47 | stop, 48 | url, 49 | }); 50 | }); 51 | }); 52 | }; 53 | -------------------------------------------------------------------------------- /test/helpers/createTestServer.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import express from 'express'; 4 | import createTusMiddleware from '../../src/factories/createTusMiddleware'; 5 | import createHttpServerWithRandomPort from './createHttpServerWithRandomPort'; 6 | 7 | type HttpServerType = {| 8 | +stop: () => Promise, 9 | +url: string, 10 | |}; 11 | 12 | export default async (configuration): Promise => { 13 | const app = express(); 14 | 15 | app.disable('x-powered-by'); 16 | 17 | app.use(createTusMiddleware(configuration)); 18 | 19 | // eslint-disable-next-line no-unused-vars 20 | app.use((error, incomingMessage, outgoingMessage, next) => { 21 | // eslint-disable-next-line no-console 22 | console.error(error); 23 | 24 | outgoingMessage 25 | .status(500) 26 | .end(JSON.stringify(error)); 27 | }); 28 | 29 | return await createHttpServerWithRandomPort(app); 30 | }; 31 | --------------------------------------------------------------------------------