Bläddra i källkod

Merge pull request #953 from transloadit/lerna-uppy-server

Lerna uppy server
Ifedapo .A. Olarewaju 6 år sedan
förälder
incheckning
9b5f8c6aa6
100 ändrade filer med 5307 tillägg och 330 borttagningar
  1. 1 1
      .eslintignore
  2. 3 1
      .gitignore
  3. 0 48
      bin/build-js.js
  4. 1 1
      bin/build-lib.js
  5. 11 0
      bin/companion
  6. 0 15
      bin/start-server.js
  7. 1 1
      examples/custom-provider/client/MyCustomProvider.js
  8. 0 0
      output/.keep
  9. 236 236
      package-lock.json
  10. 6 4
      package.json
  11. 1 1
      packages/@uppy/aws-s3-multipart/package.json
  12. 1 1
      packages/@uppy/aws-s3-multipart/src/index.js
  13. 0 0
      packages/@uppy/companion-client/LICENSE
  14. 4 4
      packages/@uppy/companion-client/README.md
  15. 1 1
      packages/@uppy/companion-client/package.json
  16. 0 0
      packages/@uppy/companion-client/src/Provider.js
  17. 0 0
      packages/@uppy/companion-client/src/RequestClient.js
  18. 0 0
      packages/@uppy/companion-client/src/RequestClient.test.js
  19. 0 0
      packages/@uppy/companion-client/src/Socket.js
  20. 0 0
      packages/@uppy/companion-client/src/Socket.test.js
  21. 0 0
      packages/@uppy/companion-client/src/index.js
  22. 0 0
      packages/@uppy/companion-client/types/index.d.ts
  23. 8 0
      packages/@uppy/companion/.eslintrc.json
  24. 44 0
      packages/@uppy/companion/.gitignore
  25. 84 0
      packages/@uppy/companion/ARCHITECTURE.md
  26. 20 0
      packages/@uppy/companion/Dockerfile
  27. 18 0
      packages/@uppy/companion/Dockerfile.test
  28. 115 0
      packages/@uppy/companion/KUBERNETES.md
  29. 21 0
      packages/@uppy/companion/LICENSE
  30. 40 0
      packages/@uppy/companion/Makefile
  31. 99 0
      packages/@uppy/companion/README.md
  32. 16 0
      packages/@uppy/companion/docker-compose-dev.yml
  33. 8 0
      packages/@uppy/companion/docker-compose-test.yml
  34. 12 0
      packages/@uppy/companion/docker-compose.yml
  35. 17 0
      packages/@uppy/companion/env.test.sh
  36. 53 0
      packages/@uppy/companion/examples/serverless/index.js
  37. 12 0
      packages/@uppy/companion/examples/serverless/package.json
  38. 32 0
      packages/@uppy/companion/examples/serverless/serverless.yml
  39. 112 0
      packages/@uppy/companion/infra/kube/companion/companion-kube.yaml
  40. 115 0
      packages/@uppy/companion/infra/kube/companion/companion-redis.yaml
  41. 49 0
      packages/@uppy/companion/infra/kube/gcloud-deploy.sh
  42. 13 0
      packages/@uppy/companion/nodemon.json
  43. 1051 0
      packages/@uppy/companion/package-lock.json
  44. 106 0
      packages/@uppy/companion/package.json
  45. 19 0
      packages/@uppy/companion/src/config/grant.js
  46. 405 0
      packages/@uppy/companion/src/server/Uploader.js
  47. 30 0
      packages/@uppy/companion/src/server/controllers/authorized.js
  48. 51 0
      packages/@uppy/companion/src/server/controllers/callback.js
  49. 25 0
      packages/@uppy/companion/src/server/controllers/connect.js
  50. 51 0
      packages/@uppy/companion/src/server/controllers/get.js
  51. 10 0
      packages/@uppy/companion/src/server/controllers/index.js
  52. 13 0
      packages/@uppy/companion/src/server/controllers/list.js
  53. 28 0
      packages/@uppy/companion/src/server/controllers/logout.js
  54. 26 0
      packages/@uppy/companion/src/server/controllers/oauth-redirect.js
  55. 282 0
      packages/@uppy/companion/src/server/controllers/s3.js
  56. 15 0
      packages/@uppy/companion/src/server/controllers/thumbnail.js
  57. 95 0
      packages/@uppy/companion/src/server/controllers/url.js
  58. 5 0
      packages/@uppy/companion/src/server/emitter/default-emitter.js
  59. 16 0
      packages/@uppy/companion/src/server/emitter/index.js
  60. 63 0
      packages/@uppy/companion/src/server/emitter/redis-emitter.js
  61. 64 0
      packages/@uppy/companion/src/server/header-blacklist.js
  62. 44 0
      packages/@uppy/companion/src/server/helpers/jwt.js
  63. 29 0
      packages/@uppy/companion/src/server/helpers/oauth-state.js
  64. 112 0
      packages/@uppy/companion/src/server/helpers/utils.js
  65. 55 0
      packages/@uppy/companion/src/server/jobs.js
  66. 49 0
      packages/@uppy/companion/src/server/logger.js
  67. 42 0
      packages/@uppy/companion/src/server/middlewares.js
  68. 73 0
      packages/@uppy/companion/src/server/provider/drive.js
  69. 133 0
      packages/@uppy/companion/src/server/provider/dropbox.js
  70. 177 0
      packages/@uppy/companion/src/server/provider/index.js
  71. 91 0
      packages/@uppy/companion/src/server/provider/instagram.js
  72. 20 0
      packages/@uppy/companion/src/server/redis.js
  73. 197 0
      packages/@uppy/companion/src/standalone/helper.js
  74. 143 0
      packages/@uppy/companion/src/standalone/index.js
  75. 11 0
      packages/@uppy/companion/src/standalone/start-server.js
  76. 251 0
      packages/@uppy/companion/src/uppy.js
  77. 43 0
      packages/@uppy/companion/test/__mocks__/purest.js
  78. 7 0
      packages/@uppy/companion/test/__mocks__/tus-js-client.js
  79. 149 0
      packages/@uppy/companion/test/__tests__/companion.js
  80. 58 0
      packages/@uppy/companion/test/__tests__/header-blacklist.js
  81. 67 0
      packages/@uppy/companion/test/__tests__/provider-manager.js
  82. 16 0
      packages/@uppy/companion/test/mockserver.js
  83. 0 0
      packages/@uppy/companion/test/output/.keep
  84. 15 0
      packages/@uppy/companion/tsconfig.json
  85. 1 1
      packages/@uppy/dropbox/package.json
  86. 1 1
      packages/@uppy/dropbox/src/index.js
  87. 1 1
      packages/@uppy/dropbox/types/index.d.ts
  88. 1 1
      packages/@uppy/google-drive/package.json
  89. 1 1
      packages/@uppy/google-drive/src/index.js
  90. 1 1
      packages/@uppy/google-drive/types/index.d.ts
  91. 1 1
      packages/@uppy/instagram/package.json
  92. 1 1
      packages/@uppy/instagram/src/index.js
  93. 1 1
      packages/@uppy/instagram/types/index.d.ts
  94. 1 1
      packages/@uppy/transloadit/package.json
  95. 1 1
      packages/@uppy/tus/package.json
  96. 1 1
      packages/@uppy/tus/src/index.js
  97. 1 1
      packages/@uppy/url/package.json
  98. 1 1
      packages/@uppy/url/src/index.js
  99. 1 1
      packages/@uppy/xhr-upload/package.json
  100. 1 1
      packages/@uppy/xhr-upload/src/index.js

+ 1 - 1
.eslintignore

@@ -1,7 +1,7 @@
 node_modules
 lib
 dist
-coverage/**
+coverage
 test/lib/**
 website/private_modules/hexo-renderer-uppyexamples/node_modules/**
 website/public/**

+ 3 - 1
.gitignore

@@ -18,9 +18,11 @@ website/themes/uppy/source/uppy/
 website/themes/uppy/_config.yml
 
 npm-debug.log*
-config/
 nohup.out
 
 examples/bundled-example/bundle.js
 uppy-*.tgz
 .eslintcache
+
+output/*
+!output/.keep

+ 0 - 48
bin/build-js.js

@@ -41,57 +41,9 @@ function buildUppyBundle (minify) {
   })
 }
 
-// function copyLocales () {
-//   var copyCommand = 'cp -R ' + path.join(srcPath, 'locales/') + ' ' + path.join(distPath, 'locales/')
-//   return new Promise(function (resolve, reject) {
-//     exec(copyCommand, function (error, stdout, stderr) {
-//       if (error) {
-//         handleErr(error)
-//         reject(error)
-//         return
-//       }
-//       console.info(chalk.green('✓ Copied locales to dist'))
-//       resolve()
-//     })
-//   })
-// }
-
-// function buildLocale (file) {
-//   return new Promise(function (resolve, reject) {
-//     var fileName = path.basename(file, '.js')
-//     browserify(file)
-//       .transform(babelify)
-//       .on('error', handleErr)
-//       .bundle()
-//       .pipe(fs.createWriteStream('./dist/locales/' + fileName + '.js', 'utf8'))
-//       .on('error', handleErr)
-//       .on('finish', function () {
-//         console.info(chalk.green('✓ Built Locale:'), chalk.magenta(fileName + '.js'))
-//         resolve()
-//       })
-//   })
-// }
-
-// function buildUppyLocales () {
-//   mkdirp.sync('./dist/locales')
-//   var localePromises = []
-//   glob('./src/locales/*.js', function (err, files) {
-//     if (err) console.log(err)
-//     files.forEach(function (file) {
-//       localePromises.push(buildLocale(file))
-//     })
-//   })
-//   return Promise.all(localePromises)
-// }
-
 mkdirp.sync(distPath)
 
 Promise.all([buildUppyBundle(), buildUppyBundle(true)])
   .then(function () {
     console.info(chalk.yellow('✓ JS Bundle 🎉'))
   })
-
-// Promise.all([buildUppyBundle(), buildUppyBundle(true), buildUppyLocales()])
-//   .then(function () {
-//     console.info(chalk.yellow('✓ JS Bundle 🎉'))
-//   })

+ 1 - 1
bin/build-lib.js

@@ -11,7 +11,7 @@ const writeFile = promisify(fs.writeFile)
 
 const SOURCE = 'packages/{*,@uppy/*}/src/**/*.js'
 // Files not to build (such as tests)
-const IGNORE = /\.test\.js$|__mocks__/
+const IGNORE = /\.test\.js$|__mocks__|companion\//
 
 async function buildLib () {
   const files = await glob(SOURCE)

+ 11 - 0
bin/companion

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+sh ../env.sh
+
+env \
+  UPPYSERVER_DATADIR="./output" \
+  UPPYSERVER_DOMAIN="localhost:3020" \
+  UPPYSERVER_PROTOCOL="http" \
+  UPPYSERVER_PORT=3020 \
+  UPPY_ENDPOINTS="" \
+  UPPYSERVER_SECRET="development" \
+nodemon --watch packages/@uppy/companion/src --exec node ./packages/@uppy/companion/src/standalone/start-server.js

+ 0 - 15
bin/start-server.js

@@ -1,15 +0,0 @@
-#!/usr/bin/env node
-
-// Set up environment variables, see
-// https://uppy.io/docs/server/
-Object.assign(process.env, {
-  UPPYSERVER_SECRET: 'development',
-  UPPYSERVER_DATADIR: './output',
-  UPPYSERVER_DOMAIN: 'localhost:3020',
-  UPPYSERVER_PROTOCOL: 'http',
-  UPPYSERVER_PORT: '3020',
-  UPPY_ENDPOINT: 'localhost:3452',
-  UPPY_ENDPOINTS: 'localhost:3452,localhost:4000'
-})
-
-require('uppy-server/lib/standalone/start-server')

+ 1 - 1
examples/custom-provider/client/MyCustomProvider.js

@@ -1,5 +1,5 @@
 const { Plugin } = require('@uppy/core')
-const { Provider } = require('@uppy/server-utils')
+const { Provider } = require('@uppy/companion-client')
 const ProviderViews = require('@uppy/provider-views')
 const { h } = require('preact')
 

+ 0 - 0
output/.keep


Filskillnaden har hållts tillbaka eftersom den är för stor
+ 236 - 236
package-lock.json


+ 6 - 4
package.json

@@ -80,18 +80,19 @@
   "scripts": {
     "build:bundle": "node ./bin/build-js.js",
     "build:css": "node ./bin/build-css.js",
+    "build:companion": "cd ./packages/@uppy/companion && npm run build",
     "build:gzip": "node ./bin/gzip.js",
     "size": "echo 'JS Bundle mingz:' && cat ./packages/uppy/dist/uppy.min.js | gzip | wc -c && echo 'CSS Bundle mingz:' && cat ./packages/uppy/dist/uppy.min.css | gzip | wc -c",
     "build:js": "npm-run-all build:lib build:bundle",
     "build:lib": "babel --version && node ./bin/build-lib.js",
-    "build": "npm-run-all --parallel build:js build:css --serial build:gzip size",
+    "build": "npm-run-all --parallel build:js build:css build:companion --serial build:gzip size",
     "clean": "rm -rf packages/*/lib packages/@uppy/*/lib && rm -rf packages/uppy/dist",
     "lint:fix": "npm run lint -- --fix",
     "lint": "eslint . --cache",
     "lint-staged": "lint-staged",
     "release": "./bin/release",
-    "start:server": "node bin/start-server",
-    "start": "npm-run-all --parallel watch start:server web:preview",
+    "start:companion": "sh ./bin/companion",
+    "start": "npm-run-all --parallel watch start:companion web:preview",
     "test:registry": "verdaccio --listen 4002 --config test/endtoend/verdaccio.yaml",
     "test:build": "./bin/endtoend-build",
     "test:build-ci": "./bin/endtoend-build-ci",
@@ -99,8 +100,9 @@
     "test:acceptance": "npm run test:prepare-ci && wdio test/endtoend/wdio.remote.conf.js",
     "test:acceptance:local": "npm run test:build && wdio test/endtoend/wdio.local.conf.js",
     "test:unit": "jest --testPathPattern=./src --coverage",
+    "test:companion": "cd ./packages/@uppy/companion && npm run test",
     "test:type": "tsc -p .",
-    "test": "npm run lint && npm run test:unit && npm run test:type",
+    "test": "npm run lint && npm run test:unit && npm run test:type && npm run test:companion",
     "test:watch": "jest --watch --testPathPattern=src",
     "travis:deletecache": "travis cache --delete",
     "watch:css": "onchange 'packages/**/*.scss' --initial --verbose -- npm run build:css",

+ 1 - 1
packages/@uppy/aws-s3-multipart/package.json

@@ -24,7 +24,7 @@
     "url": "git+https://github.com/transloadit/uppy.git"
   },
   "dependencies": {
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "resolve-url": "^0.2.1"
   },

+ 1 - 1
packages/@uppy/aws-s3-multipart/src/index.js

@@ -1,5 +1,5 @@
 const { Plugin } = require('@uppy/core')
-const { Socket, RequestClient } = require('@uppy/server-utils')
+const { Socket, RequestClient } = require('@uppy/companion-client')
 const emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress')
 const getSocketHost = require('@uppy/utils/lib/getSocketHost')
 const limitPromises = require('@uppy/utils/lib/limitPromises')

+ 0 - 0
packages/@uppy/server-utils/LICENSE → packages/@uppy/companion-client/LICENSE


+ 4 - 4
packages/@uppy/server-utils/README.md → packages/@uppy/companion-client/README.md

@@ -1,8 +1,8 @@
-# @uppy/server-utils
+# @uppy/companion-client
 
 <img src="https://uppy.io/images/logos/uppy-dog-head-arrow.svg" width="120" alt="Uppy logo: a superman puppy in a pink suit" align="right">
 
-<a href="https://www.npmjs.com/package/@uppy/server-utils"><img src="https://img.shields.io/npm/v/@uppy/server-utils.svg?style=flat-square"></a>
+<a href="https://www.npmjs.com/package/@uppy/companion-client"><img src="https://img.shields.io/npm/v/@uppy/companion-client.svg?style=flat-square"></a>
 <a href="https://travis-ci.org/transloadit/uppy"><img src="https://img.shields.io/travis/transloadit/uppy/master.svg?style=flat-square" alt="Build Status"></a>
 
 Client library for communication with Uppy Server. Intended for use in Uppy plugins.
@@ -13,7 +13,7 @@ Uppy is being developed by the folks at [Transloadit](https://transloadit.com),
 
 ```js
 const Uppy = require('@uppy/core')
-const { Provider, RequestClient, Socket } = require('@uppy/server-utils')
+const { Provider, RequestClient, Socket } = require('@uppy/companion-client')
 
 const uppy = Uppy()
 
@@ -35,7 +35,7 @@ socket.on('progress', () => {})
 > Unless you are writing a custom provider plugin, you do not need to install this.
 
 ```bash
-$ npm install @uppy/server-utils --save
+$ npm install @uppy/companion-client --save
 ```
 
 <!-- Undocumented currently

+ 1 - 1
packages/@uppy/server-utils/package.json → packages/@uppy/companion-client/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@uppy/server-utils",
+  "name": "@uppy/companion-client",
   "description": "Client library for communication with Uppy Server. Intended for use in Uppy plugins.",
   "version": "0.26.0",
   "license": "MIT",

+ 0 - 0
packages/@uppy/server-utils/src/Provider.js → packages/@uppy/companion-client/src/Provider.js


+ 0 - 0
packages/@uppy/server-utils/src/RequestClient.js → packages/@uppy/companion-client/src/RequestClient.js


+ 0 - 0
packages/@uppy/server-utils/src/RequestClient.test.js → packages/@uppy/companion-client/src/RequestClient.test.js


+ 0 - 0
packages/@uppy/server-utils/src/Socket.js → packages/@uppy/companion-client/src/Socket.js


+ 0 - 0
packages/@uppy/server-utils/src/Socket.test.js → packages/@uppy/companion-client/src/Socket.test.js


+ 0 - 0
packages/@uppy/server-utils/src/index.js → packages/@uppy/companion-client/src/index.js


+ 0 - 0
packages/@uppy/server-utils/types/index.d.ts → packages/@uppy/companion-client/types/index.d.ts


+ 8 - 0
packages/@uppy/companion/.eslintrc.json

@@ -0,0 +1,8 @@
+{
+  "extends": "standard",
+  "env": {
+    "browser": false,
+    "node": true
+  },
+  "root": true
+}

+ 44 - 0
packages/@uppy/companion/.gitignore

@@ -0,0 +1,44 @@
+# Logs
+logs
+*.log
+
+# Runtime data
+pids
+*.pid
+*.seed
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+
+# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (http://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directory
+# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
+node_modules
+
+# oAuth Configuration
+config/auth.js
+
+*.pem
+env.*
+!env.test.sh
+
+output/*
+test/output/*
+!test/output/.keep
+.DS_Store
+
+# Transpiled
+lib/
+infra/kube/companion/uppy-env.yaml
+scripts/.tl-deploy-hosts-danger.txt

+ 84 - 0
packages/@uppy/companion/ARCHITECTURE.md

@@ -0,0 +1,84 @@
+Uppy Server is the server side component for Uppy.  It is currently built with the Express.js.
+The purpose of Uppy Server is to interface with third party APIs and handle remote file uploading from them.
+
+# How it works
+
+## oAuth with Grant, Sessions
+Uppy Server uses an oAuth middleware library called `Grant` to simplify oAuth authentication.
+Inside of `config/grant.js`, you configure the oAuth providers you wish to use, providing things like client key, 
+client secret, scopes, and the callback URL you wish to use.  For example:
+
+```
+  google: {
+    key: process.env.UPPYSERVER_GOOGLE_KEY,
+    secret: process.env.UPPYSERVER_GOOGLE_SECRET,
+    scope: [
+      'https://www.googleapis.com/auth/drive',
+      'https://www.googleapis.com/auth/drive.file'
+    ],
+    callback: '/google/callback'
+  }
+```
+
+Once this `google` config is added to `config/grant.js`, Grant automatically creates a route `/connect/google` that 
+redirects to Google's oAuth page.  So on the client side, you just need to link the user to `https://your-server/connect/google`.
+
+After the user completes the oAuth flow, they should always be redirected to `https://your-server/:provider/callback`.
+The `/:provider/callback` routes are handled by the `callback` controller at `server/controllers/callback.js`.  
+This controller receives the oAuth token, generates a json web token with it, and sends the generated json web token to the client by adding it to the cookies. This way companion doesn't have to save users' oAuth tokens (which is good from the security perspective).
+This json web token would be sent to companion in subsequent requests and the oAuth token can be read from it.
+
+## Routing And Controllers
+There are four generic routes:
+
+```
+router.get('/:provider/:action', dispatcher)
+router.get('/:provider/:action/:id', dispatcher)
+router.post('/:provider/:action', dispatcher)
+router.post('/:provider/:action/:id', dispatcher)
+```
+
+Each route is handled by the `dispatcher` controller in `server/controllers/dispatcher.js`, which calls the correct controller based on `:action`.
+
+There are 5 controllers:
+
+| controller | description |
+| ---------- | ----------- |
+| `authorized` | checks if the current user is authorized |
+| `callback` | handles redirect from oAuth.  Stores oAuth token in user session and redirects user. |
+| `get` | downloads files from third party APIs, writes them to disk, and uploads them to the target server |
+| `list` | fetches a list of files, usually from a specified directory |
+| `logout` | removes all token info from the user session |
+
+These controllers are generalized to work for any provider.  The provider specific implementation code for each provider can be found under `server/providers`.
+
+## Adding new providers
+To add a new provider to Uppy Server, you need to do two things: add the provider config to `config/grant.js`, and then create a new file in `server/providers` that describes how to interface with the provider's API.
+
+We are using a library called [purest](https://github.com/simov/purest) to make it easier to interface with third party APIs.  Instead of dealing with each and every single provider's client library/SDK, we use Purest, a "generic REST API client library" that gives us a consistent, "generic" API to interface with any provider.  This makes life a lot easier.
+
+Since each API works differently, we need to describe how to `download` and `list` files from the provider in a file within `server/providers`.  The name of the file should be the same as what endpoint it will use.  For example, `server/providers/foobar.js` if the client requests a list of files from `https://our-server/foobar/list`.
+
+**Note:** As of right now, you only need to implement `YourProvider.prototype.list` and `YourProvider.prototype.download` for each provider, I believe.  `stats` seems to be used by Dropbox to get a list of files, so that's required there, but `upload` is optional unless you all decide to allow uploading to third parties.  I got that code from an example.
+
+This whole approach was inspired by an example from `purest 2.x`.  Keep in mind that we're using `3.x`, so the API is different, but here is the example for reference: https://github.com/simov/purest/tree/2.x/examples/storage
+
+## WebSockets
+Uppy Server uses WebSockets to transfer `progress` events to the client during file transfers.  It's currently only set up to transfer progress during Tus uploads to the target server.  
+
+When a request is made to `/:provider/get` to start a transfer, a token is generated and sent back to the client in response.  The client then connects to `wss://your-server/whatever-their-token-is`.  Any events that are emitted using the token as the name (i.e. `emitter.emit('whatever-their-token-is', progressData)`) are sent back to the client.
+
+WebSockets aren't particularly secure, but we feel this is safe because the token is only usable during the corresponding file transfer, and no sensitive information is being sent, only a file id and the progress.
+
+# Design Goals
+These are the goals I had in mind while designing and building Uppy Server.
+
+## Standalone Server / Pluggable Module
+Uppy Server currently works as a standalone server.  It should also work as a module that can easily be incorporated into an already existing server, so people don't have to manage another server just to use Uppy.
+
+One issue here is that `Grant` has different versions for Koa, Express, and Hapi.  We're using `grant-express` right now, and also use all express modules.  This becomes a problem if someone is using Koa, or Hapi, or something else.  I don't think we can make Uppy Server completely framework agnostic, so best case scenario would be to follow Grant and make versions for Koa, Hapi, and Express.
+
+All of this may be more trouble than it's worth if no one needs it, so I'd get some community feedback beforehand.
+
+## Allow users to add new providers
+Suppose a developer wants to use Uppy with a third party API provider that we don't support.  There needs to be some way for them to be able to add their own custom providers, hopefully without having to edit `companion`'s source (adding files to `server/providers`).

+ 20 - 0
packages/@uppy/companion/Dockerfile

@@ -0,0 +1,20 @@
+FROM alpine:3.6
+
+RUN apk add --update nodejs \
+	           nodejs-npm 
+
+COPY package.json /app/package.json
+
+WORKDIR /app
+
+RUN apk --update add  --virtual native-dep \
+  make gcc g++ python libgcc libstdc++ && \
+  npm  install && \
+  apk del native-dep
+RUN apk add bash
+COPY . /app
+RUN npm run build
+CMD ["node","/app/lib/standalone/start-server.js"]
+# This can be overwritten later
+EXPOSE 3020
+

+ 18 - 0
packages/@uppy/companion/Dockerfile.test

@@ -0,0 +1,18 @@
+FROM alpine:3.6
+
+RUN apk add --update nodejs \
+	           nodejs-npm 
+
+COPY package.json /app/package.json
+
+WORKDIR /app
+
+RUN apk --update add  --virtual native-dep \
+  make gcc g++ python libgcc libstdc++ && \
+  npm  install && \
+  apk del native-dep
+RUN apk add bash
+
+COPY . /app
+RUN npm install -g nodemon
+CMD ["npm","test"]

+ 115 - 0
packages/@uppy/companion/KUBERNETES.md

@@ -0,0 +1,115 @@
+### Run uppy-server on kuberenetes
+
+You can use our docker container to run uppy-server on kubernetes with the following configuration.
+```bash
+kubectl create ns uppy
+```
+We will need a Redis container that we can get through [helm](https://github.com/kubernetes/helm)
+
+```bash
+ helm install --name redis \
+  --namespace uppy \
+  --set password=superSecretPassword \
+    stable/redis
+```
+
+> uppy-server-env.yml
+```yaml
+apiVersion: v1
+data:
+  UPPY_ENDPOINTS: "localhost:3452,uppy.io"
+  UPPYSERVER_DATADIR: "PATH/TO/DOWNLOAD/DIRECTORY"
+  UPPYSERVER_DOMAIN: "YOUR SERVER DOMAIN"
+  UPPYSERVER_DOMAINS: "sub1.domain.com,sub2.domain.com,sub3.domain.com"
+  UPPYSERVER_PROTOCOL: "YOUR SERVER PROTOCOL"
+  UPPYSERVER_REDIS_URL: redis://:superSecretPassword@uppy-redis.uppy.svc.cluster.local:6379
+  UPPYSERVER_SECRET: "shh!Issa Secret!"
+  UPPYSERVER_DROPBOX_KEY: "YOUR DROPBOX KEY"
+  UPPYSERVER_DROPBOX_SECRET: "YOUR DROPBOX SECRET"
+  UPPYSERVER_GOOGLE_KEY: "YOUR GOOGLE KEY"
+  UPPYSERVER_GOOGLE_SECRET: "YOUR GOOGLE SECRET"
+  UPPYSERVER_INSTAGRAM_KEY: "YOUR INSTAGRAM KEY"
+  UPPYSERVER_INSTAGRAM_SECRET: "YOUR INSTAGRAM SECRET"
+  UPPYSERVER_AWS_KEY: "YOUR AWS KEY"
+  UPPYSERVER_AWS_SECRET: "YOUR AWS SECRET"
+  UPPYSERVER_AWS_BUCKET: "YOUR AWS S3 BUCKET"
+  UPPYSERVER_AWS_REGION: "AWS REGION"
+  UPPYSERVER_OAUTH_DOMAIN: "sub.domain.com"
+  UPPYSERVER_UPLOAD_URLS: "http://master.tus.io/files/,https://master.tus.io/files/"
+kind: Secret
+metadata:
+  name: uppy-server-env
+  namespace: uppy
+type: Opaque
+```
+
+> uppy-server-deployment.yml
+```yaml
+apiVersion: extensions/v1beta1
+kind: Deployment
+metadata:
+  name: uppy-server
+  namespace: uppy
+spec:
+  replicas: 2
+  minReadySeconds: 5
+  strategy:
+    type: RollingUpdate
+    rollingUpdate:
+      maxSurge: 2
+      maxUnavailable: 1
+  template:
+    metadata:
+      labels:
+        app: uppy-server
+    spec:
+      containers:
+      - image: docker.io/transloadit/uppy-server:latest
+        imagePullPolicy: ifNotPresent
+        name: uppy-server        
+        resources:
+          limits:
+            memory: 150Mi
+          requests:
+            memory: 100Mi
+        envFrom:
+        - secretRef:
+            name: uppy-server-env
+        ports:
+        - containerPort: 3020
+        volumeMounts:
+        - name: uppy-server-data
+          mountPath: /mnt/uppy-server-data
+      volumes:
+      - name: uppy-server-data
+        emptyDir: {}
+```
+
+`kubectl apply -f uppy-server-deployment.yml`
+
+> uppy-server-service.yml
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+  name: uppy-server
+  namespace: uppy
+spec:
+  ports:
+  - port: 80
+    targetPort: 3020
+    protocol: TCP
+  selector:
+    app: uppy-server
+```
+
+`kubectl apply -f uppy-server-service.yml`
+
+## Logging
+
+You can check the production logs for the production pod using: 
+
+```bash
+kubectl logs my-pod-name 
+```

+ 21 - 0
packages/@uppy/companion/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2018 Transloadit
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 40 - 0
packages/@uppy/companion/Makefile

@@ -0,0 +1,40 @@
+# Licensed under MIT.
+# Copyright (2016) by Kevin van Zonneveld https://twitter.com/kvz
+#
+# https://www.npmjs.com/package/fakefile
+#
+# This Makefile offers convience shortcuts into any Node.js project that utilizes npm scripts.
+# It functions as a wrapper around the actual listed in `package.json`
+# So instead of typing:
+#
+#  $ npm script build:assets
+#
+# you could also type:
+#
+#  $ make build-assets
+#
+# Notice that colons (:) are replaced by dashes for Makefile compatibility.
+#
+# The benefits of this wrapper are:
+#
+# - You get to keep the the scripts package.json, which is more portable
+#   (Makefiles & Windows are harder to mix)
+# - Offer a polite way into the project for developers coming from different
+#   languages (npm scripts is obviously very Node centric)
+# - Profit from better autocomplete (make <TAB><TAB>) than npm currently offers.
+#   OSX users will have to install bash-completion
+#   (http://davidalger.com/development/bash-completion-on-os-x-with-brew/)
+
+define npm_script_targets
+TARGETS := $(shell node -e 'for (var k in require("./package.json").scripts) {console.log(k.replace(/:/g, "-"));}')
+$$(TARGETS):
+	npm run $(subst -,:,$(MAKECMDGOALS))
+
+.PHONY: $$(TARGETS)
+endef
+
+$(eval $(call npm_script_targets))
+
+# These npm run scripts are available, without needing to be mentioned in `package.json`
+install:
+	npm run install

+ 99 - 0
packages/@uppy/companion/README.md

@@ -0,0 +1,99 @@
+# uppy-server
+
+<img src="http://uppy.io/images/logos/uppy-dog-full.svg" width="120" alt="Uppy logo — a superman puppy in a pink suit" align="right">
+
+[![Build Status](https://travis-ci.org/transloadit/uppy-server.svg?branch=master)](https://travis-ci.org/transloadit/uppy-server)
+
+Uppy-server is a server integration for [Uppy](https://github.com/transloadit/uppy) file uploader.
+
+It handles the server-to-server communication between your server and file storage providers such as Google Drive, Dropbox,
+Instagram, etc. [See here for full documentation](https://uppy.io/docs/server/)
+
+## Install
+
+```bash
+npm install uppy-server
+```
+
+## Usage
+
+Uppy-server may either be used as pluggable express app, which you plug to your already existing server, or it may simply be run as a standalone server:
+
+### Plug to already existing server
+
+```javascript
+
+var express = require('express')
+var bodyParser = require('body-parser')
+var session = require('express-session')
+var uppy = require('uppy-server')
+
+var app = express()
+app.use(bodyParser.json())
+app.use(session({secret: 'some secrety secret'}))
+...
+// be sure to place this anywhere after app.use(bodyParser.json()) and app.use(session({...})
+const options = {
+  providerOptions: {
+    google: {
+      key: 'GOOGLE_KEY',
+      secret: 'GOOGLE_SECRET'
+    }
+  },
+  server: {
+    host: 'localhost:3020',
+    protocol: 'http',
+  },
+  filePath: '/path/to/folder/'
+}
+
+app.use(uppy.app(options))
+
+```
+
+To enable uppy socket for realtime feed to the client while upload is going on, you call the `socket` method like so.
+
+```javascript
+...
+var server = app.listen(PORT)
+
+uppy.socket(server, options)
+
+```
+
+### Run as standalone server
+Please ensure that the required env variables are set before runnning/using uppy-server as a standalone server. [See](https://uppy.io/docs/server/#Configure-Standalone).
+
+```bash
+$ uppy-server
+```
+
+If you cloned the repo from gtihub and want to run it as a standalone server, you may also run the following command from within its
+directory
+
+```bash
+npm start
+```
+
+### Run as a serverless function
+
+Uppy-server can be deployed as a serverless function to AWS Lambda or other cloud providers through `serverless`. Check [this guide](https://serverless.com/framework/docs/getting-started/) to get started.
+
+After you have cloned the repo go inside `examples/serverless`:
+```
+cd examples/serverless
+```
+ 
+You can enter your API Keys inside the `serverless.yml` file:
+```
+INSTAGRAM_KEY: <YOUR_INSTAGRAM_KEY>
+INSTAGRAM_SECRET: <YOUR_INSTAGRAM_SECRET>
+```
+
+When you are all set install the dependencies and deploy your function:
+```
+npm install && sls deploy
+```
+
+
+See [full documentation](https://uppy.io/docs/server/)

+ 16 - 0
packages/@uppy/companion/docker-compose-dev.yml

@@ -0,0 +1,16 @@
+version: '2'
+
+services:
+  uppy:
+    image: companion
+    build:
+      context: .
+      dockerfile: Dockerfile
+    environment:
+      - NODE_ENV=development
+    volumes:
+      - ./:/app
+      - /app/node_modules
+    ports:
+      - "3020:3020"
+    command: ["/usr/bin/nodemon", "/app/lib/standalone/start-server.js", "--config", "/app/nodemon.json"]

+ 8 - 0
packages/@uppy/companion/docker-compose-test.yml

@@ -0,0 +1,8 @@
+version: '2'
+
+services:
+  uppy:
+    image: companion
+    build:
+      context: .
+      dockerfile: Dockerfile.test

+ 12 - 0
packages/@uppy/companion/docker-compose.yml

@@ -0,0 +1,12 @@
+version: '2'
+
+services:
+  uppy:
+    image: companion
+    build:
+      context: .
+      dockerfile: Dockerfile
+    volumes:
+      - /app/node_modules
+    ports:
+      - "3020:3020"

+ 17 - 0
packages/@uppy/companion/env.test.sh

@@ -0,0 +1,17 @@
+export NODE_ENV="test"
+export UPPYSERVER_PORT=3020
+export UPPYSERVER_DOMAIN="localhost:3020"
+export UPPYSERVER_SELF_ENDPOINT="localhost:3020"
+
+export UPPYSERVER_PROTOCOL="http"
+export UPPYSERVER_DATADIR="./test/output"
+export UPPYSERVER_SECRET="secret"
+
+export UPPYSERVER_DROPBOX_KEY="dropbox_key"
+export UPPYSERVER_DROPBOX_SECRET="dropbox_secret"
+
+export UPPYSERVER_GOOGLE_KEY="google_key"
+export UPPYSERVER_GOOGLE_SECRET="google_secret"
+
+export UPPYSERVER_INSTAGRAM_KEY="instagram_key"
+export UPPYSERVER_INSTAGRAM_SECRET="instagram_secret"

+ 53 - 0
packages/@uppy/companion/examples/serverless/index.js

@@ -0,0 +1,53 @@
+'use strict'
+
+const express = require('express')
+const bodyParser = require('body-parser')
+const cors = require('cors')
+const compression = require('compression')
+const awsServerlessExpress = require('aws-serverless-express')
+const awsServerlessExpressMiddleware = require('aws-serverless-express/middleware')
+const uppy = require('@uppy/companion')
+
+const app = express()
+
+app.use(compression())
+app.use(cors())
+app.use(bodyParser.json())
+app.use(bodyParser.urlencoded({ extended: true }))
+app.use(awsServerlessExpressMiddleware.eventContext())
+
+const host = process.env.DOMAIN.split('://')[1]
+const protocol = process.env.DOMAIN.split('://')[0]
+
+const options = {
+  providerOptions: {
+    s3: {
+      getKey: (req, filename) => filename,
+      bucket: process.env.AWS_S3_BUCKET,
+      region: process.env.AWS_S3_REGION
+    },
+    instagram: {
+      key: process.env.INSTAGRAM_KEY,
+      secret: process.env.INSTAGRAM_SECRET
+    },
+    google: {
+      key: process.env.GOOGLE_KEY,
+      secret: process.env.GOOGLE_SECRET
+    },
+    dropbox: {
+      key: process.env.DROPBOX_KEY,
+      secret: process.env.DROPBOX_SECRET
+    }
+  },
+  server: {
+    host: host,
+    protocol: protocol
+  }
+}
+
+app.use(uppy.app(options))
+
+const server = awsServerlessExpress.createServer(app)
+
+exports.uppy = (event, context) =>
+  awsServerlessExpress.proxy(server, event, context)

+ 12 - 0
packages/@uppy/companion/examples/serverless/package.json

@@ -0,0 +1,12 @@
+{
+  "name": "uploader",
+  "version": "1.0.0",
+  "dependencies": {
+    "aws-serverless-express": "^3.1.3",
+    "body-parser": "^1.18.2",
+    "compression": "^1.7.2",
+    "cors": "^2.8.4",
+    "express": "^4.16.3",
+    "@uppy/companion": "^0.11.2"
+  }
+}

+ 32 - 0
packages/@uppy/companion/examples/serverless/serverless.yml

@@ -0,0 +1,32 @@
+service: uppyloader
+
+provider:
+  name: aws
+  runtime: nodejs6.10
+
+  environment:
+    # NOTE: Make sure you set this to the url of your service endpoint
+
+    DOMAIN: <YOUR_SERVICE_ENDPOINT>
+
+    # NOTE: Make sure you set the API Keys for your chosen provider
+
+    AWS_S3_BUCKET: <YOUR_AWS_S3_BUCKET_NAME>
+    AWS_S3_REGION: <YOUR_AWS_S3_BUCKET_REGION>
+
+    # INSTAGRAM_KEY: <YOUR_INSTAGRAM_KEY>
+    # INSTAGRAM_SECRET: <YOUR_INSTAGRAM_SECRET>
+
+    # GOOGLE_KEY: <YOUR_GOOGLE_KEY>
+    # GOOGLE_SECRET: <YOUR_GOOGLE_SECRET>
+
+    # DROPBOX_KEY: <YOUR_DROPBOX_KEY>
+    # DROPBOX_SECRET: <YOUR_DROPBOX_SECRET>
+
+functions:
+  uppy:
+    handler: handler.uppy
+
+    events:
+      - http: ANY /
+      - http: 'ANY {proxy+}'

+ 112 - 0
packages/@uppy/companion/infra/kube/companion/companion-kube.yaml

@@ -0,0 +1,112 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: companion
+  namespace: uppy
+  labels: 
+    app: companion
+spec:
+  ports:
+  - port: 80
+    targetPort: 3020
+    protocol: TCP
+  selector:
+    app: companion
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+  name: companion
+  namespace: uppy
+spec:
+  selector:
+    matchLabels:
+      app: companion
+  replicas: 2
+  serviceName: "companion"
+  template:
+    metadata:
+      labels:
+        app: companion
+    spec:
+      affinity:
+        nodeAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+              nodeSelectorTerms:
+              - matchExpressions:
+                - key: cloud.google.com/gke-preemptible
+                  operator: Exists
+        podAntiAffinity:
+          requiredDuringSchedulingIgnoredDuringExecution:
+          - labelSelector:
+              matchExpressions:
+              - key: app
+                operator: In
+                values:
+                - companion
+            topologyKey: kubernetes.io/hostname    
+      containers:
+      - image: docker.io/transloadit/companion:latest
+        imagePullPolicy: Always
+        name: companion        
+        resources:
+          limits:
+            memory: 2Gi
+          requests:
+            memory: 2Gi
+        envFrom:
+        - configMapRef:
+            name: companion-env
+        ports:
+        - containerPort: 3020
+        volumeMounts:
+        - name: companion-data
+          mountPath: /mnt/companion-data
+  volumeClaimTemplates:
+  - metadata:
+      name: companion-data
+    spec:
+      accessModes: [ "ReadWriteOnce" ]
+      storageClassName: "standard"
+      resources:
+        requests:
+          storage: 10Gi
+---
+apiVersion: extensions/v1beta1
+kind: Ingress
+metadata:
+  name: companion
+  namespace: uppy
+  annotations:
+    kubernetes.io/tls-acme: "true"
+    kubernetes.io/ingress.class: "nginx"
+    nginx.ingress.kubernetes.io/affinity: "cookie"
+    nginx.ingress.kubernetes.io/session-cookie-name: "route"
+    nginx.ingress.kubernetes.io/session-cookie-hash: "sha1"
+spec:
+  tls:
+  - hosts:
+    - server.uppy.io
+    secretName: companion-tls
+  rules:
+  - host: server.uppy.io
+    http:
+      paths:
+      - path: /
+        backend:
+          serviceName: companion
+          servicePort: 80
+---
+apiVersion: autoscaling/v1
+kind: HorizontalPodAutoscaler
+metadata:
+  name: companion
+  namespace: uppy
+spec:
+  scaleTargetRef:
+    apiVersion: apps/v1
+    kind: Statefulset
+    name: companion
+  minReplicas: 1
+  maxReplicas: 5
+  targetCPUUtilizationPercentage: 80

+ 115 - 0
packages/@uppy/companion/infra/kube/companion/companion-redis.yaml

@@ -0,0 +1,115 @@
+kind: PersistentVolumeClaim
+apiVersion: v1
+metadata:
+  name: uppy-redis
+  namespace: uppy
+spec:
+  accessModes:
+    - ReadWriteOnce
+  storageClassName: "standard"
+  resources:
+    requests:
+      storage: 20Gi
+---
+apiVersion: extensions/v1beta1
+kind: Deployment
+metadata:
+  labels:
+    app: uppy-redis
+    release: uppy
+  name: uppy-redis
+  namespace: uppy
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: uppy-redis
+  strategy:
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 1
+    type: RollingUpdate
+  template:
+    metadata:
+      labels:
+        app: uppy-redis
+    spec:
+      # affinity:
+      #   nodeAffinity:
+      #     requiredDuringSchedulingIgnoredDuringExecution:
+      #       nodeSelectorTerms:
+      #       - matchExpressions:
+      #         - key: cloud.google.com/gke-preemptible
+      #           operator: DoesNotExist
+      containers:
+      - env:
+        - name: REDIS_PASSWORD
+          valueFrom:
+            secretKeyRef:
+              key: redis-password
+              name: uppy-redis
+        image: bitnami/redis:4.0.2-r1
+        imagePullPolicy: IfNotPresent
+        livenessProbe:
+          exec:
+            command:
+            - redis-cli
+            - ping
+          failureThreshold: 3
+          initialDelaySeconds: 30
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 5
+        name: uppy-redis
+        ports:
+        - containerPort: 6379
+          name: redis
+          protocol: TCP
+        readinessProbe:
+          exec:
+            command:
+            - redis-cli
+            - ping
+          failureThreshold: 3
+          initialDelaySeconds: 5
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        resources:
+          requests:
+            cpu: 100m
+            memory: 256Mi
+        terminationMessagePath: /dev/termination-log
+        terminationMessagePolicy: File
+        volumeMounts:
+        - mountPath: /bitnami
+          name: redis-data
+      dnsPolicy: ClusterFirst
+      restartPolicy: Always
+      securityContext:
+        fsGroup: 1001
+        runAsUser: 1001
+      terminationGracePeriodSeconds: 30
+      volumes:
+      - name: redis-data
+        persistentVolumeClaim:
+          claimName: uppy-redis
+---
+apiVersion: v1
+kind: Service
+metadata:
+  labels:
+    app: uppy-redis
+  name: uppy-redis
+  namespace: uppy
+spec:
+  ports:
+  - name: redis
+    port: 6379
+    protocol: TCP
+    targetPort: redis
+  selector:
+    app: uppy-redis
+  sessionAffinity: None
+  type: ClusterIP
+

+ 49 - 0
packages/@uppy/companion/infra/kube/gcloud-deploy.sh

@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+set -o pipefail
+set -o errexit
+set -o nounset
+# set -o xtrace
+
+# Set magic variables for current FILE & DIR
+__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+__kube="${__dir}"
+
+# Store the new image in docker hub
+docker build --quiet -t transloadit/uppy-server:latest -t transloadit/uppy-server:$TRAVIS_COMMIT .;
+docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD";
+docker push transloadit/uppy-server:$TRAVIS_COMMIT;
+docker push transloadit/uppy-server:latest;
+
+echo $CA_CRT | base64 --decode -i > ${HOME}/ca.crt
+
+gcloud config set container/use_client_certificate True
+export CLOUDSDK_CONTAINER_USE_CLIENT_CERTIFICATE=True
+
+kubectl config set-cluster transloadit-gke-cluster --embed-certs=true --server=${CLUSTER_ENDPOINT} --certificate-authority=${HOME}/ca.crt
+kubectl config set-credentials travis-uppy --token=$SA_TOKEN
+kubectl config set-context travis --cluster=$CLUSTER_NAME --user=travis-uppy --namespace=uppy
+kubectl config use-context travis
+# Should be already removed. Using it temporarily.
+rm -f "${__kube}/uppy-server/uppy-env.yaml"
+echo $UPPY_ENV | base64 --decode > "${__kube}/uppy-server/uppy-env.yaml"
+
+kubectl config current-context
+
+kubectl apply -f "${__kube}/uppy-server/uppy-env.yaml"
+sleep 10s # This cost me some precious debugging time.
+kubectl apply -f "${__kube}/uppy-server/uppy-server-kube.yaml"
+kubectl apply -f "${__kube}/uppy-server/uppy-server-redis.yaml"
+kubectl set image statefulset uppy-server --namespace=uppy uppy-server=docker.io/transloadit/uppy-server:$TRAVIS_COMMIT
+sleep 10s
+
+kubectl get pods --namespace=uppy
+kubectl get service --namespace=uppy
+kubectl get deployment --namespace=uppy
+
+function cleanup {
+    printf "Cleaning up...\n"
+    rm -vf "${__kube}/uppy-server/uppy-env.yaml"
+    printf "Cleaning done."
+}
+
+trap cleanup EXIT

+ 13 - 0
packages/@uppy/companion/nodemon.json

@@ -0,0 +1,13 @@
+{
+    "restartable": "rs",
+    "verbose": true,
+    "env": {
+        "NODE_ENV": "development",
+        "DEBUG": "app:*",
+        "DEBUG_COLORS": true
+    },
+    "debug": true,
+    "watch": ["/app/", "/src/"],
+    "ext": "js dust html ejs css scss rb json htpasswd",
+    "exec": "node /app/lib/standalone/start-server.js"
+}

+ 1051 - 0
packages/@uppy/companion/package-lock.json

@@ -0,0 +1,1051 @@
+{
+	"requires": true,
+	"lockfileVersion": 1,
+	"dependencies": {
+		"eslint": {
+			"version": "4.19.1",
+			"resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz",
+			"integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==",
+			"requires": {
+				"ajv": "^5.3.0",
+				"babel-code-frame": "^6.22.0",
+				"chalk": "^2.1.0",
+				"concat-stream": "^1.6.0",
+				"cross-spawn": "^5.1.0",
+				"debug": "^3.1.0",
+				"doctrine": "^2.1.0",
+				"eslint-scope": "^3.7.1",
+				"eslint-visitor-keys": "^1.0.0",
+				"espree": "^3.5.4",
+				"esquery": "^1.0.0",
+				"esutils": "^2.0.2",
+				"file-entry-cache": "^2.0.0",
+				"functional-red-black-tree": "^1.0.1",
+				"glob": "^7.1.2",
+				"globals": "^11.0.1",
+				"ignore": "^3.3.3",
+				"imurmurhash": "^0.1.4",
+				"inquirer": "^3.0.6",
+				"is-resolvable": "^1.0.0",
+				"js-yaml": "^3.9.1",
+				"json-stable-stringify-without-jsonify": "^1.0.1",
+				"levn": "^0.3.0",
+				"lodash": "^4.17.4",
+				"minimatch": "^3.0.2",
+				"mkdirp": "^0.5.1",
+				"natural-compare": "^1.4.0",
+				"optionator": "^0.8.2",
+				"path-is-inside": "^1.0.2",
+				"pluralize": "^7.0.0",
+				"progress": "^2.0.0",
+				"regexpp": "^1.0.1",
+				"require-uncached": "^1.0.3",
+				"semver": "^5.3.0",
+				"strip-ansi": "^4.0.0",
+				"strip-json-comments": "~2.0.1",
+				"table": "4.0.2",
+				"text-table": "~0.2.0"
+			},
+			"dependencies": {
+				"acorn": {
+					"version": "5.7.1",
+					"resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.1.tgz",
+					"integrity": "sha512-d+nbxBUGKg7Arpsvbnlq61mc12ek3EY8EQldM3GPAhWJ1UVxC6TDGbIvUMNU6obBX3i1+ptCIzV4vq0gFPEGVQ=="
+				},
+				"acorn-jsx": {
+					"version": "3.0.1",
+					"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz",
+					"integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=",
+					"requires": {
+						"acorn": "^3.0.4"
+					},
+					"dependencies": {
+						"acorn": {
+							"version": "3.3.0",
+							"resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz",
+							"integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo="
+						}
+					}
+				},
+				"ajv": {
+					"version": "5.5.2",
+					"resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz",
+					"integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
+					"requires": {
+						"co": "^4.6.0",
+						"fast-deep-equal": "^1.0.0",
+						"fast-json-stable-stringify": "^2.0.0",
+						"json-schema-traverse": "^0.3.0"
+					}
+				},
+				"ajv-keywords": {
+					"version": "2.1.1",
+					"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz",
+					"integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I="
+				},
+				"ansi-escapes": {
+					"version": "3.1.0",
+					"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz",
+					"integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw=="
+				},
+				"ansi-regex": {
+					"version": "2.1.1",
+					"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
+					"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
+				},
+				"ansi-styles": {
+					"version": "2.2.1",
+					"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz",
+					"integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4="
+				},
+				"argparse": {
+					"version": "1.0.10",
+					"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+					"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+					"requires": {
+						"sprintf-js": "~1.0.2"
+					}
+				},
+				"array-union": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz",
+					"integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=",
+					"requires": {
+						"array-uniq": "^1.0.1"
+					}
+				},
+				"array-uniq": {
+					"version": "1.0.3",
+					"resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
+					"integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY="
+				},
+				"arrify": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
+					"integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0="
+				},
+				"babel-code-frame": {
+					"version": "6.26.0",
+					"resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz",
+					"integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=",
+					"requires": {
+						"chalk": "^1.1.3",
+						"esutils": "^2.0.2",
+						"js-tokens": "^3.0.2"
+					},
+					"dependencies": {
+						"chalk": {
+							"version": "1.1.3",
+							"resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
+							"integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=",
+							"requires": {
+								"ansi-styles": "^2.2.1",
+								"escape-string-regexp": "^1.0.2",
+								"has-ansi": "^2.0.0",
+								"strip-ansi": "^3.0.0",
+								"supports-color": "^2.0.0"
+							}
+						},
+						"strip-ansi": {
+							"version": "3.0.1",
+							"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
+							"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
+							"requires": {
+								"ansi-regex": "^2.0.0"
+							}
+						}
+					}
+				},
+				"balanced-match": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
+					"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
+				},
+				"brace-expansion": {
+					"version": "1.1.11",
+					"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+					"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+					"requires": {
+						"balanced-match": "^1.0.0",
+						"concat-map": "0.0.1"
+					}
+				},
+				"buffer-from": {
+					"version": "1.1.0",
+					"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz",
+					"integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ=="
+				},
+				"caller-path": {
+					"version": "0.1.0",
+					"resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz",
+					"integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=",
+					"requires": {
+						"callsites": "^0.2.0"
+					}
+				},
+				"callsites": {
+					"version": "0.2.0",
+					"resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz",
+					"integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo="
+				},
+				"chalk": {
+					"version": "2.4.1",
+					"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz",
+					"integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==",
+					"requires": {
+						"ansi-styles": "^3.2.1",
+						"escape-string-regexp": "^1.0.5",
+						"supports-color": "^5.3.0"
+					},
+					"dependencies": {
+						"ansi-styles": {
+							"version": "3.2.1",
+							"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+							"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+							"requires": {
+								"color-convert": "^1.9.0"
+							}
+						},
+						"supports-color": {
+							"version": "5.4.0",
+							"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz",
+							"integrity": "sha512-zjaXglF5nnWpsq470jSv6P9DwPvgLkuapYmfDm3JWOm0vkNTVF2tI4UrN2r6jH1qM/uc/WtxYY1hYoA2dOKj5w==",
+							"requires": {
+								"has-flag": "^3.0.0"
+							}
+						}
+					}
+				},
+				"chardet": {
+					"version": "0.4.2",
+					"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz",
+					"integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I="
+				},
+				"circular-json": {
+					"version": "0.3.3",
+					"resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz",
+					"integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A=="
+				},
+				"cli-cursor": {
+					"version": "2.1.0",
+					"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz",
+					"integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=",
+					"requires": {
+						"restore-cursor": "^2.0.0"
+					}
+				},
+				"cli-width": {
+					"version": "2.2.0",
+					"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz",
+					"integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk="
+				},
+				"co": {
+					"version": "4.6.0",
+					"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+					"integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
+				},
+				"color-convert": {
+					"version": "1.9.2",
+					"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.2.tgz",
+					"integrity": "sha512-3NUJZdhMhcdPn8vJ9v2UQJoH0qqoGUkYTgFEPZaPjEtwmmKUfNV46zZmgB2M5M4DCEQHMaCfWHCxiBflLm04Tg==",
+					"requires": {
+						"color-name": "1.1.1"
+					}
+				},
+				"color-name": {
+					"version": "1.1.1",
+					"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.1.tgz",
+					"integrity": "sha1-SxQVMEz1ACjqgWQ2Q72C6gWANok="
+				},
+				"concat-map": {
+					"version": "0.0.1",
+					"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+					"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+				},
+				"concat-stream": {
+					"version": "1.6.2",
+					"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
+					"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
+					"requires": {
+						"buffer-from": "^1.0.0",
+						"inherits": "^2.0.3",
+						"readable-stream": "^2.2.2",
+						"typedarray": "^0.0.6"
+					}
+				},
+				"core-util-is": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+					"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
+				},
+				"cross-spawn": {
+					"version": "5.1.0",
+					"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
+					"integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=",
+					"requires": {
+						"lru-cache": "^4.0.1",
+						"shebang-command": "^1.2.0",
+						"which": "^1.2.9"
+					}
+				},
+				"debug": {
+					"version": "3.1.0",
+					"resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz",
+					"integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==",
+					"requires": {
+						"ms": "2.0.0"
+					}
+				},
+				"deep-is": {
+					"version": "0.1.3",
+					"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
+					"integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
+				},
+				"del": {
+					"version": "2.2.2",
+					"resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz",
+					"integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=",
+					"requires": {
+						"globby": "^5.0.0",
+						"is-path-cwd": "^1.0.0",
+						"is-path-in-cwd": "^1.0.0",
+						"object-assign": "^4.0.1",
+						"pify": "^2.0.0",
+						"pinkie-promise": "^2.0.0",
+						"rimraf": "^2.2.8"
+					}
+				},
+				"doctrine": {
+					"version": "2.1.0",
+					"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+					"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+					"requires": {
+						"esutils": "^2.0.2"
+					}
+				},
+				"escape-string-regexp": {
+					"version": "1.0.5",
+					"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+					"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+				},
+				"eslint-scope": {
+					"version": "3.7.1",
+					"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.1.tgz",
+					"integrity": "sha1-PWPD7f2gLgbgGkUq2IyqzHzctug=",
+					"requires": {
+						"esrecurse": "^4.1.0",
+						"estraverse": "^4.1.1"
+					}
+				},
+				"eslint-visitor-keys": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz",
+					"integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ=="
+				},
+				"espree": {
+					"version": "3.5.4",
+					"resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz",
+					"integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==",
+					"requires": {
+						"acorn": "^5.5.0",
+						"acorn-jsx": "^3.0.0"
+					}
+				},
+				"esprima": {
+					"version": "4.0.0",
+					"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.0.tgz",
+					"integrity": "sha512-oftTcaMu/EGrEIu904mWteKIv8vMuOgGYo7EhVJJN00R/EED9DCua/xxHRdYnKtcECzVg7xOWhflvJMnqcFZjw=="
+				},
+				"esquery": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz",
+					"integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==",
+					"requires": {
+						"estraverse": "^4.0.0"
+					}
+				},
+				"esrecurse": {
+					"version": "4.2.1",
+					"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz",
+					"integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==",
+					"requires": {
+						"estraverse": "^4.1.0"
+					}
+				},
+				"estraverse": {
+					"version": "4.2.0",
+					"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz",
+					"integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM="
+				},
+				"esutils": {
+					"version": "2.0.2",
+					"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
+					"integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs="
+				},
+				"external-editor": {
+					"version": "2.2.0",
+					"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz",
+					"integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==",
+					"requires": {
+						"chardet": "^0.4.0",
+						"iconv-lite": "^0.4.17",
+						"tmp": "^0.0.33"
+					}
+				},
+				"fast-deep-equal": {
+					"version": "1.1.0",
+					"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
+					"integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ="
+				},
+				"fast-json-stable-stringify": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz",
+					"integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I="
+				},
+				"fast-levenshtein": {
+					"version": "2.0.6",
+					"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+					"integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc="
+				},
+				"figures": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz",
+					"integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=",
+					"requires": {
+						"escape-string-regexp": "^1.0.5"
+					}
+				},
+				"file-entry-cache": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz",
+					"integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=",
+					"requires": {
+						"flat-cache": "^1.2.1",
+						"object-assign": "^4.0.1"
+					}
+				},
+				"flat-cache": {
+					"version": "1.3.0",
+					"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz",
+					"integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=",
+					"requires": {
+						"circular-json": "^0.3.1",
+						"del": "^2.0.2",
+						"graceful-fs": "^4.1.2",
+						"write": "^0.2.1"
+					}
+				},
+				"fs.realpath": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+					"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+				},
+				"functional-red-black-tree": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+					"integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc="
+				},
+				"glob": {
+					"version": "7.1.2",
+					"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.2.tgz",
+					"integrity": "sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==",
+					"requires": {
+						"fs.realpath": "^1.0.0",
+						"inflight": "^1.0.4",
+						"inherits": "2",
+						"minimatch": "^3.0.4",
+						"once": "^1.3.0",
+						"path-is-absolute": "^1.0.0"
+					}
+				},
+				"globals": {
+					"version": "11.7.0",
+					"resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz",
+					"integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg=="
+				},
+				"globby": {
+					"version": "5.0.0",
+					"resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz",
+					"integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=",
+					"requires": {
+						"array-union": "^1.0.1",
+						"arrify": "^1.0.0",
+						"glob": "^7.0.3",
+						"object-assign": "^4.0.1",
+						"pify": "^2.0.0",
+						"pinkie-promise": "^2.0.0"
+					}
+				},
+				"graceful-fs": {
+					"version": "4.1.11",
+					"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz",
+					"integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg="
+				},
+				"has-ansi": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
+					"integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=",
+					"requires": {
+						"ansi-regex": "^2.0.0"
+					}
+				},
+				"has-flag": {
+					"version": "3.0.0",
+					"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+					"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+				},
+				"iconv-lite": {
+					"version": "0.4.23",
+					"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.23.tgz",
+					"integrity": "sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==",
+					"requires": {
+						"safer-buffer": ">= 2.1.2 < 3"
+					}
+				},
+				"ignore": {
+					"version": "3.3.10",
+					"resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz",
+					"integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug=="
+				},
+				"imurmurhash": {
+					"version": "0.1.4",
+					"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+					"integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o="
+				},
+				"inflight": {
+					"version": "1.0.6",
+					"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+					"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+					"requires": {
+						"once": "^1.3.0",
+						"wrappy": "1"
+					}
+				},
+				"inherits": {
+					"version": "2.0.3",
+					"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
+					"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
+				},
+				"inquirer": {
+					"version": "3.3.0",
+					"resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz",
+					"integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==",
+					"requires": {
+						"ansi-escapes": "^3.0.0",
+						"chalk": "^2.0.0",
+						"cli-cursor": "^2.1.0",
+						"cli-width": "^2.0.0",
+						"external-editor": "^2.0.4",
+						"figures": "^2.0.0",
+						"lodash": "^4.3.0",
+						"mute-stream": "0.0.7",
+						"run-async": "^2.2.0",
+						"rx-lite": "^4.0.8",
+						"rx-lite-aggregates": "^4.0.8",
+						"string-width": "^2.1.0",
+						"strip-ansi": "^4.0.0",
+						"through": "^2.3.6"
+					}
+				},
+				"is-fullwidth-code-point": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
+					"integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
+				},
+				"is-path-cwd": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz",
+					"integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0="
+				},
+				"is-path-in-cwd": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz",
+					"integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==",
+					"requires": {
+						"is-path-inside": "^1.0.0"
+					}
+				},
+				"is-path-inside": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz",
+					"integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=",
+					"requires": {
+						"path-is-inside": "^1.0.1"
+					}
+				},
+				"is-promise": {
+					"version": "2.1.0",
+					"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz",
+					"integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o="
+				},
+				"is-resolvable": {
+					"version": "1.1.0",
+					"resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz",
+					"integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg=="
+				},
+				"isarray": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+					"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
+				},
+				"isexe": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+					"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+				},
+				"js-tokens": {
+					"version": "3.0.2",
+					"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz",
+					"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls="
+				},
+				"js-yaml": {
+					"version": "3.12.0",
+					"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz",
+					"integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==",
+					"requires": {
+						"argparse": "^1.0.7",
+						"esprima": "^4.0.0"
+					}
+				},
+				"json-schema-traverse": {
+					"version": "0.3.1",
+					"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
+					"integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A="
+				},
+				"json-stable-stringify-without-jsonify": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+					"integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE="
+				},
+				"levn": {
+					"version": "0.3.0",
+					"resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+					"integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+					"requires": {
+						"prelude-ls": "~1.1.2",
+						"type-check": "~0.3.2"
+					}
+				},
+				"lodash": {
+					"version": "4.17.10",
+					"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
+					"integrity": "sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg=="
+				},
+				"lru-cache": {
+					"version": "4.1.3",
+					"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz",
+					"integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==",
+					"requires": {
+						"pseudomap": "^1.0.2",
+						"yallist": "^2.1.2"
+					}
+				},
+				"mimic-fn": {
+					"version": "1.2.0",
+					"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
+					"integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
+				},
+				"minimatch": {
+					"version": "3.0.4",
+					"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
+					"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
+					"requires": {
+						"brace-expansion": "^1.1.7"
+					}
+				},
+				"minimist": {
+					"version": "0.0.8",
+					"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
+					"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
+				},
+				"mkdirp": {
+					"version": "0.5.1",
+					"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
+					"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
+					"requires": {
+						"minimist": "0.0.8"
+					}
+				},
+				"ms": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+					"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
+				},
+				"mute-stream": {
+					"version": "0.0.7",
+					"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz",
+					"integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s="
+				},
+				"natural-compare": {
+					"version": "1.4.0",
+					"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+					"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc="
+				},
+				"object-assign": {
+					"version": "4.1.1",
+					"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+					"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+				},
+				"once": {
+					"version": "1.4.0",
+					"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+					"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+					"requires": {
+						"wrappy": "1"
+					}
+				},
+				"onetime": {
+					"version": "2.0.1",
+					"resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz",
+					"integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=",
+					"requires": {
+						"mimic-fn": "^1.0.0"
+					}
+				},
+				"optionator": {
+					"version": "0.8.2",
+					"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz",
+					"integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=",
+					"requires": {
+						"deep-is": "~0.1.3",
+						"fast-levenshtein": "~2.0.4",
+						"levn": "~0.3.0",
+						"prelude-ls": "~1.1.2",
+						"type-check": "~0.3.2",
+						"wordwrap": "~1.0.0"
+					}
+				},
+				"os-tmpdir": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
+					"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
+				},
+				"path-is-absolute": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+					"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+				},
+				"path-is-inside": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz",
+					"integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM="
+				},
+				"pify": {
+					"version": "2.3.0",
+					"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+					"integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw="
+				},
+				"pinkie": {
+					"version": "2.0.4",
+					"resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz",
+					"integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA="
+				},
+				"pinkie-promise": {
+					"version": "2.0.1",
+					"resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz",
+					"integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=",
+					"requires": {
+						"pinkie": "^2.0.0"
+					}
+				},
+				"pluralize": {
+					"version": "7.0.0",
+					"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz",
+					"integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow=="
+				},
+				"prelude-ls": {
+					"version": "1.1.2",
+					"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+					"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
+				},
+				"process-nextick-args": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz",
+					"integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw=="
+				},
+				"progress": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz",
+					"integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8="
+				},
+				"pseudomap": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
+					"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
+				},
+				"readable-stream": {
+					"version": "2.3.6",
+					"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz",
+					"integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==",
+					"requires": {
+						"core-util-is": "~1.0.0",
+						"inherits": "~2.0.3",
+						"isarray": "~1.0.0",
+						"process-nextick-args": "~2.0.0",
+						"safe-buffer": "~5.1.1",
+						"string_decoder": "~1.1.1",
+						"util-deprecate": "~1.0.1"
+					}
+				},
+				"regexpp": {
+					"version": "1.1.0",
+					"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz",
+					"integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw=="
+				},
+				"require-uncached": {
+					"version": "1.0.3",
+					"resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz",
+					"integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=",
+					"requires": {
+						"caller-path": "^0.1.0",
+						"resolve-from": "^1.0.0"
+					}
+				},
+				"resolve-from": {
+					"version": "1.0.1",
+					"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz",
+					"integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY="
+				},
+				"restore-cursor": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz",
+					"integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=",
+					"requires": {
+						"onetime": "^2.0.0",
+						"signal-exit": "^3.0.2"
+					}
+				},
+				"rimraf": {
+					"version": "2.6.2",
+					"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz",
+					"integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==",
+					"requires": {
+						"glob": "^7.0.5"
+					}
+				},
+				"run-async": {
+					"version": "2.3.0",
+					"resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz",
+					"integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=",
+					"requires": {
+						"is-promise": "^2.1.0"
+					}
+				},
+				"rx-lite": {
+					"version": "4.0.8",
+					"resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz",
+					"integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ="
+				},
+				"rx-lite-aggregates": {
+					"version": "4.0.8",
+					"resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz",
+					"integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=",
+					"requires": {
+						"rx-lite": "*"
+					}
+				},
+				"safe-buffer": {
+					"version": "5.1.2",
+					"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+					"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+				},
+				"safer-buffer": {
+					"version": "2.1.2",
+					"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+					"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+				},
+				"semver": {
+					"version": "5.5.0",
+					"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
+					"integrity": "sha512-4SJ3dm0WAwWy/NVeioZh5AntkdJoWKxHxcmyP622fOkgHa4z3R0TdBJICINyaSDE6uNwVc8gZr+ZinwZAH4xIA=="
+				},
+				"shebang-command": {
+					"version": "1.2.0",
+					"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz",
+					"integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=",
+					"requires": {
+						"shebang-regex": "^1.0.0"
+					}
+				},
+				"shebang-regex": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz",
+					"integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM="
+				},
+				"signal-exit": {
+					"version": "3.0.2",
+					"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz",
+					"integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0="
+				},
+				"slice-ansi": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz",
+					"integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==",
+					"requires": {
+						"is-fullwidth-code-point": "^2.0.0"
+					}
+				},
+				"sprintf-js": {
+					"version": "1.0.3",
+					"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+					"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+				},
+				"string-width": {
+					"version": "2.1.1",
+					"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
+					"integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==",
+					"requires": {
+						"is-fullwidth-code-point": "^2.0.0",
+						"strip-ansi": "^4.0.0"
+					}
+				},
+				"string_decoder": {
+					"version": "1.1.1",
+					"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+					"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+					"requires": {
+						"safe-buffer": "~5.1.0"
+					}
+				},
+				"strip-ansi": {
+					"version": "4.0.0",
+					"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
+					"integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=",
+					"requires": {
+						"ansi-regex": "^3.0.0"
+					},
+					"dependencies": {
+						"ansi-regex": {
+							"version": "3.0.0",
+							"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
+							"integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg="
+						}
+					}
+				},
+				"strip-json-comments": {
+					"version": "2.0.1",
+					"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+					"integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo="
+				},
+				"supports-color": {
+					"version": "2.0.0",
+					"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz",
+					"integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc="
+				},
+				"table": {
+					"version": "4.0.2",
+					"resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz",
+					"integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==",
+					"requires": {
+						"ajv": "^5.2.3",
+						"ajv-keywords": "^2.1.0",
+						"chalk": "^2.1.0",
+						"lodash": "^4.17.4",
+						"slice-ansi": "1.0.0",
+						"string-width": "^2.1.1"
+					}
+				},
+				"text-table": {
+					"version": "0.2.0",
+					"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+					"integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ="
+				},
+				"through": {
+					"version": "2.3.8",
+					"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+					"integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU="
+				},
+				"tmp": {
+					"version": "0.0.33",
+					"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
+					"integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==",
+					"requires": {
+						"os-tmpdir": "~1.0.2"
+					}
+				},
+				"type-check": {
+					"version": "0.3.2",
+					"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+					"integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+					"requires": {
+						"prelude-ls": "~1.1.2"
+					}
+				},
+				"typedarray": {
+					"version": "0.0.6",
+					"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
+					"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
+				},
+				"util-deprecate": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+					"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
+				},
+				"which": {
+					"version": "1.3.1",
+					"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+					"integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+					"requires": {
+						"isexe": "^2.0.0"
+					}
+				},
+				"wordwrap": {
+					"version": "1.0.0",
+					"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+					"integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus="
+				},
+				"wrappy": {
+					"version": "1.0.2",
+					"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+					"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+				},
+				"write": {
+					"version": "0.2.1",
+					"resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz",
+					"integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=",
+					"requires": {
+						"mkdirp": "^0.5.1"
+					}
+				},
+				"yallist": {
+					"version": "2.1.2",
+					"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
+					"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
+				}
+			}
+		},
+		"tus-js-client": {
+			"version": "1.4.5",
+			"resolved": "https://registry.npmjs.org/tus-js-client/-/tus-js-client-1.4.5.tgz",
+			"integrity": "sha1-7lJd+KLE3EETvPkz3dfUCj20O5U=",
+			"requires": {
+				"buffer-from": "^0.1.1",
+				"extend": "^3.0.0",
+				"lodash.throttle": "^4.1.1",
+				"resolve-url": "^0.2.1"
+			},
+			"dependencies": {
+				"buffer-from": {
+					"version": "0.1.2",
+					"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-0.1.2.tgz",
+					"integrity": "sha512-RiWIenusJsmI2KcvqQABB83tLxCByE3upSP8QU3rJDMVFGPWLvPQJt/O1Su9moRWeH7d+Q2HYb68f6+v+tw2vg=="
+				},
+				"extend": {
+					"version": "3.0.1",
+					"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
+					"integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ="
+				},
+				"lodash.throttle": {
+					"version": "4.1.1",
+					"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+					"integrity": "sha1-wj6RtxAkKscMN/HhzaknTMOb8vQ="
+				},
+				"resolve-url": {
+					"version": "0.2.1",
+					"resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
+					"integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo="
+				}
+			}
+		}
+	}
+}

+ 106 - 0
packages/@uppy/companion/package.json

@@ -0,0 +1,106 @@
+{
+  "name": "@uppy/companion",
+  "version": "0.13.4",
+  "description": "Server component for Uppy's (https://uppy.io) extensible file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Dropbox and Google Drive, S3 and more :dog:",
+  "main": "lib/uppy.js",
+  "types": "types/index.d.ts",
+  "author": "Transloadit.com",
+  "license": "ISC",
+  "homepage": "https://github.com/transloadit/uppy#readme",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/transloadit/uppy.git"
+  },
+  "keywords": [
+    "file uploader",
+    "progress",
+    "preview",
+    "resumable uploads",
+    "tus",
+    "s3",
+    "google drive",
+    "dropbox",
+    "backend",
+    "websocket",
+    "express",
+    "realtime"
+  ],
+  "bin": {
+    "companion": "./lib/standalone/start-server.js"
+  },
+  "devDependencies": {
+    "@types/aws-serverless-express": "^3.0.1",
+    "@types/compression": "0.0.36",
+    "@types/connect-redis": "0.0.7",
+    "@types/cookie-parser": "^1.4.1",
+    "@types/cors": "^2.8.3",
+    "@types/express-session": "^1.15.6",
+    "@types/helmet": "0.0.37",
+    "@types/jsonwebtoken": "^7.2.5",
+    "@types/lodash.merge": "^4.6.3",
+    "@types/morgan": "^1.7.35",
+    "@types/ms": "^0.7.30",
+    "@types/node": "^8.5.1",
+    "@types/request": "^2.0.9",
+    "@types/tus-js-client": "^1.4.1",
+    "@types/uuid": "^3.4.3",
+    "@types/ws": "^3.2.1",
+    "jest-cli": "^23.1.0",
+    "nodemon": "^1.17.5",
+    "supertest": "3.0.0"
+  },
+  "dependencies": {
+    "@purest/providers": "1.0.0",
+    "@uppy/fs-tail-stream": "^1.2.0",
+    "atob": "2.1.0",
+    "aws-sdk": "^2.254.1",
+    "body-parser": "1.18.2",
+    "common-tags": "^1.7.2",
+    "connect-redis": "^3.3.0",
+    "cookie-parser": "1.4.3",
+    "express": "^4.16.0",
+    "express-interceptor": "^1.2.0",
+    "express-prom-bundle": "^3.1.0",
+    "express-session": "1.15.6",
+    "grant-express": "^4.0.1",
+    "helmet": "3.8.2",
+    "isobject": "3.0.1",
+    "jsonwebtoken": "^8.0.1",
+    "lodash.merge": "^4.6.0",
+    "morgan": "1.9.0",
+    "ms": "^2.0.0",
+    "node-redis-pubsub": "^2.0.0",
+    "node-schedule": "^1.3.0",
+    "prom-client": "^10.0.2",
+    "purest": "3.0.0",
+    "redis": "^2.7.1",
+    "request": "2.85.0",
+    "serialize-error": "^2.1.0",
+    "tus-js-client": "^1.5.1",
+    "uuid": "2.0.2",
+    "validator": "^9.0.0",
+    "ws": "1.1.5"
+  },
+  "files": [
+    "lib/"
+  ],
+  "jest": {
+    "testEnvironment": "node",
+    "automock": false,
+    "collectCoverage": true,
+    "collectCoverageFrom": [
+      "src/**"
+    ]
+  },
+  "scripts": {
+    "build": "tsc -p .",
+    "deploy": "kubectl apply -f infra/kube/companion-kube.yml",
+    "prepublishOnly": "npm run build",
+    "start": "node ./lib/standalone/start-server.js",
+    "test": "/bin/bash -c 'npm run build && source env.test.sh && jest'",
+    "test:watch": "npm test -- --watch"
+  },
+  "engines": {
+    "node": ">=6.0.0"
+  }
+}

+ 19 - 0
packages/@uppy/companion/src/config/grant.js

@@ -0,0 +1,19 @@
+// oauth configuration for provider services that are used.
+module.exports = () => {
+  return {
+    google: {
+      scope: [
+        'https://www.googleapis.com/auth/drive.readonly'
+      ],
+      callback: '/drive/callback'
+    },
+    dropbox: {
+      authorize_url: 'https://www.dropbox.com/oauth2/authorize',
+      access_url: 'https://api.dropbox.com/oauth2/token',
+      callback: '/dropbox/callback'
+    },
+    instagram: {
+      callback: '/instagram/callback'
+    }
+  }
+}

+ 405 - 0
packages/@uppy/companion/src/server/Uploader.js

@@ -0,0 +1,405 @@
+const fs = require('fs')
+const path = require('path')
+const tus = require('tus-js-client')
+const uuid = require('uuid')
+const createTailReadStream = require('@uppy/fs-tail-stream')
+const emitter = require('./emitter')
+const request = require('request')
+const serializeError = require('serialize-error')
+const { jsonStringify, hasMatch } = require('./helpers/utils')
+const logger = require('./logger')
+const validator = require('validator')
+const headerSanitize = require('./header-blacklist')
+
+class Uploader {
+  /**
+   * @typedef {object} UploaderOptions
+   * @property {string} endpoint
+   * @property {string=} uploadUrl
+   * @property {string} protocol
+   * @property {number} size
+   * @property {string=} fieldname
+   * @property {string} pathPrefix
+   * @property {string=} path
+   * @property {any=} s3
+   * @property {any} metadata
+   * @property {any} uppyOptions
+   * @property {any=} storage
+   * @property {any=} headers
+   *
+   * @param {UploaderOptions} options
+   */
+  constructor (options) {
+    if (!this.validateOptions(options)) {
+      logger.debug(this._errRespMessage, 'uploader.validator.fail')
+      return
+    }
+
+    this.options = options
+    this.token = uuid.v4()
+    this.options.path = `${this.options.pathPrefix}/${Uploader.FILE_NAME_PREFIX}-${this.token}`
+    this.writer = fs.createWriteStream(this.options.path, { mode: 0o666 }) // no executable files
+      .on('error', (err) => logger.error(`${this.shortToken} ${err}`, 'uploader.write.error'))
+    /** @type {number} */
+    this.emittedProgress = 0
+    this.storage = options.storage
+  }
+
+  /**
+   * Validate the options passed down to the uplaoder
+   *
+   * @param {UploaderOptions} options
+   * @returns {boolean}
+   */
+  validateOptions (options) {
+    if (!options.endpoint && !options.uploadUrl) {
+      this._errRespMessage = 'No destination specified'
+      return false
+    }
+
+    const validatorOpts = { require_protocol: true, require_tld: !options.uppyOptions.debug }
+    return [options.endpoint, options.uploadUrl].every((url) => {
+      if (url && !validator.isURL(url, validatorOpts)) {
+        this._errRespMessage = 'Invalid destination url'
+        return false
+      }
+
+      const allowedUrls = options.uppyOptions.uploadUrls
+      if (allowedUrls && url && !hasMatch(url, allowedUrls)) {
+        this._errRespMessage = 'upload destination does not match any allowed destinations'
+        return false
+      }
+
+      return true
+    })
+  }
+
+  /**
+   * returns a substring of the token
+   */
+  get shortToken () {
+    return this.token.substring(0, 8)
+  }
+
+  /**
+   *
+   * @param {function} callback
+   */
+  onSocketReady (callback) {
+    emitter().once(`connection:${this.token}`, () => callback())
+    logger.debug(`${this.shortToken} waiting for connection`, 'uploader.socket.wait')
+  }
+
+  cleanUp () {
+    fs.unlink(this.options.path, (err) => {
+      if (err) {
+        logger.error(`cleanup failed for: ${this.options.path} err: ${err}`, 'uploader.cleanup.error')
+      }
+    })
+    emitter().removeAllListeners(`pause:${this.token}`)
+    emitter().removeAllListeners(`resume:${this.token}`)
+  }
+
+  /**
+   *
+   * @param {Buffer | Buffer[]} chunk
+   */
+  handleChunk (chunk) {
+    logger.debug(`${this.shortToken} ${this.writer.bytesWritten} bytes`, 'uploader.download.progress')
+
+    const protocol = this.options.protocol || 'multipart'
+
+    // The download has completed; close the file and start an upload if necessary.
+    if (chunk === null) {
+      if (this.options.endpoint && protocol === 'multipart') {
+        this.writer.on('finish', () => {
+          this.uploadMultipart()
+        })
+      }
+      return this.writer.end()
+    }
+
+    this.writer.write(chunk, () => {
+      if (protocol === 's3-multipart' && !this.s3Upload) {
+        return this.uploadS3Streaming()
+      }
+      if (!this.options.endpoint) return
+
+      if (protocol === 'tus' && !this.tus) {
+        return this.uploadTus()
+      }
+    })
+  }
+
+  /**
+   *
+   * @param {object} resp
+   */
+  handleResponse (resp) {
+    resp.pipe(this.writer)
+
+    const protocol = this.options.protocol || 'multipart'
+
+    this.writer.on('finish', () => {
+      if (protocol === 's3-multipart') {
+        this.uploadS3Full()
+      }
+
+      if (!this.options.endpoint) return
+
+      if (protocol === 'tus') {
+        this.uploadTus()
+      }
+      if (protocol === 'multipart') {
+        this.uploadMultipart()
+      }
+    })
+  }
+
+  getResponse () {
+    if (this._errRespMessage) {
+      return { body: this._errRespMessage, status: 400 }
+    }
+    return { body: { token: this.token }, status: 200 }
+  }
+
+  /**
+   * @typedef {{action: string, payload: object}} State
+   * @param {State} state
+   */
+  saveState (state) {
+    if (!this.storage) return
+    this.storage.set(`${Uploader.STORAGE_PREFIX}:${this.token}`, jsonStringify(state))
+  }
+
+  /**
+   *
+   * @param {number} bytesUploaded
+   * @param {number | null} bytesTotal
+   */
+  emitProgress (bytesUploaded, bytesTotal) {
+    bytesTotal = bytesTotal || this.options.size
+    const percentage = (bytesUploaded / bytesTotal * 100)
+    const formatPercentage = percentage.toFixed(2)
+    logger.debug(
+      `${this.shortToken} ${bytesUploaded} ${bytesTotal} ${formatPercentage}%`,
+      'uploader.upload.progress'
+    )
+
+    const dataToEmit = {
+      action: 'progress',
+      payload: { progress: formatPercentage, bytesUploaded, bytesTotal }
+    }
+    this.saveState(dataToEmit)
+
+    // avoid flooding the client with progress events.
+    const roundedPercentage = Math.floor(percentage)
+    if (this.emittedProgress !== roundedPercentage) {
+      this.emittedProgress = roundedPercentage
+      emitter().emit(this.token, dataToEmit)
+    }
+  }
+
+  /**
+   *
+   * @param {string} url
+   * @param {object} extraData
+   */
+  emitSuccess (url, extraData = {}) {
+    const emitData = {
+      action: 'success',
+      payload: Object.assign(extraData, { complete: true, url })
+    }
+    this.saveState(emitData)
+    emitter().emit(this.token, emitData)
+  }
+
+  /**
+   *
+   * @param {Error} err
+   * @param {object=} extraData
+   */
+  emitError (err, extraData = {}) {
+    const dataToEmit = {
+      action: 'error',
+      // TODO: consider removing the stack property
+      payload: Object.assign(extraData, { error: serializeError(err) })
+    }
+    this.saveState(dataToEmit)
+    emitter().emit(this.token, dataToEmit)
+  }
+
+  uploadTus () {
+    const fname = path.basename(this.options.path)
+    const ftype = this.options.metadata.type
+    const metadata = Object.assign({ filename: fname, filetype: ftype }, this.options.metadata || {})
+    const file = fs.createReadStream(this.options.path)
+    const uploader = this
+
+    // @ts-ignore
+    this.tus = new tus.Upload(file, {
+      endpoint: this.options.endpoint,
+      uploadUrl: this.options.uploadUrl,
+      resume: true,
+      uploadSize: this.options.size || fs.statSync(this.options.path).size,
+      metadata,
+      chunkSize: this.writer.bytesWritten,
+      /**
+       *
+       * @param {Error} error
+       */
+      onError (error) {
+        logger.error(error, 'uploader.tus.error')
+        uploader.emitError(error)
+      },
+      /**
+       *
+       * @param {number} bytesUploaded
+       * @param {number} bytesTotal
+       */
+      onProgress (bytesUploaded, bytesTotal) {
+        uploader.emitProgress(bytesUploaded, bytesTotal)
+      },
+      /**
+       *
+       * @param {number} chunkSize
+       * @param {number} bytesUploaded
+       * @param {number} bytesTotal
+       */
+      onChunkComplete (chunkSize, bytesUploaded, bytesTotal) {
+        uploader.tus.options.chunkSize = uploader.writer.bytesWritten - bytesUploaded
+      },
+      onSuccess () {
+        uploader.emitSuccess(uploader.tus.url)
+        uploader.cleanUp()
+      }
+    })
+
+    this.tus.start()
+
+    emitter().on(`pause:${this.token}`, () => {
+      this.tus.abort()
+    })
+
+    emitter().on(`resume:${this.token}`, () => {
+      this.tus.start()
+    })
+  }
+
+  uploadMultipart () {
+    const file = fs.createReadStream(this.options.path)
+
+    // upload progress
+    let bytesUploaded = 0
+    file.on('data', (data) => {
+      bytesUploaded += data.length
+      this.emitProgress(bytesUploaded, null)
+    })
+
+    const formData = Object.assign(
+      {},
+      this.options.metadata,
+      { [this.options.fieldname]: file }
+    )
+    const headers = headerSanitize(this.options.headers)
+    request.post({ url: this.options.endpoint, headers, formData, encoding: null }, (error, response, body) => {
+      if (error) {
+        logger.error(error, 'upload.multipart.error')
+        this.emitError(error)
+        return
+      }
+      const headers = response.headers
+      // remove browser forbidden headers
+      delete headers['set-cookie']
+      delete headers['set-cookie2']
+
+      const respObj = {
+        responseText: body.toString(),
+        status: response.statusCode,
+        statusText: response.statusMessage,
+        headers
+      }
+
+      if (response.statusCode >= 400) {
+        logger.error(`upload failed with status: ${response.statusCode}`, 'upload.multipar.error')
+        this.emitError(new Error(response.statusMessage), respObj)
+      } else {
+        this.emitSuccess(null, { response: respObj })
+      }
+
+      this.cleanUp()
+    })
+  }
+
+  /**
+   * Upload the file to S3 while it is still being downloaded.
+   */
+  uploadS3Streaming () {
+    const file = createTailReadStream(this.options.path, {
+      tail: true
+    })
+
+    this.writer.on('finish', () => {
+      file.close()
+    })
+
+    return this._uploadS3(file)
+  }
+
+  /**
+   * Upload the file to S3 after it has been fully downloaded.
+   */
+  uploadS3Full () {
+    const file = fs.createReadStream(this.options.path)
+    return this._uploadS3(file)
+  }
+
+  /**
+   * Upload a stream to S3.
+   */
+  _uploadS3 (stream) {
+    if (!this.options.s3) {
+      this.emitError(new Error('The S3 client is not configured on this companion instance.'))
+      return
+    }
+
+    const filename = this.options.metadata.filename || path.basename(this.options.path)
+    const { client, options } = this.options.s3
+
+    const upload = client.upload({
+      Bucket: options.bucket,
+      Key: options.getKey(null, filename),
+      ACL: options.acl,
+      ContentType: this.options.metadata.type,
+      Body: stream
+    })
+
+    this.s3Upload = upload
+
+    upload.on('httpUploadProgress', ({ loaded, total }) => {
+      this.emitProgress(loaded, total)
+    })
+
+    upload.send((error, data) => {
+      this.s3Upload = null
+      if (error) {
+        this.emitError(error)
+      } else {
+        this.emitSuccess(null, {
+          response: {
+            responseText: JSON.stringify(data),
+            headers: {
+              'content-type': 'application/json'
+            }
+          }
+        })
+      }
+      this.cleanUp()
+    })
+  }
+}
+
+Uploader.FILE_NAME_PREFIX = 'uppy-file'
+Uploader.STORAGE_PREFIX = 'uppy-server'
+
+module.exports = Uploader

+ 30 - 0
packages/@uppy/companion/src/server/controllers/authorized.js

@@ -0,0 +1,30 @@
+// TODO: this function seems uneccessary. Might be better to just
+// have this as a middleware that is used for all auth required routes.
+
+const logger = require('../logger')
+
+/**
+ * checks if companion is authorized to access a user's provider account.
+ *
+ * @param {object} req
+ * @param {object} res
+ */
+function authorized (req, res) {
+  const { params, uppy } = req
+  const providerName = params.providerName
+
+  if (!uppy.providerTokens || !uppy.providerTokens[providerName]) {
+    return res.json({ authenticated: false })
+  }
+
+  const token = uppy.providerTokens[providerName]
+  uppy.provider.list({ token }, (err, response, body) => {
+    const notAuthenticated = Boolean(err)
+    if (notAuthenticated) {
+      logger.debug(`${providerName} failed authorizarion test err:${err}`, 'provider.auth.check')
+    }
+    return res.json({ authenticated: !notAuthenticated })
+  })
+}
+
+module.exports = authorized

+ 51 - 0
packages/@uppy/companion/src/server/controllers/callback.js

@@ -0,0 +1,51 @@
+/**
+ * oAuth callback.  Encrypts the access token and sends the new token with the response,
+ * and redirects to redirect url.
+ */
+const tokenService = require('../helpers/jwt')
+const parseUrl = require('url').parse
+const { hasMatch } = require('../helpers/utils')
+const oAuthState = require('../helpers/oauth-state')
+
+/**
+ *
+ * @param {object} req
+ * @param {object} res
+ * @param {function} next
+ */
+module.exports = function callback (req, res, next) {
+  const providerName = req.params.providerName
+
+  if (!req.uppy.providerTokens) {
+    req.uppy.providerTokens = {}
+  }
+
+  // TODO see if the access_token can be transported in a different way that url query params
+  req.uppy.providerTokens[providerName] = req.query.access_token
+  req.uppy.debugLog(`Generating auth token for provider ${providerName}.`)
+  const uppyAuthToken = tokenService.generateToken(req.uppy.providerTokens, req.uppy.options.secret)
+  // add the token to cookies for thumbnail/image requests
+  tokenService.addToCookies(res, uppyAuthToken, req.uppy.options)
+
+  if ((req.session.grant || {}).state) {
+    const origin = oAuthState.getFromState(req.session.grant.state, 'origin', req.uppy.options.secret)
+    const allowedClients = req.uppy.options.clients
+    // if no preset clients then allow any client
+    if (!allowedClients || hasMatch(origin, allowedClients) || hasMatch(parseUrl(origin).host, allowedClients)) {
+      return res.send(`
+        <!DOCTYPE html>
+        <html>
+        <head>
+            <meta charset="utf-8" />
+            <script>
+              window.opener.postMessage({token: "${uppyAuthToken}"}, "${origin}")
+              window.close()
+            </script>
+        </head>
+        <body></body>
+        </html>`
+      )
+    }
+  }
+  next()
+}

+ 25 - 0
packages/@uppy/companion/src/server/controllers/connect.js

@@ -0,0 +1,25 @@
+const oAuthState = require('../helpers/oauth-state')
+// @ts-ignore
+const atob = require('atob')
+
+/**
+ * initializes the oAuth flow for a provider.
+ *
+ * @param {object} req
+ * @param {object} res
+ */
+module.exports = function connect (req, res) {
+  const secret = req.uppy.options.secret
+  let state = oAuthState.generateState(secret)
+  if (req.query.state) {
+    // todo change this query from state to "origin"
+    const origin = JSON.parse(atob(req.query.state))
+    state = oAuthState.addToState(state, origin, secret)
+  }
+
+  if (req.uppy.options.server.oauthDomain) {
+    state = oAuthState.addToState(state, { uppyInstance: req.uppy.buildURL('', true) }, secret)
+  }
+
+  res.redirect(req.uppy.buildURL(`/connect/${req.uppy.provider.authProvider}?state=${state}`, true))
+}

+ 51 - 0
packages/@uppy/companion/src/server/controllers/get.js

@@ -0,0 +1,51 @@
+const Uploader = require('../Uploader')
+const redis = require('../redis')
+const logger = require('../logger')
+
+function get (req, res) {
+  const providerName = req.params.providerName
+  const id = req.params.id
+  const body = req.body
+  const token = req.uppy.providerTokens[providerName]
+  const provider = req.uppy.provider
+  const { providerOptions } = req.uppy.options
+
+  // get the file size before proceeding
+  provider.size({ id, token }, (size) => {
+    if (!size) {
+      logger.error('unable to determine file size', 'controller.get.provider.size')
+      return res.status(400).json({error: 'unable to determine file size'})
+    }
+
+    req.uppy.debugLog('Instantiating uploader.')
+    const uploader = new Uploader({
+      uppyOptions: req.uppy.options,
+      endpoint: body.endpoint,
+      uploadUrl: body.uploadUrl,
+      protocol: body.protocol,
+      metadata: body.metadata,
+      size: size,
+      fieldname: body.fieldname,
+      pathPrefix: `${req.uppy.options.filePath}`,
+      storage: redis.client(),
+      s3: req.uppy.s3Client ? {
+        client: req.uppy.s3Client,
+        options: providerOptions.s3
+      } : null,
+      headers: body.headers
+    })
+
+    // wait till the client has connected to the socket, before starting
+    // the download, so that the client can receive all download/upload progress.
+    req.uppy.debugLog('Waiting for socket connection before beginning remote download.')
+    // waiting for socketReady.
+    uploader.onSocketReady(() => {
+      req.uppy.debugLog('Socket connection received. Starting remote download.')
+      provider.download({ id, token, query: req.query }, uploader.handleChunk.bind(uploader))
+    })
+    const response = uploader.getResponse()
+    res.status(response.status).json(response.body)
+  })
+}
+
+module.exports = get

+ 10 - 0
packages/@uppy/companion/src/server/controllers/index.js

@@ -0,0 +1,10 @@
+module.exports = {
+  authorized: require('./authorized'),
+  callback: require('./callback'),
+  get: require('./get'),
+  thumbnail: require('./thumbnail'),
+  list: require('./list'),
+  logout: require('./logout'),
+  connect: require('./connect'),
+  redirect: require('./oauth-redirect')
+}

+ 13 - 0
packages/@uppy/companion/src/server/controllers/list.js

@@ -0,0 +1,13 @@
+function list ({ query, params, uppy }, res, next) {
+  const providerName = params.providerName
+  const token = uppy.providerTokens[providerName]
+
+  uppy.provider.list({ token, directory: params.id, query }, (err, resp, body) => {
+    if (err) {
+      return next(err)
+    }
+    return res.json(body)
+  })
+}
+
+module.exports = list

+ 28 - 0
packages/@uppy/companion/src/server/controllers/logout.js

@@ -0,0 +1,28 @@
+const tokenService = require('../helpers/jwt')
+
+/**
+ *
+ * @param {object} req
+ * @param {object} res
+ */
+function logout (req, res) {
+  const session = req.session
+  const providerName = req.params.providerName
+
+  if (req.uppy.providerTokens[providerName]) {
+    delete req.uppy.providerTokens[providerName]
+    tokenService.addToCookies(
+      res,
+      tokenService.generateToken(req.uppy.providerTokens, req.uppy.options.secret),
+      req.uppy.options
+    )
+  }
+
+  if (session.grant) {
+    session.grant.state = null
+    session.grant.dynamic = null
+  }
+  res.json({ ok: true })
+}
+
+module.exports = logout

+ 26 - 0
packages/@uppy/companion/src/server/controllers/oauth-redirect.js

@@ -0,0 +1,26 @@
+const qs = require('querystring')
+const parseUrl = require('url').parse
+const { hasMatch } = require('../helpers/utils')
+const oAuthState = require('../helpers/oauth-state')
+
+/**
+ *
+ * @param {object} req
+ * @param {object} res
+ */
+module.exports = function oauthRedirect (req, res) {
+  if (!req.query.state) {
+    return res.status(400).send('Cannot find state param in reques')
+  }
+  const handler = oAuthState.getFromState(req.query.state, 'uppyInstance', req.uppy.options.secret)
+  const handlerHostName = parseUrl(handler).host
+
+  if (hasMatch(handlerHostName, req.uppy.options.server.validHosts)) {
+    const providerName = req.uppy.provider.authProvider
+    const params = qs.stringify(req.query)
+    const url = `${handler}/connect/${providerName}/callback?${params}`
+    return res.redirect(url)
+  }
+
+  res.status(400).send('Invalid Host in state')
+}

+ 282 - 0
packages/@uppy/companion/src/server/controllers/s3.js

@@ -0,0 +1,282 @@
+const router = require('express').Router
+const ms = require('ms')
+
+module.exports = function s3 (config) {
+  if (typeof config.acl !== 'string') {
+    throw new TypeError('s3: The `acl` option must be a string')
+  }
+  if (typeof config.getKey !== 'function') {
+    throw new TypeError('s3: The `getKey` option must be a function')
+  }
+
+  /**
+   * Get upload paramaters for a simple direct upload.
+   *
+   * Expected query parameters:
+   *  - filename - The name of the file, given to the `config.getKey`
+   *    option to determine the object key name in the S3 bucket.
+   *  - type - The MIME type of the file.
+   *
+   * Response JSON:
+   *  - method - The HTTP method to use to upload.
+   *  - url - The URL to upload to.
+   *  - fields - Form fields to send along.
+   */
+  function getUploadParameters (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const key = config.getKey(req, req.query.filename)
+    if (typeof key !== 'string') {
+      return res.status(500).json({ error: 's3: filename returned from `getKey` must be a string' })
+    }
+
+    const fields = {
+      acl: config.acl,
+      key: key,
+      success_action_status: '201',
+      'content-type': req.query.type
+    }
+
+    client.createPresignedPost({
+      Bucket: config.bucket,
+      Expires: ms('5 minutes') / 1000,
+      Fields: fields,
+      Conditions: config.conditions
+    }, (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({
+        method: 'post',
+        url: data.url,
+        fields: data.fields
+      })
+    })
+  }
+
+  /**
+   * Create an S3 multipart upload. With this, files can be uploaded in chunks of 5MB+ each.
+   *
+   * Expected JSON body:
+   *  - filename - The name of the file, given to the `config.getKey`
+   *    option to determine the object key name in the S3 bucket.
+   *  - type - The MIME type of the file.
+   *
+   * Response JSON:
+   *  - key - The object key in the S3 bucket.
+   *  - uploadId - The ID of this multipart upload, to be used in later requests.
+   */
+  function createMultipartUpload (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const key = config.getKey(req, req.body.filename)
+    const { type } = req.body
+    if (typeof key !== 'string') {
+      return res.status(500).json({ error: 's3: filename returned from `getKey` must be a string' })
+    }
+    if (typeof type !== 'string') {
+      return res.status(400).json({ error: 's3: content type must be a string' })
+    }
+
+    client.createMultipartUpload({
+      Bucket: config.bucket,
+      Key: key,
+      ACL: config.acl,
+      ContentType: type,
+      Expires: ms('5 minutes') / 1000
+    }, (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({
+        key: data.Key,
+        uploadId: data.UploadId
+      })
+    })
+  }
+
+  /**
+   * List parts that have been fully uploaded so far.
+   *
+   * Expected URL parameters:
+   *  - uploadId - The uploadId returned from `createMultipartUpload`.
+   * Expected query parameters:
+   *  - key - The object key in the S3 bucket.
+   * Response JSON:
+   *  - An array of objects representing parts:
+   *     - PartNumber - the index of this part.
+   *     - ETag - a hash of this part's contents, used to refer to it.
+   *     - Size - size of this part.
+   */
+  function getUploadedParts (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const { uploadId } = req.params
+    const { key } = req.query
+
+    if (typeof key !== 'string') {
+      return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    }
+
+    let parts = []
+    listPartsPage(0)
+
+    function listPartsPage (startAt) {
+      client.listParts({
+        Bucket: config.bucket,
+        Key: key,
+        UploadId: uploadId,
+        PartNumberMarker: startAt
+      }, (err, data) => {
+        if (err) {
+          next(err)
+          return
+        }
+
+        parts = parts.concat(data.Parts)
+
+        if (data.IsTruncated) {
+          // Get the next page.
+          listPartsPage(data.NextPartNumberMarker)
+        } else {
+          done()
+        }
+      })
+    }
+
+    function done () {
+      res.json(parts)
+    }
+  }
+
+  /**
+   * Get parameters for uploading one part.
+   *
+   * Expected URL parameters:
+   *  - uploadId - The uploadId returned from `createMultipartUpload`.
+   *  - partNumber - This part's index in the file (1-10000).
+   * Expected query parameters:
+   *  - key - The object key in the S3 bucket.
+   * Response JSON:
+   *  - url - The URL to upload to, including signed query parameters.
+   */
+  function signPartUpload (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const { uploadId, partNumber } = req.params
+    const { key } = req.query
+
+    if (typeof key !== 'string') {
+      return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    }
+    if (!parseInt(partNumber, 10)) {
+      return res.status(400).json({ error: 's3: the part number must be a number between 1 and 10000.' })
+    }
+
+    client.getSignedUrl('uploadPart', {
+      Bucket: config.bucket,
+      Key: key,
+      UploadId: uploadId,
+      PartNumber: partNumber,
+      Body: '',
+      Expires: ms('5 minutes') / 1000
+    }, (err, url) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({ url })
+    })
+  }
+
+  /**
+   * Abort a multipart upload, deleting already uploaded parts.
+   *
+   * Expected URL parameters:
+   *  - uploadId - The uploadId returned from `createMultipartUpload`.
+   * Expected query parameters:
+   *  - key - The object key in the S3 bucket.
+   * Response JSON:
+   *   Empty.
+   */
+  function abortMultipartUpload (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const { uploadId } = req.params
+    const { key } = req.query
+
+    if (typeof key !== 'string') {
+      return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    }
+
+    client.abortMultipartUpload({
+      Bucket: config.bucket,
+      Key: key,
+      UploadId: uploadId
+    }, (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({})
+    })
+  }
+
+  /**
+   * Complete a multipart upload, combining all the parts into a single object in the S3 bucket.
+   *
+   * Expected URL parameters:
+   *  - uploadId - The uploadId returned from `createMultipartUpload`.
+   * Expected query parameters:
+   *  - key - The object key in the S3 bucket.
+   * Expected JSON body:
+   *  - parts - An array of parts, see the `getUploadedParts` response JSON.
+   * Response JSON:
+   *  - location - The full URL to the object in the S3 bucket.
+   */
+  function completeMultipartUpload (req, res, next) {
+    // @ts-ignore The `uppy` property is added by middleware before reaching here.
+    const client = req.uppy.s3Client
+    const { uploadId } = req.params
+    const { key } = req.query
+    const { parts } = req.body
+
+    if (typeof key !== 'string') {
+      return res.status(400).json({ error: 's3: the object key must be passed as a query parameter. For example: "?key=abc.jpg"' })
+    }
+    if (!Array.isArray(parts) || !parts.every(isValidPart)) {
+      return res.status(400).json({ error: 's3: `parts` must be an array of {ETag, PartNumber} objects.' })
+    }
+
+    client.completeMultipartUpload({
+      Bucket: config.bucket,
+      Key: key,
+      UploadId: uploadId,
+      MultipartUpload: {
+        Parts: parts
+      }
+    }, (err, data) => {
+      if (err) {
+        next(err)
+        return
+      }
+      res.json({
+        location: data.Location
+      })
+    })
+  }
+
+  return router()
+    .get('/params', getUploadParameters)
+    .post('/multipart', createMultipartUpload)
+    .get('/multipart/:uploadId', getUploadedParts)
+    .get('/multipart/:uploadId/:partNumber', signPartUpload)
+    .post('/multipart/:uploadId/complete', completeMultipartUpload)
+    .delete('/multipart/:uploadId', abortMultipartUpload)
+}
+
+function isValidPart (part) {
+  return part && typeof part === 'object' && typeof part.PartNumber === 'number' && typeof part.ETag === 'string'
+}

+ 15 - 0
packages/@uppy/companion/src/server/controllers/thumbnail.js

@@ -0,0 +1,15 @@
+/**
+ *
+ * @param {object} req
+ * @param {object} res
+ */
+function thumbnail (req, res) {
+  const providerName = req.params.providerName
+  const id = req.params.id
+  const token = req.uppy.providerTokens[providerName]
+  const provider = req.uppy.provider
+
+  provider.thumbnail({ id, token }, (response) => response ? response.pipe(res) : res.sendStatus(404))
+}
+
+module.exports = thumbnail

+ 95 - 0
packages/@uppy/companion/src/server/controllers/url.js

@@ -0,0 +1,95 @@
+const router = require('express').Router
+const request = require('request')
+const Uploader = require('../Uploader')
+const validator = require('validator')
+const utils = require('../helpers/utils')
+const logger = require('../logger')
+const redis = require('../redis')
+
+module.exports = () => {
+  return router()
+    .post('/meta', meta)
+    .post('/get', get)
+}
+
+/**
+ * Fteches the size and content type of a URL
+ *
+ * @param {object} req expressJS request object
+ * @param {object} res expressJS response object
+ */
+const meta = (req, res) => {
+  req.uppy.debugLog('URL file import handler running')
+
+  if (!validator.isURL(req.body.url, { require_protocol: true, require_tld: !req.uppy.options.debug })) {
+    req.uppy.debugLog('Invalid request body detected. Exiting url meta handler.')
+    return res.status(400).json({error: 'Invalid request body'})
+  }
+
+  utils.getURLMeta(req.body.url)
+    .then((meta) => res.json(meta))
+    .catch((err) => {
+      logger.error(err, 'controller.url.meta.error')
+      return res.status(500).json({ error: err })
+    })
+}
+
+/**
+ * Handles the reques of import a file from a remote URL, and then
+ * subsequently uploading it to the specified destination.
+ *
+ * @param {object} req expressJS request object
+ * @param {object} res expressJS response object
+ */
+const get = (req, res) => {
+  req.uppy.debugLog('URL file import handler running')
+
+  utils.getURLMeta(req.body.url)
+    .then(({ size }) => {
+      // @ts-ignore
+      const { filePath } = req.uppy.options
+      req.uppy.debugLog('Instantiating uploader.')
+      const uploader = new Uploader({
+        uppyOptions: req.uppy.options,
+        endpoint: req.body.endpoint,
+        uploadUrl: req.body.uploadUrl,
+        protocol: req.body.protocol,
+        metadata: req.body.metadata,
+        size: size,
+        pathPrefix: `${filePath}`,
+        storage: redis.client(),
+        headers: req.body.headers
+      })
+
+      req.uppy.debugLog('Waiting for socket connection before beginning remote download.')
+      uploader.onSocketReady(() => {
+        req.uppy.debugLog('Socket connection received. Starting remote download.')
+        downloadURL(req.body.url, uploader.handleChunk.bind(uploader))
+      })
+
+      const response = uploader.getResponse()
+      res.status(response.status).json(response.body)
+    }).catch((err) => {
+      logger.error(err, 'controller.url.get.error')
+      res.json({ err })
+    })
+}
+
+/**
+ * Downloads the content in the specified url, and passes the data
+ * to the callback chunk by chunk.
+ *
+ * @param {string} url
+ * @param {typeof Function} onDataChunk
+ */
+const downloadURL = (url, onDataChunk) => {
+  const opts = {
+    uri: url,
+    method: 'GET',
+    followAllRedirects: true
+  }
+
+  request(opts)
+    .on('data', onDataChunk)
+    .on('error', (err) => logger.error(err, 'controller.url.download.error'))
+}

+ 5 - 0
packages/@uppy/companion/src/server/emitter/default-emitter.js

@@ -0,0 +1,5 @@
+const EventEmitter = require('events').EventEmitter
+
+module.exports = () => {
+  return new EventEmitter()
+}

+ 16 - 0
packages/@uppy/companion/src/server/emitter/index.js

@@ -0,0 +1,16 @@
+const nodeEmitter = require('./default-emitter')
+const redisEmitter = require('./redis-emitter')
+let emitter
+
+/**
+ * Singleton event emitter that is shared between modules throughout the lifetime of the server.
+ * Used to transmit events (such as progress, upload completion) from controllers,
+ * such as the Google Drive 'get' controller, along to the client.
+ */
+module.exports = (redisUrl) => {
+  if (!emitter) {
+    emitter = redisUrl ? redisEmitter(redisUrl) : nodeEmitter()
+  }
+
+  return emitter
+}

+ 63 - 0
packages/@uppy/companion/src/server/emitter/redis-emitter.js

@@ -0,0 +1,63 @@
+// @ts-ignore
+const NRP = require('node-redis-pubsub')
+
+/**
+ * This class simulates the builtin events.EventEmitter but with the use of redis.
+ * This is useful for when companion is running on multiple instances and events need
+ * to be distributed across.
+ */
+class RedisEmitter extends NRP {
+  /**
+   *
+   * @param {string} redisUrl redis URL
+   */
+  constructor (redisUrl) {
+    // @ts-ignore
+    super({url: redisUrl})
+  }
+
+  /**
+   * Add a one-off event listener
+   * @param {string} eventName name of the event
+   * @param {function} handler the handler of the event
+   */
+  once (eventName, handler) {
+    let removeListener
+    removeListener = this.on(eventName, (message) => {
+      handler(message)
+      removeListener()
+    })
+  }
+
+  /**
+   * Announce the occurence of an event
+   * @param {string} eventName name of the event
+   * @param {object} message the message to pass along with the event
+   */
+  emit (eventName, message) {
+    return super.emit(eventName, message || {})
+  }
+
+  /**
+   * Remove an event listener
+   * @param {string} eventName name of the event
+   * @param {function} handler the handler of the event to remove
+   */
+  removeListener (eventName, handler) {
+    this.receiver.removeListener(eventName, handler)
+    this.receiver.punsubscribe(this.prefix + eventName)
+  }
+
+  /**
+   * Remove all listeners of an event
+   * @param {string} eventName name of the event
+   */
+  removeAllListeners (eventName) {
+    this.receiver.removeAllListeners(eventName)
+    this.receiver.punsubscribe(this.prefix + eventName)
+  }
+}
+
+module.exports = (redisUrl) => {
+  return new RedisEmitter(redisUrl)
+}

+ 64 - 0
packages/@uppy/companion/src/server/header-blacklist.js

@@ -0,0 +1,64 @@
+const isObject = require('isobject')
+const logger = require('./logger')
+
+/**
+ * Forbidden header names.
+ */
+const forbiddenNames = [
+  'accept-charset',
+  'accept-encoding',
+  'access-control-request-headers',
+  'access-control-request-method',
+  'connection',
+  'content-length',
+  'cookie',
+  'cookie2',
+  'date',
+  'dnt',
+  'expect',
+  'host',
+  'keep-alive',
+  'origin',
+  'referer',
+  'te',
+  'trailer',
+  'transfer-encoding',
+  'upgrade',
+  'via'
+]
+
+/**
+ * Forbidden header regexs.
+ */
+const forbiddenRegex = [/^proxy-.*$/, /^sec-.*$/]
+
+/**
+ * Check if the header in parameter is a forbidden header.
+ * @param {string} header Header to check
+ * @return True if header is forbidden, false otherwise.
+ */
+const isForbiddenHeader = (header) => {
+  const headerLower = header.toLowerCase()
+  const forbidden =
+    forbiddenNames.indexOf(headerLower) >= 0 ||
+    forbiddenRegex.findIndex((regex) => regex.test(headerLower)) >= 0
+
+  if (forbidden) {
+    logger.warn(`Header forbbiden: ${header}`, 'header.forbidden')
+  }
+  return forbidden
+}
+
+module.exports = (headers) => {
+  if (!isObject(headers)) {
+    return {}
+  }
+
+  const headersCloned = Object.assign({}, headers)
+  Object.keys(headersCloned).forEach((header) => {
+    if (isForbiddenHeader(header)) {
+      delete headersCloned[header]
+    }
+  })
+  return headersCloned
+}

+ 44 - 0
packages/@uppy/companion/src/server/helpers/jwt.js

@@ -0,0 +1,44 @@
+const jwt = require('jsonwebtoken')
+const { encrypt, decrypt } = require('./utils')
+
+/**
+ *
+ * @param {*} payload
+ * @param {string} secret
+ */
+module.exports.generateToken = (payload, secret) => {
+  return encrypt(jwt.sign({data: payload}, secret, { expiresIn: 60 * 60 * 24 }), secret)
+}
+
+/**
+ *
+ * @param {string} token
+ * @param {string} secret
+ */
+module.exports.verifyToken = (token, secret) => {
+  try {
+    // @ts-ignore
+    return {payload: jwt.verify(decrypt(token, secret), secret, {}).data}
+  } catch (err) {
+    return {err}
+  }
+}
+
+/**
+ *
+ * @param {object} res
+ * @param {string} token
+ * @param {object=} uppyOptions
+ */
+module.exports.addToCookies = (res, token, uppyOptions) => {
+  const cookieOptions = {
+    maxAge: 1000 * 60 * 60 * 24 * 30, // would expire after 30 days
+    httpOnly: true
+  }
+
+  if (uppyOptions.cookieDomain) {
+    cookieOptions.domain = uppyOptions.cookieDomain
+  }
+  // send signed token to client.
+  res.cookie('uppyAuthToken', token, cookieOptions)
+}

+ 29 - 0
packages/@uppy/companion/src/server/helpers/oauth-state.js

@@ -0,0 +1,29 @@
+const { encrypt, decrypt } = require('./utils')
+const crypto = require('crypto')
+// @ts-ignore
+const atob = require('atob')
+
+module.exports.generateState = (secret) => {
+  const state = {}
+  state.id = crypto.randomBytes(10).toString('hex')
+  return setState(state, secret)
+}
+
+module.exports.addToState = (state, data, secret) => {
+  const stateObj = getState(state, secret)
+  return setState(Object.assign(stateObj, data), secret)
+}
+
+module.exports.getFromState = (state, name, secret) => {
+  return getState(state, secret)[name]
+}
+
+const setState = (state, secret) => {
+  const encodedState = Buffer.from(JSON.stringify(state)).toString('base64')
+  return encrypt(encodedState, secret)
+}
+
+const getState = (state, secret) => {
+  const encodedState = decrypt(state, secret)
+  return JSON.parse(atob(encodedState))
+}

+ 112 - 0
packages/@uppy/companion/src/server/helpers/utils.js

@@ -0,0 +1,112 @@
+const request = require('request')
+const crypto = require('crypto')
+
+/**
+ *
+ * @param {string} value
+ * @param {string[]} criteria
+ * @returns {boolean}
+ */
+exports.hasMatch = (value, criteria) => {
+  return criteria.some((i) => {
+    return value === i || (new RegExp(i)).test(value)
+  })
+}
+
+/**
+ *
+ * @param {object} data
+ * @returns {string}
+ */
+exports.jsonStringify = (data) => {
+  const cache = []
+  return JSON.stringify(data, (key, value) => {
+    if (typeof value === 'object' && value !== null) {
+      if (cache.indexOf(value) !== -1) {
+        // Circular reference found, discard key
+        return
+      }
+      cache.push(value)
+    }
+    return value
+  })
+}
+
+/**
+ * Gets the size and content type of a url's content
+ *
+ * @param {string} url
+ * @return {Promise}
+ */
+exports.getURLMeta = (url) => {
+  return new Promise((resolve, reject) => {
+    const opts = {
+      uri: url,
+      method: 'HEAD',
+      followAllRedirects: true
+    }
+
+    request(opts, (err, response, body) => {
+      if (err) {
+        reject(err)
+      } else {
+        resolve({
+          type: response.headers['content-type'],
+          size: parseInt(response.headers['content-length'])
+        })
+      }
+    })
+  })
+}
+
+// all paths are assumed to be '/' prepended
+/**
+ * Returns a url builder
+ *
+ * @param {object} options uppy options
+ */
+module.exports.getURLBuilder = (options) => {
+  /**
+   * Builds uppy targeted url
+   *
+   * @param {string} path the tail path of the url
+   * @param {boolean} isExternal if the url is for the external world
+   * @param {boolean=} excludeHost if the server domain and protocol should be included
+   */
+  const buildURL = (path, isExternal, excludeHost) => {
+    let url = path
+    // supports for no path specified too
+    if (isExternal) {
+      url = `${options.server.implicitPath || ''}${url}`
+    }
+
+    url = `${options.server.path || ''}${url}`
+
+    if (!excludeHost) {
+      url = `${options.server.protocol}://${options.server.host}${url}`
+    }
+
+    return url
+  }
+
+  return buildURL
+}
+
+/**
+ *
+ * @param {*} input
+ * @param {string} secret
+ */
+module.exports.encrypt = (input, secret) => {
+  const cipher = crypto.createCipher('aes256', secret)
+  let encrypted = cipher.update(input, 'utf8', 'hex')
+  encrypted += cipher.final('hex')
+  return encrypted
+}
+
+module.exports.decrypt = (encrypted, secret) => {
+  var decipher = crypto.createDecipher('aes256', secret)
+  var decrypted = decipher.update(encrypted, 'hex', 'utf8')
+  decrypted += decipher.final('utf8')
+  return decrypted
+}

+ 55 - 0
packages/@uppy/companion/src/server/jobs.js

@@ -0,0 +1,55 @@
+const schedule = require('node-schedule')
+const { FILE_NAME_PREFIX } = require('./Uploader')
+const fs = require('fs')
+const path = require('path')
+const logger = require('./logger')
+
+/**
+ * Runs a function every 24 hours, to clean up stale, upload related files.
+ * @param {string} dirPath path to the directory which you want to clean
+ */
+exports.startCleanUpJob = (dirPath) => {
+  logger.info('starting clean up job', 'jobs.cleanup.start')
+  // run once a day
+  schedule.scheduleJob('0 23 * * *', () => cleanUpFinishedUploads(dirPath))
+}
+
+const cleanUpFinishedUploads = (dirPath) => {
+  logger.info(`running clean up job for path: ${dirPath}`, 'jobs.cleanup.progress.read')
+  fs.readdir(dirPath, (err, files) => {
+    if (err) {
+      logger.error(err, 'jobs.cleanup.read.error')
+      return
+    }
+
+    logger.info(`found ${files.length} files`, 'jobs.cleanup.files')
+    files.forEach((file, fileIndex) => {
+      // if it does not contain FILE_NAME_PREFIX then it probably wasn't created by companion.
+      // this is to avoid deleting unintended files, e.g if a wrong path was accidentally given
+      // by a developer.
+      if (!file.startsWith(FILE_NAME_PREFIX)) {
+        logger.info(`skipping file ${file}`, 'jobs.cleanup.skip')
+        return
+      }
+      const fullPath = path.join(dirPath, file)
+
+      fs.stat(fullPath, (err, stats) => {
+        const twelveHoursAgo = 12 * 60 * 60 * 1000
+        if (err) {
+          // we still delete the file if we can't get the stats
+          // but we also log the error
+          logger.error(err, 'jobs.cleanup.stat.error')
+        // @ts-ignore
+        } else if (((new Date()) - stats.mtime) < twelveHoursAgo) {
+          logger.info(`skipping file ${file}`, 'jobs.cleanup.skip')
+          return
+        }
+
+        logger.info(`deleting file ${file}`, 'jobs.cleanup.progress.delete')
+        fs.unlink(fullPath, (err) => {
+          if (err) logger.error(err, 'jobs.cleanup.delete.error')
+        })
+      })
+    })
+  })
+}

+ 49 - 0
packages/@uppy/companion/src/server/logger.js

@@ -0,0 +1,49 @@
+/**
+ * INFO level log
+ * @param {string} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ */
+exports.info = (msg, tag) => {
+  log(msg, tag, 'info')
+}
+
+/**
+ * WARN level log
+ * @param {string} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ */
+exports.warn = (msg, tag) => {
+  log(msg, tag, 'warn')
+}
+
+/**
+ * ERROR level log
+ * @param {string | Error} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ */
+exports.error = (msg, tag) => {
+  log(msg, tag, 'error')
+}
+
+/**
+ * DEBUG level log
+ * @param {string} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ */
+exports.debug = (msg, tag) => {
+  if (process.env.NODE_ENV !== 'production') {
+    log(msg, tag, 'debug')
+  }
+}
+
+/**
+ * message log
+ * @param {string | Error} msg the message to log
+ * @param {string} tag a unique tag to easily search for this message
+ * @param {string} level error | info | debug
+ */
+const log = (msg, tag, level) => {
+  // @TODO add some colors based on log level
+  const time = new Date().toISOString()
+  console.log(`uppy: ${time} [${level}] ${tag} ${msg}`)
+}

+ 42 - 0
packages/@uppy/companion/src/server/middlewares.js

@@ -0,0 +1,42 @@
+const tokenService = require('./helpers/jwt')
+
+exports.hasSessionAndProvider = (req, res, next) => {
+  if (!req.session || !req.body) {
+    req.uppy.debugLog('No session/body attached to req object. Exiting dispatcher.')
+    return res.sendStatus(400)
+  }
+
+  if (!req.uppy.provider) {
+    req.uppy.debugLog('No provider/provider-handler found. Exiting dispatcher.')
+    return res.sendStatus(400)
+  }
+
+  return next()
+}
+
+exports.verifyToken = (req, res, next) => {
+  const providerName = req.params.providerName
+  const { err, payload } = tokenService.verifyToken(req.uppy.authToken, req.uppy.options.secret)
+  if (err || !payload[providerName]) {
+    return res.sendStatus(401)
+  }
+  req.uppy.providerTokens = payload
+  next()
+}
+
+// does not fail if token is invalid
+exports.gentleVerifyToken = (req, res, next) => {
+  const providerName = req.params.providerName
+  if (req.uppy.authToken) {
+    const { err, payload } = tokenService.verifyToken(req.uppy.authToken, req.uppy.options.secret)
+    if (!err && payload[providerName]) {
+      req.uppy.providerTokens = payload
+    }
+  }
+  next()
+}
+
+exports.cookieAuthToken = (req, res, next) => {
+  req.uppy.authToken = req.cookies.uppyAuthToken
+  return next()
+}

+ 73 - 0
packages/@uppy/companion/src/server/provider/drive.js

@@ -0,0 +1,73 @@
+const request = require('request')
+// @ts-ignore
+const purest = require('purest')({ request })
+const logger = require('../logger')
+
+/**
+ * @class
+ * @implements {Provider}
+ */
+class Drive {
+  constructor (options) {
+    this.authProvider = options.provider = Drive.authProvider
+    options.alias = 'drive'
+
+    this.client = purest(options)
+  }
+
+  static get authProvider () {
+    return 'google'
+  }
+
+  list (options, done) {
+    const directory = options.directory || 'root'
+    const trashed = options.trashed || false
+
+    return this.client
+      .query()
+      .get('files')
+      .where({ q: `'${directory}' in parents and trashed=${trashed}` })
+      .auth(options.token)
+      .request(done)
+  }
+
+  stats ({ id, token }, done) {
+    return this.client.query().get(`files/${id}`).auth(token).request(done)
+  }
+
+  download ({ id, token }, onData) {
+    return this.client
+      .query()
+      .get(`files/${id}`)
+      .where({ alt: 'media' })
+      .auth(token)
+      .request()
+      .on('data', onData)
+      .on('end', () => onData(null))
+      .on('error', (err) => {
+        logger.error(err, 'provider.drive.download.error')
+      })
+  }
+
+  thumbnail ({id, token}, done) {
+    return this.stats({id, token}, (err, resp, body) => {
+      if (err) {
+        logger.error(err, 'provider.drive.thumbnail.error')
+        return done(null)
+      }
+      done(body.thumbnailLink ? request(body.thumbnailLink) : null)
+    })
+  }
+
+  size ({id, token}, done) {
+    return this.stats({ id, token }, (err, resp, body) => {
+      if (err) {
+        logger.error(err, 'provider.drive.size.error')
+        return done(null)
+      }
+      done(parseInt(body.fileSize))
+    })
+  }
+}
+
+module.exports = Drive

+ 133 - 0
packages/@uppy/companion/src/server/provider/dropbox.js

@@ -0,0 +1,133 @@
+const request = require('request')
+const purest = require('purest')({ request })
+const logger = require('../logger')
+
+/**
+ *
+ */
+class DropBox {
+  constructor (options) {
+    this.authProvider = options.provider = DropBox.authProvider
+    this.client = purest(options)
+  }
+
+  static get authProvider () {
+    return 'dropbox'
+  }
+
+  _userInfo ({ token }, done) {
+    this.client
+      .post('users/get_current_account')
+      .options({ version: '2' })
+      .auth(token)
+      .request(done)
+  }
+
+  /**
+   * Makes 2 requests in parallel - 1. to get files, 2. to get user email
+   * it then waits till both requests are done before proceeding with the callback
+   *
+   * @param {object} options
+   * @param {function} done
+   */
+  list (options, done) {
+    let userInfoDone = false
+    let statsDone = false
+    let userInfo
+    let stats
+    let reqErr
+    const finishReq = () => {
+      if (!reqErr) {
+        stats.body.user_email = userInfo.body.email
+      }
+      done(reqErr, stats, stats.body)
+    }
+
+    this.stats(options, (err, resp) => {
+      statsDone = true
+      stats = resp
+      reqErr = reqErr || err
+      if (userInfoDone) {
+        finishReq()
+      }
+    })
+
+    this._userInfo(options, (err, resp) => {
+      userInfoDone = true
+      userInfo = resp
+      reqErr = reqErr || err
+      if (statsDone) {
+        finishReq()
+      }
+    })
+  }
+
+  stats ({ directory, query, token }, done) {
+    this.client
+      .post('files/list_folder')
+      .options({version: '2'})
+      .where(query)
+      .auth(token)
+      .json({
+        path: `${directory || ''}`,
+        include_media_info: true
+      })
+      .request(done)
+  }
+
+  download ({ id, token }, onData) {
+    return this.client
+      .post('https://content.dropboxapi.com/2/files/download')
+      .options({
+        version: '2',
+        headers: {
+          'Dropbox-API-Arg': JSON.stringify({path: `${id}`})
+        }
+      })
+      .auth(token)
+      .request()
+      .on('data', onData)
+      .on('end', () => onData(null))
+      .on('error', (err) => {
+        logger.error(err, 'provider.dropbox.download.error')
+      })
+  }
+
+  thumbnail ({id, token}, done) {
+    return this.client
+      .post('https://content.dropboxapi.com/2/files/get_thumbnail')
+      .options({
+        version: '2',
+        headers: {
+          'Dropbox-API-Arg': JSON.stringify({path: `${id}`})
+        }
+      })
+      .auth(token)
+      .request()
+      .on('response', done)
+      .on('error', (err) => {
+        logger.error(err, 'provider.dropbox.thumbnail.error')
+      })
+  }
+
+  size ({id, token}, done) {
+    return this.client
+      .post('files/get_metadata')
+      .options({ version: '2' })
+      .auth(token)
+      .json({
+        path: id,
+        include_media_info: true
+      })
+      .request((err, resp, body) => {
+        if (err) {
+          logger.error(err, 'provider.dropbox.size.error')
+          return done(null)
+        }
+
+        done(body.size)
+      })
+  }
+}
+
+module.exports = DropBox

+ 177 - 0
packages/@uppy/companion/src/server/provider/index.js

@@ -0,0 +1,177 @@
+/**
+ * @module provider
+ */
+// @ts-ignore
+const config = require('@purest/providers')
+const dropbox = require('./dropbox')
+const drive = require('./drive')
+const instagram = require('./instagram')
+const { getURLBuilder } = require('../helpers/utils')
+const logger = require('../logger')
+
+/**
+ * Provider interface defines the specifications of any provider implementation
+ *
+ * @interface
+ */
+class Provider {
+  /**
+   *
+   * @param {object} options
+   */
+  constructor (options) {
+    return this
+  }
+  /**
+   *
+   * @param {object} options
+   * @param {function} cb
+   */
+  list (options, cb) {}
+
+  /**
+   *
+   * @param {object} options
+   * @param {function} cb
+   */
+  download (options, cb) {}
+
+  /**
+   *
+   * @param {object} options
+   * @param {function} cb
+   */
+  thumbnail (options, cb) {}
+
+  /**
+   *
+   * @param {object} options
+   * @param {function} cb
+   */
+  size (options, cb) {}
+
+  /**
+   * @returns {string}
+   */
+  static get authProvider () {
+    return ''
+  }
+}
+
+module.exports.ProviderInterface = Provider
+
+/**
+ * adds the desired provider module to the request object,
+ * based on the providerName parameter specified
+ *
+ * @param {Object.<string, typeof Provider>} providers
+ */
+module.exports.getProviderMiddleware = (providers) => {
+  /**
+   *
+   * @param {object} req
+   * @param {object} res
+   * @param {function} next
+   * @param {string} providerName
+   */
+  const middleware = (req, res, next, providerName) => {
+    if (providers[providerName] && validOptions(req.uppy.options)) {
+      req.uppy.provider = new providers[providerName]({ providerName, config })
+    } else {
+      logger.warn('invalid provider options detected. Provider will not be loaded', 'provider.middleware.invalid')
+    }
+    next()
+  }
+
+  return middleware
+}
+
+/**
+ * @return {Object.<string, typeof Provider>}
+ */
+module.exports.getDefaultProviders = () => {
+  return { dropbox, drive, instagram }
+}
+
+/**
+ *
+ * @typedef {{module: typeof Provider, config: object}} CustomProvider
+ *
+ * @param {Object.<string, CustomProvider>} customProviders
+ * @param {Object.<string, typeof Provider>} providers
+ * @param {object} grantConfig
+ */
+module.exports.addCustomProviders = (customProviders, providers, grantConfig) => {
+  Object.keys(customProviders).forEach((providerName) => {
+    providers[providerName] = customProviders[providerName].module
+    grantConfig[providerName] = customProviders[providerName].config
+  })
+}
+
+/**
+ *
+ * @param {{server: object, providerOptions: object}} options
+ * @param {object} grantConfig
+ */
+module.exports.addProviderOptions = (options, grantConfig) => {
+  const { server, providerOptions } = options
+  if (!validOptions({ server })) {
+    logger.warn('invalid provider options detected. Providers will not be loaded', 'provider.options.invalid')
+    return
+  }
+
+  grantConfig.server = {
+    host: server.host,
+    protocol: server.protocol,
+    path: server.path
+  }
+
+  const { oauthDomain } = server
+  const keys = Object.keys(providerOptions).filter((key) => key !== 'server')
+  keys.forEach((authProvider) => {
+    if (grantConfig[authProvider]) {
+      // explicitly add providerOptions so users don't override other providerOptions.
+      grantConfig[authProvider].key = providerOptions[authProvider].key
+      grantConfig[authProvider].secret = providerOptions[authProvider].secret
+
+      // override grant.js redirect uri with uppy's custom redirect url
+      if (oauthDomain) {
+        const providerName = authToProviderName(authProvider)
+        const redirectPath = `/${providerName}/redirect`
+        const isExternal = !!server.implicitPath
+        const fullRedirectPath = getURLBuilder(options)(redirectPath, isExternal, true)
+        grantConfig[authProvider].redirect_uri = `${server.protocol}://${oauthDomain}${fullRedirectPath}`
+      }
+
+      if (server.implicitPath) {
+        // no url builder is used for this because grant internally adds the path
+        grantConfig[authProvider].callback = `${server.implicitPath}${grantConfig[authProvider].callback}`
+      }
+    } else if (authProvider !== 's3') { // TODO: there should be a cleaner way to do this.
+      logger.warn(`skipping one found unsupported provider "${authProvider}".`, 'provider.options.skip')
+    }
+  })
+}
+
+/**
+ *
+ * @param {string} authProvider
+ */
+const authToProviderName = (authProvider) => {
+  const providers = exports.getDefaultProviders()
+  const providerNames = Object.keys(providers)
+  for (const name of providerNames) {
+    const provider = providers[name]
+    if (provider.authProvider === authProvider) {
+      return name
+    }
+  }
+}
+
+/**
+ *
+ * @param {{server: object}} options
+ */
+const validOptions = (options) => {
+  return options.server.host && options.server.protocol
+}

+ 91 - 0
packages/@uppy/companion/src/server/provider/instagram.js

@@ -0,0 +1,91 @@
+const request = require('request')
+const purest = require('purest')({ request })
+const utils = require('../helpers/utils')
+const logger = require('../logger')
+
+class Instagram {
+  constructor (options) {
+    this.authProvider = options.provider = Instagram.authProvider
+    this.client = purest(options)
+  }
+
+  static get authProvider () {
+    return 'instagram'
+  }
+
+  list ({ directory = 'recent', token, query = {} }, done) {
+    const qs = query.max_id ? {max_id: query.max_id} : {}
+    this.client
+      .select(`users/self/media/${directory}`)
+      .qs(qs)
+      .auth(token)
+      .request(done)
+  }
+
+  _getMediaUrl (body, carouselId) {
+    let mediaObj
+    let type
+
+    if (body.data.type === 'carousel') {
+      carouselId = carouselId ? parseInt(carouselId) : 0
+      mediaObj = body.data.carousel_media[carouselId]
+      type = mediaObj.type
+    } else {
+      mediaObj = body.data
+      type = body.data.type
+    }
+
+    return mediaObj[`${type}s`].standard_resolution.url
+  }
+
+  download ({ id, token, query = {} }, onData) {
+    return this.client
+      .get(`media/${id}`)
+      .auth(token)
+      .request((err, resp, body) => {
+        if (err) return logger.error(err, 'provider.instagram.download.error')
+        request(this._getMediaUrl(body, query.carousel_id))
+          .on('data', onData)
+          .on('end', () => onData(null))
+          .on('error', (err) => {
+            logger.error(err, 'provider.instagram.download.url.error')
+          })
+      })
+  }
+
+  thumbnail ({id, token}, done) {
+    return this.client
+      .get(`media/${id}`)
+      .auth(token)
+      .request((err, resp, body) => {
+        if (err) return logger.error(err, 'provider.instagram.thumbnail.error')
+
+        request(body.data.images.thumbnail.url)
+          .on('response', done)
+          .on('error', (err) => {
+            logger.error(err, 'provider.instagram.thumbnail.error')
+          })
+      })
+  }
+
+  size ({id, token, query = {}}, done) {
+    return this.client
+      .get(`media/${id}`)
+      .auth(token)
+      .request((err, resp, body) => {
+        if (err) {
+          logger.error(err, 'provider.instagram.size.error')
+          return done()
+        }
+
+        utils.getURLMeta(this._getMediaUrl(body, query.carousel_id))
+          .then(({ size }) => done(size))
+          .catch((err) => {
+            logger.error(err, 'provider.instagram.size.error')
+            done()
+          })
+      })
+  }
+}
+
+module.exports = Instagram

+ 20 - 0
packages/@uppy/companion/src/server/redis.js

@@ -0,0 +1,20 @@
+const redis = require('redis')
+let redisClient
+
+/**
+ * A Singleton module that provides only on redis client through out
+ * the lifetime of the server
+ *
+ * @param {object=} opts node-redis client options
+ */
+module.exports.client = (opts) => {
+  if (!opts) {
+    return redisClient
+  }
+
+  if (!redisClient) {
+    redisClient = redis.createClient(opts)
+  }
+
+  return redisClient
+}

+ 197 - 0
packages/@uppy/companion/src/standalone/helper.js

@@ -0,0 +1,197 @@
+const fs = require('fs')
+const merge = require('lodash.merge')
+const stripIndent = require('common-tags/lib/stripIndent')
+const utils = require('../server/helpers/utils')
+const logger = require('../server/logger')
+const crypto = require('crypto')
+// @ts-ignore
+const { version } = require('../../package.json')
+
+/**
+ * Reads all companion configuration set via environment variables
+ * and via the config file path
+ *
+ * @returns {object}
+ */
+exports.getUppyOptions = () => {
+  return merge({}, getConfigFromEnv(), getConfigFromFile())
+}
+
+/**
+ * Loads the config from environment variables
+ *
+ * @returns {object}
+ */
+const getConfigFromEnv = () => {
+  const uploadUrls = process.env.UPPYSERVER_UPLOAD_URLS
+  const domains = process.env.UPPYSERVER_DOMAINS || process.env.UPPYSERVER_DOMAIN || null
+  const validHosts = domains ? domains.split(',') : []
+
+  return {
+    // TODO: Rename providerOptions to providers.
+    providerOptions: {
+      google: {
+        key: process.env.UPPYSERVER_GOOGLE_KEY,
+        secret: process.env.UPPYSERVER_GOOGLE_SECRET
+      },
+      dropbox: {
+        key: process.env.UPPYSERVER_DROPBOX_KEY,
+        secret: process.env.UPPYSERVER_DROPBOX_SECRET
+      },
+      instagram: {
+        key: process.env.UPPYSERVER_INSTAGRAM_KEY,
+        secret: process.env.UPPYSERVER_INSTAGRAM_SECRET
+      },
+      s3: {
+        key: process.env.UPPYSERVER_AWS_KEY,
+        secret: process.env.UPPYSERVER_AWS_SECRET,
+        bucket: process.env.UPPYSERVER_AWS_BUCKET,
+        endpoint: process.env.UPPYSERVER_AWS_ENDPOINT,
+        region: process.env.UPPYSERVER_AWS_REGION
+      }
+    },
+    server: {
+      host: process.env.UPPYSERVER_DOMAIN,
+      protocol: process.env.UPPYSERVER_PROTOCOL,
+      path: process.env.UPPYSERVER_PATH,
+      implicitPath: process.env.UPPYSERVER_IMPLICIT_PATH,
+      oauthDomain: process.env.UPPYSERVER_OAUTH_DOMAIN,
+      validHosts: validHosts
+    },
+    filePath: process.env.UPPYSERVER_DATADIR,
+    redisUrl: process.env.UPPYSERVER_REDIS_URL,
+    sendSelfEndpoint: process.env.UPPYSERVER_SELF_ENDPOINT,
+    uploadUrls: uploadUrls ? uploadUrls.split(',') : null,
+    secret: process.env.UPPYSERVER_SECRET || generateSecret(),
+    debug: process.env.NODE_ENV !== 'production',
+    // TODO: this is a temporary hack to support distributed systems.
+    // it is not documented, because it should be changed soon.
+    cookieDomain: process.env.UPPYSERVER_COOKIE_DOMAIN,
+    multipleInstances: true
+  }
+}
+
+/**
+ * Auto-generates server secret
+ *
+ * @returns {string}
+ */
+const generateSecret = () => {
+  logger.warn('auto-generating server secret because none was specified', 'startup.secret')
+  return crypto.randomBytes(64).toString('hex')
+}
+
+/**
+ * Loads the config from a file and returns it as an object
+ *
+ * @returns {object}
+ */
+const getConfigFromFile = () => {
+  const path = getConfigPath()
+  if (!path) return {}
+
+  const rawdata = fs.readFileSync(getConfigPath())
+  // TODO validate the json object fields to match the uppy config schema
+  // @ts-ignore
+  return JSON.parse(rawdata)
+}
+
+/**
+ * Returns the config path specified via cli arguments
+ *
+ * @returns {string}
+ */
+const getConfigPath = () => {
+  let configPath
+
+  for (let i = process.argv.length - 1; i >= 0; i--) {
+    const isConfigFlag = process.argv[i] === '-c' || process.argv[i] === '--config'
+    const flagHasValue = i + 1 <= process.argv.length
+    if (isConfigFlag && flagHasValue) {
+      configPath = process.argv[i + 1]
+      break
+    }
+  }
+
+  return configPath
+}
+
+/**
+ * validates that the mandatory companion options are set.
+ * If it is invalid, it will console an error of unset options and exits the process.
+ * If it is valid, nothing happens.
+ *
+ * @param {object} config
+ */
+exports.validateConfig = (config) => {
+  const mandatoryOptions = ['secret', 'filePath', 'server.host']
+  /** @type {string[]} */
+  const unspecified = []
+
+  mandatoryOptions.forEach((i) => {
+    const value = i.split('.').reduce((prev, curr) => prev[curr], config)
+
+    if (!value) unspecified.push(`"${i}"`)
+  })
+
+  // vaidate that all required config is specified
+  if (unspecified.length) {
+    console.error('\x1b[31m', 'Please specify the following options',
+      'to run companion as Standalone:\n', unspecified.join(',\n'), '\x1b[0m')
+    process.exit(1)
+  }
+
+  // validate that specified filePath is writeable/readable.
+  // TODO: consider moving this into the uppy module itself.
+  try {
+    // @ts-ignore
+    fs.accessSync(`${config.filePath}`, fs.R_OK | fs.W_OK)
+  } catch (err) {
+    console.error('\x1b[31m', `No access to "${config.filePath}".`,
+      'Please ensure the directory exists and with read/write permissions.', '\x1b[0m')
+    process.exit(1)
+  }
+}
+
+/**
+ *
+ * @param {string} url
+ */
+exports.hasProtocol = (url) => {
+  return url.startsWith('http://') || url.startsWith('https://')
+}
+
+exports.buildHelpfulStartupMessage = (uppyOptions) => {
+  const buildURL = utils.getURLBuilder(uppyOptions)
+  const callbackURLs = []
+  Object.keys(uppyOptions.providerOptions).forEach((providerName) => {
+    // s3 does not need redirect_uris
+    if (providerName === 's3') {
+      return
+    }
+
+    if (providerName === 'google') {
+      providerName = 'drive'
+    }
+
+    callbackURLs.push(buildURL(`/${providerName}/callback`, true))
+  })
+
+  return stripIndent`
+    Welcome to Uppy Server v${version}
+    ===================================
+
+    Congratulations on setting up Uppy Server! Thanks for joining our cause, you have taken
+    the first step towards the future of file uploading! We
+    hope you are as excited about this as we are!
+
+    While you did an awesome job on getting Uppy Server running, this is just the welcome
+    message, so let's talk about the places that really matter:
+
+    - Be sure to add ${callbackURLs.join(', ')} as your Oauth redirect uris on their corresponding developer interfaces.
+    - The URL ${buildURL('/metrics', true)} is available for  statistics to keep Uppy Server running smoothly
+    - https://github.com/transloadit/uppy/issues - report your bugs here
+
+    So quit lollygagging, start uploading and experience the future!
+  `
+}

+ 143 - 0
packages/@uppy/companion/src/standalone/index.js

@@ -0,0 +1,143 @@
+const express = require('express')
+const qs = require('querystring')
+const uppy = require('../uppy')
+const helmet = require('helmet')
+const morgan = require('morgan')
+const bodyParser = require('body-parser')
+// @ts-ignore
+const promBundle = require('express-prom-bundle')
+const session = require('express-session')
+const helper = require('./helper')
+// @ts-ignore
+const { version } = require('../../package.json')
+
+const app = express()
+
+// for server metrics tracking.
+const metricsMiddleware = promBundle({includeMethod: true})
+const promClient = metricsMiddleware.promClient
+const collectDefaultMetrics = promClient.collectDefaultMetrics
+const promInterval = collectDefaultMetrics({ register: promClient.register, timeout: 5000 })
+
+// Add version as a prometheus gauge
+const versionGauge = new promClient.Gauge({ name: 'uppyserver_version', help: 'npm version as an integer' })
+const numberVersion = version.replace(/\D/g, '') * 1
+versionGauge.set(numberVersion)
+
+if (app.get('env') !== 'test') {
+  clearInterval(promInterval)
+}
+
+// log server requests.
+app.use(morgan('combined'))
+morgan.token('url', (req, res) => {
+  // don't log access_tokens in urls
+  if (req.query && req.query.access_token) {
+    const query = Object.assign({}, req.query)
+    // replace logged access token with xxxx character
+    query.access_token = 'x'.repeat(req.query.access_token.length)
+    return `${req.path}?${qs.stringify(query)}`
+  }
+  return req.originalUrl || req.url
+})
+
+// make app metrics available at '/metrics'.
+app.use(metricsMiddleware)
+
+app.use(bodyParser.json())
+app.use(bodyParser.urlencoded({ extended: false }))
+
+// Use helmet to secure Express headers
+app.use(helmet.frameguard())
+app.use(helmet.xssFilter())
+app.use(helmet.noSniff())
+app.use(helmet.ieNoOpen())
+app.disable('x-powered-by')
+
+const uppyOptions = helper.getUppyOptions()
+const sessionOptions = {
+  secret: uppyOptions.secret,
+  resave: true,
+  saveUninitialized: true
+}
+
+if (process.env.UPPYSERVER_REDIS_URL) {
+  const RedisStore = require('connect-redis')(session)
+  sessionOptions.store = new RedisStore({
+    url: process.env.UPPYSERVER_REDIS_URL
+  })
+}
+
+if (process.env.UPPYSERVER_COOKIE_DOMAIN) {
+  sessionOptions.cookie = {
+    domain: process.env.UPPYSERVER_COOKIE_DOMAIN,
+    maxAge: 24 * 60 * 60 * 1000 // 1 day
+  }
+}
+
+app.use(session(sessionOptions))
+
+app.use((req, res, next) => {
+  const protocol = process.env.UPPYSERVER_PROTOCOL || 'http'
+
+  // if endpoint urls are specified, then we only allow those endpoints
+  // otherwise, we allow any client url to access companion.
+  // here we also enforce that only the protocol allowed by companion is used.
+  if (process.env.UPPY_ENDPOINTS) {
+    const whitelist = process.env.UPPY_ENDPOINTS
+      .split(',')
+      .map((url) => helper.hasProtocol(url) ? url : `${protocol}://${url}`)
+
+    // @ts-ignore
+    if (req.headers.origin && whitelist.indexOf(req.headers.origin) > -1) {
+      res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
+    }
+  } else {
+    res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*')
+  }
+
+  res.setHeader(
+    'Access-Control-Allow-Methods',
+    'GET, POST, OPTIONS, PUT, PATCH, DELETE'
+  )
+  res.setHeader(
+    'Access-Control-Allow-Headers',
+    'Authorization, Origin, Content-Type, Accept'
+  )
+  res.setHeader('Access-Control-Allow-Credentials', 'true')
+  next()
+})
+
+// Routes
+app.get('/', (req, res) => {
+  res.setHeader('Content-Type', 'text/plain')
+  res.send(helper.buildHelpfulStartupMessage(uppyOptions))
+})
+
+// initialize uppy
+helper.validateConfig(uppyOptions)
+if (process.env.UPPYSERVER_PATH) {
+  app.use(process.env.UPPYSERVER_PATH, uppy.app(uppyOptions))
+} else {
+  app.use(uppy.app(uppyOptions))
+}
+
+app.use((req, res, next) => {
+  return res.status(404).json({ message: 'Not Found' })
+})
+
+if (app.get('env') === 'production') {
+  // @ts-ignore
+  app.use((err, req, res, next) => {
+    console.error('\x1b[31m', err, '\x1b[0m')
+    res.status(err.status || 500).json({ message: 'Something went wrong' })
+  })
+} else {
+  // @ts-ignore
+  app.use((err, req, res, next) => {
+    console.error('\x1b[31m', err, '\x1b[0m')
+    res.status(err.status || 500).json({ message: err.message, error: err })
+  })
+}
+
+module.exports = { app, uppyOptions }

+ 11 - 0
packages/@uppy/companion/src/standalone/start-server.js

@@ -0,0 +1,11 @@
+#!/usr/bin/env node
+const uppy = require('../uppy')
+// @ts-ignore
+const { version } = require('../../package.json')
+const { app } = require('.')
+const PORT = process.env.UPPYSERVER_PORT || 3020
+
+uppy.socket(app.listen(PORT))
+
+console.log(`Welcome to Uppy Server! v${version}`)
+console.log(`Listening on http://0.0.0.0:${PORT}`)

+ 251 - 0
packages/@uppy/companion/src/uppy.js

@@ -0,0 +1,251 @@
+const express = require('express')
+// @ts-ignore
+const Grant = require('grant-express')
+const grantConfig = require('./config/grant')()
+const providerManager = require('./server/provider')
+const controllers = require('./server/controllers')
+const s3 = require('./server/controllers/s3')
+const url = require('./server/controllers/url')
+const SocketServer = require('ws').Server
+const emitter = require('./server/emitter')
+const merge = require('lodash.merge')
+const redis = require('./server/redis')
+const cookieParser = require('cookie-parser')
+const { jsonStringify, getURLBuilder } = require('./server/helpers/utils')
+const jobs = require('./server/jobs')
+const interceptor = require('express-interceptor')
+const logger = require('./server/logger')
+const { STORAGE_PREFIX } = require('./server/Uploader')
+const middlewares = require('./server/middlewares')
+
+const providers = providerManager.getDefaultProviders()
+const defaultOptions = {
+  server: {
+    protocol: 'http',
+    path: ''
+  },
+  providerOptions: {
+    s3: {
+      acl: 'public-read',
+      endpoint: 'https://{service}.{region}.amazonaws.com',
+      conditions: [],
+      getKey: (req, filename) => filename
+    }
+  },
+  debug: true
+}
+
+/**
+ * Entry point into initializing the companion app.
+ *
+ * @param {object} options
+ */
+module.exports.app = (options = {}) => {
+  options = merge({}, defaultOptions, options)
+  providerManager.addProviderOptions(options, grantConfig)
+
+  const customProviders = options.customProviders
+  if (customProviders) {
+    providerManager.addCustomProviders(customProviders, providers, grantConfig)
+  }
+
+  // create singleton redis client
+  if (options.redisUrl) {
+    redis.client({ url: options.redisUrl })
+  }
+  emitter(options.multipleInstances && options.redisUrl)
+
+  const app = express()
+  app.use(cookieParser()) // server tokens are added to cookies
+
+  app.use(interceptGrantErrorResponse)
+  app.use(new Grant(grantConfig))
+  app.use((req, res, next) => {
+    res.header(
+      'Access-Control-Allow-Headers',
+      [res.get('Access-Control-Allow-Headers'), 'uppy-auth-token'].join(', ')
+    )
+    next()
+  })
+  if (options.sendSelfEndpoint) {
+    app.use('*', (req, res, next) => {
+      const { protocol } = options.server
+      res.header('i-am', `${protocol}://${options.sendSelfEndpoint}`)
+      // add it to the exposed custom headers.
+      res.header('Access-Control-Expose-Headers', [res.get('Access-Control-Expose-Headers'), 'i-am'].join(', '))
+      next()
+    })
+  }
+
+  // add uppy options to the request object so it can be accessed by subsequent handlers.
+  app.use('*', getOptionsMiddleware(options))
+  app.use('/s3', s3(options.providerOptions.s3))
+  app.use('/url', url())
+
+  app.get('/:providerName/callback', middlewares.hasSessionAndProvider, controllers.callback)
+  app.get('/:providerName/connect', middlewares.hasSessionAndProvider, controllers.connect)
+  app.get('/:providerName/redirect', middlewares.hasSessionAndProvider, controllers.redirect)
+  app.get('/:providerName/logout', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.logout)
+  app.get('/:providerName/authorized', middlewares.hasSessionAndProvider, middlewares.gentleVerifyToken, controllers.authorized)
+  app.get('/:providerName/list/:id?', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.list)
+  app.post('/:providerName/get/:id', middlewares.hasSessionAndProvider, middlewares.verifyToken, controllers.get)
+  app.get('/:providerName/thumbnail/:id', middlewares.hasSessionAndProvider, middlewares.cookieAuthToken, middlewares.verifyToken, controllers.thumbnail)
+
+  app.param('providerName', providerManager.getProviderMiddleware(providers))
+
+  if (app.get('env') !== 'test') {
+    jobs.startCleanUpJob(options.filePath)
+  }
+
+  return app
+}
+
+/**
+ * the socket is used to send progress events during an upload
+ *
+ * @param {object} server
+ */
+module.exports.socket = (server) => {
+  const wss = new SocketServer({ server })
+  const redisClient = redis.client()
+
+  // A new connection is usually created when an upload begins,
+  // or when connection fails while an upload is on-going and,
+  // client attempts to reconnect.
+  wss.on('connection', (ws) => {
+    // @ts-ignore
+    const fullPath = ws.upgradeReq.url
+    // the token identifies which ongoing upload's progress, the socket
+    // connection wishes to listen to.
+    const token = fullPath.replace(/\/api\//, '')
+    logger.info(`connection received from ${token}`, 'socket.connect')
+
+    /**
+     *
+     * @param {{action: string, payload: object}} data
+     */
+    function sendProgress (data) {
+      ws.send(jsonStringify(data), (err) => {
+        if (err) logger.error(err, 'socket.progress.error')
+      })
+    }
+
+    // if the redisClient is available, then we attempt to check the storage
+    // if we have any already stored progress data on the upload.
+    if (redisClient) {
+      redisClient.get(`${STORAGE_PREFIX}:${token}`, (err, data) => {
+        if (err) logger.error(err, 'socket.redis.error')
+        if (data) {
+          const dataObj = JSON.parse(data.toString())
+          if (dataObj.action) sendProgress(dataObj)
+        }
+      })
+    }
+
+    emitter().emit(`connection:${token}`)
+    emitter().on(token, sendProgress)
+
+    ws.on('message', (jsonData) => {
+      const data = JSON.parse(jsonData.toString())
+      // whitelist triggered actions
+      if (data.action === 'pause' || data.action === 'resume') {
+        emitter().emit(`${data.action}:${token}`)
+      }
+    })
+
+    ws.on('close', () => {
+      emitter().removeListener(token, sendProgress)
+    })
+  })
+}
+
+// intercepts grantJS' default response error when something goes
+// wrong during oauth process.
+const interceptGrantErrorResponse = interceptor((req, res) => {
+  return {
+    isInterceptable: () => {
+      // match grant.js' callback url
+      return /^\/connect\/\w+\/callback/.test(req.path)
+    },
+    intercept: (body, send) => {
+      const unwantedBody = 'error=Grant%3A%20missing%20session%20or%20misconfigured%20provider'
+      if (body === unwantedBody) {
+        logger.error(`grant.js responded with error: ${body}`, 'grant.oauth.error')
+        send([
+          'Companion was unable to complete the OAuth process :(',
+          '(Hint, try clearing your cookies and try again)'
+        ].join('\n'))
+      } else {
+        send(body)
+      }
+    }
+  }
+})
+
+/**
+ * returns a logger function, that would log a message only if
+ * the debug option is set to true
+ *
+ * @param {{debug: boolean}} options
+ * @returns {function}
+ */
+const getDebugLogger = (options) => {
+  // TODO: deprecate this.
+  // TODO: add line number and originating file
+  /**
+   *
+   * @param {string} message
+   */
+  const conditonalLogger = (message) => {
+    if (options.debug) {
+      logger.debug(message, 'debugLog')
+    }
+  }
+
+  return conditonalLogger
+}
+
+/**
+ *
+ * @param {object} options
+ */
+const getOptionsMiddleware = (options) => {
+  let s3Client = null
+  if (options.providerOptions.s3) {
+    const S3 = require('aws-sdk/clients/s3')
+    const AWS = require('aws-sdk')
+    const config = options.providerOptions.s3
+    // Use credentials to allow assumed roles to pass STS sessions in.
+    // If the user doesn't specify key and secret, the default credentials (process-env)
+    // will be used by S3 in calls below.
+    let credentials
+    if (config.key && config.secret) {
+      credentials = new AWS.Credentials(config.key, config.secret, config.sessionToken)
+    }
+    s3Client = new S3({
+      region: config.region,
+      endpoint: config.endpoint,
+      credentials,
+      signatureVersion: 'v4'
+    })
+  }
+
+  /**
+   *
+   * @param {object} req
+   * @param {object} res
+   * @param {function} next
+   */
+  const middleware = (req, res, next) => {
+    req.uppy = {
+      options,
+      s3Client,
+      authToken: req.header('uppy-auth-token'),
+      debugLog: getDebugLogger(options),
+      buildURL: getURLBuilder(options)
+    }
+    next()
+  }
+
+  return middleware
+}

+ 43 - 0
packages/@uppy/companion/test/__mocks__/purest.js

@@ -0,0 +1,43 @@
+const fs = require('fs')
+
+class MockPurest {
+  constructor (opts) {
+    const methodsToMock = ['query', 'select', 'where', 'auth', 'get', 'put', 'post', 'options', 'json']
+    methodsToMock.forEach((item) => {
+      this[item] = () => this
+    })
+    this.opts = opts
+  }
+
+  request (done) {
+    if (typeof done === 'function') {
+      const responses = {
+        dropbox: {
+          hash: '0a9f95a989dd4b1851f0103c31e304ce',
+          entries: [{ rev: 'f24234cd4' }]
+        },
+        drive: {
+          kind: 'drive#fileList',
+          etag: '"bcIyJ9A3gXa8oTYmz6nzAjQd-lY/eQc3WbZHkXpcItNyGKDuKXM_bNY"',
+          items: [{ id: '0B2x-PmqQHSKdT013TE1VVjZ3TWs' }],
+          fileSize: 300
+        }
+      }
+      const body = responses[this.opts.providerName]
+      done(null, { body }, body)
+    }
+
+    return this
+  }
+
+  on (evt, cb) {
+    if (evt === 'response') {
+      cb(fs.createReadStream('./README.md'))
+    }
+    return this
+  }
+}
+
+module.exports = () => {
+  return (options) => new MockPurest(options)
+}

+ 7 - 0
packages/@uppy/companion/test/__mocks__/tus-js-client.js

@@ -0,0 +1,7 @@
+class Upload {
+  constructor (file, options) {
+    this.url = 'https://tus.endpoint/files/foo-bar'
+  }
+}
+
+module.exports = { Upload }

+ 149 - 0
packages/@uppy/companion/test/__tests__/companion.js

@@ -0,0 +1,149 @@
+/* global jest:false, test:false, expect:false, describe:false */
+
+jest.mock('tus-js-client')
+jest.mock('purest')
+jest.mock('../../src/server/helpers/oauth-state', () => {
+  return {
+    generateState: () => 'some-cool-nice-encrytpion',
+    addToState: () => 'some-cool-nice-encrytpion',
+    getFromState: (state) => {
+      return state === 'state-with-invalid-instance-url' ? 'http://localhost:3452' : 'http://localhost:3020'
+    }
+  }
+})
+
+const request = require('supertest')
+const tokenService = require('../../src/server/helpers/jwt')
+const { authServer, noAuthServer } = require('../mockserver')
+const authData = {
+  dropbox: 'token value',
+  drive: 'token value'
+}
+const token = tokenService.generateToken(authData, process.env.UPPYSERVER_SECRET)
+const OAUTH_STATE = 'some-cool-nice-encrytpion'
+
+describe('set i-am header', () => {
+  test('set i-am header in response', () => {
+    return request(authServer)
+      .get('/dropbox/list/')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .then((res) => expect(res.header['i-am']).toBe('http://localhost:3020'))
+  })
+})
+
+describe('list provider files', () => {
+  test('list files for dropbox', () => {
+    return request(authServer)
+      .get('/dropbox/list/')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .then((res) => expect(res.body.hash).toBe('0a9f95a989dd4b1851f0103c31e304ce'))
+  })
+
+  test('list files for google drive', () => {
+    return request(authServer)
+      .get('/drive/list/')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .then((res) => expect(res.body.etag).toBe('"bcIyJ9A3gXa8oTYmz6nzAjQd-lY/eQc3WbZHkXpcItNyGKDuKXM_bNY"'))
+  })
+})
+
+describe('download provdier file', () => {
+  test('specified file gets downloaded from provider', () => {
+    return request(authServer)
+      .post('/drive/get/README.md')
+      .set('uppy-auth-token', token)
+      .set('Content-Type', 'application/json')
+      .send({
+        endpoint: 'http://master.tus.com/files',
+        protocol: 'tus'
+      })
+      .expect(200)
+      .then((res) => expect(res.body.token).toBeTruthy())
+  })
+})
+
+describe('test authentication', () => {
+  test('authentication callback redirects to specified url', () => {
+    return request(authServer)
+      .get('/drive/callback')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .expect((res) => {
+        const authToken = res.header['set-cookie'][0].split(';')[0].split('uppyAuthToken=')[1]
+        // see mock ../../src/server/helpers/oauth-state above for http://localhost:3020
+        const body = `
+        <!DOCTYPE html>
+        <html>
+        <head>
+            <meta charset="utf-8" />
+            <script>
+              window.opener.postMessage({token: "${authToken}"}, "http://localhost:3020")
+              window.close()
+            </script>
+        </head>
+        <body></body>
+        </html>`
+        expect(res.text).toBe(body)
+      })
+  })
+
+  test('check for authenticated provider', () => {
+    request(authServer)
+      .get('/drive/authorized/')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .then((res) => expect(res.body.authenticated).toBe(true))
+
+    request(noAuthServer)
+      .get('/drive/authorized/')
+      .expect(200)
+      .then((res) => expect(res.body.authenticated).toBe(false))
+  })
+
+  test('logout provider', () => {
+    return request(authServer)
+      .get('/drive/logout/')
+      .set('uppy-auth-token', token)
+      .expect(200)
+      .then((res) => expect(res.body.ok).toBe(true))
+  })
+})
+
+describe('connect to provider', () => {
+  test('connect to dropbox via grant.js endpoint', () => {
+    return request(authServer)
+      .get('/dropbox/connect?foo=bar')
+      .set('uppy-auth-token', token)
+      .expect(302)
+      .expect('Location', `http://localhost:3020/connect/dropbox?state=${OAUTH_STATE}`)
+  })
+
+  test('connect to drive via grant.js endpoint', () => {
+    return request(authServer)
+      .get('/drive/connect?foo=bar')
+      .set('uppy-auth-token', token)
+      .expect(302)
+      .expect('Location', `http://localhost:3020/connect/google?state=${OAUTH_STATE}`)
+  })
+})
+
+describe('handle oauth redirect', () => {
+  test('redirect to a valid uppy instance', () => {
+    return request(authServer)
+      .get(`/dropbox/redirect?state=${OAUTH_STATE}`)
+      .set('uppy-auth-token', token)
+      .expect(302)
+      .expect('Location', `http://localhost:3020/connect/dropbox/callback?state=${OAUTH_STATE}`)
+  })
+
+  test('do not redirect to invalid uppy instances', () => {
+    const state = 'state-with-invalid-instance-url' // see mock ../../src/server/helpers/oauth-state above
+    return request(authServer)
+      .get(`/dropbox/redirect?state=${state}`)
+      .set('uppy-auth-token', token)
+      .expect(400)
+  })
+})

+ 58 - 0
packages/@uppy/companion/test/__tests__/header-blacklist.js

@@ -0,0 +1,58 @@
+/* global test:false, expect:false, describe:false, */
+
+const headerSanitize = require('../../src/server/header-blacklist')
+
+describe('Header black-list testing', () => {
+  test('All headers invalid by name', () => {
+    const headers = headerSanitize({
+      origin: 'http://www.google.com',
+      'Accept-Charset': '...',
+      'content-Length': 1234
+    })
+
+    expect(headers).toEqual({})
+  })
+
+  test('All headers invalid by regex', () => {
+    const headers = headerSanitize({
+      'Proxy-header-fake': 'proxy-header-fake',
+      'proxy-header-fake-lower': 'proxy-header-fake-lower',
+      'proxy-': 'proxy-header-fake-empty',
+      'Sec-': 'sec-header-empty',
+      'sec-': 'sec-lower-header-empty',
+      'Sec-header-fake': 'sec-header-fake',
+      'sec-header-fake': 'sec-header-fake'
+    })
+    expect(headers).toEqual({})
+  })
+
+  test('All headers invalid by name and regex', () => {
+    const headers = headerSanitize({
+      'Proxy-header-fake': 'proxy-header-fake',
+      'Sec-header-fake': 'sec-header-fake'
+    })
+    expect(headers).toEqual({})
+  })
+
+  test('Returning only allowed headers', () => {
+    const headers = headerSanitize({
+      Authorization: 'Basic Xxxxxx',
+      'Content-Type': 'application/json',
+      'Content-Length': 1234,
+      Expires: 'Wed, 21 Oct 2015 07:28:00 GMT',
+      Origin: 'http://www.google.com'
+    })
+    expect(Object.keys(headers)).toHaveLength(3)
+    expect(headers).toHaveProperty('Authorization')
+    expect(headers).toHaveProperty('Content-Type')
+    expect(headers).toHaveProperty('Expires')
+  })
+
+  test('Return empty object when headers is not an object', () => {
+    expect(headerSanitize({})).toEqual({})
+    expect(headerSanitize(null)).toEqual({})
+    expect(headerSanitize(undefined)).toEqual({})
+    expect(headerSanitize('Authorization: Basic 1234')).toEqual({})
+    expect(headerSanitize(['Authorization', 'Basic 1234'])).toEqual({})
+  })
+})

+ 67 - 0
packages/@uppy/companion/test/__tests__/provider-manager.js

@@ -0,0 +1,67 @@
+/* global jest:false, test:false, expect:false, describe:false, beforeEach:false */
+
+const providerManager = require('../../src/server/provider')
+let grantConfig
+let uppyOptions
+
+describe('Test Provider options', () => {
+  beforeEach(() => {
+    grantConfig = require('../../src/config/grant')()
+    uppyOptions = require('../../src/standalone/helper').getUppyOptions()
+  })
+
+  test('adds provider options', () => {
+    providerManager.addProviderOptions(uppyOptions, grantConfig)
+    expect(grantConfig.dropbox.key).toBe('dropbox_key')
+    expect(grantConfig.dropbox.secret).toBe('dropbox_secret')
+
+    expect(grantConfig.google.key).toBe('google_key')
+    expect(grantConfig.google.secret).toBe('google_secret')
+
+    expect(grantConfig.instagram.key).toBe('instagram_key')
+    expect(grantConfig.instagram.secret).toBe('instagram_secret')
+  })
+
+  test('does not add provider options if protocol and host are not set', () => {
+    delete uppyOptions.server.host
+    delete uppyOptions.server.protocol
+
+    providerManager.addProviderOptions(uppyOptions, grantConfig)
+    expect(grantConfig.dropbox.key).toBeUndefined()
+    expect(grantConfig.dropbox.secret).toBeUndefined()
+
+    expect(grantConfig.google.key).toBeUndefined()
+    expect(grantConfig.google.secret).toBeUndefined()
+
+    expect(grantConfig.instagram.key).toBeUndefined()
+    expect(grantConfig.instagram.secret).toBeUndefined()
+  })
+
+  test('sets a master redirect uri, if oauthDomain is set', () => {
+    uppyOptions.server.oauthDomain = 'domain.com'
+    providerManager.addProviderOptions(uppyOptions, grantConfig)
+
+    expect(grantConfig.dropbox.redirect_uri).toBe('http://domain.com/dropbox/redirect')
+    expect(grantConfig.google.redirect_uri).toBe('http://domain.com/drive/redirect')
+    expect(grantConfig.instagram.redirect_uri).toBe('http://domain.com/instagram/redirect')
+  })
+})
+
+describe('Test Custom Provider options', () => {
+  test('adds custom provider options', () => {
+    const providers = providerManager.getDefaultProviders()
+    providerManager.addCustomProviders({
+      foo: {
+        config: {
+          key: 'foo_key',
+          secret: 'foo_secret'
+        },
+        module: jest.mock()
+      }
+    }, providers, grantConfig)
+
+    expect(grantConfig.foo.key).toBe('foo_key')
+    expect(grantConfig.foo.secret).toBe('foo_secret')
+    expect(providers.foo).toBeTruthy()
+  })
+})

+ 16 - 0
packages/@uppy/companion/test/mockserver.js

@@ -0,0 +1,16 @@
+const { app } = require('../src/standalone')
+
+const express = require('express')
+const session = require('express-session')
+var authServer = express()
+
+authServer.use(session({ secret: 'grant', resave: true, saveUninitialized: true }))
+authServer.all('/drive/callback', (req, res, next) => {
+  req.session.grant = {
+    state: 'non-empty-value' }
+  next()
+})
+
+authServer.use(app)
+
+module.exports = { authServer, noAuthServer: app }

+ 0 - 0
packages/@uppy/companion/test/output/.keep


+ 15 - 0
packages/@uppy/companion/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "outDir": "./lib",
+    "module": "commonjs",
+    "target": "es6",
+    "noImplicitAny": false,
+    "sourceMap": false,
+    "allowJs": true,
+    "checkJs": true,
+    "noEmitOnError": true
+  },
+  "include": [
+    "src/**/*"
+  ]
+}

+ 1 - 1
packages/@uppy/dropbox/package.json

@@ -22,7 +22,7 @@
   },
   "dependencies": {
     "@uppy/provider-views": "0.26.0",
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "preact": "^8.2.9"
   },

+ 1 - 1
packages/@uppy/dropbox/src/index.js

@@ -1,5 +1,5 @@
 const { Plugin } = require('@uppy/core')
-const { Provider } = require('@uppy/server-utils')
+const { Provider } = require('@uppy/companion-client')
 const ProviderViews = require('@uppy/provider-views')
 const icons = require('./icons')
 const { h } = require('preact')

+ 1 - 1
packages/@uppy/dropbox/types/index.d.ts

@@ -1,5 +1,5 @@
 import { Plugin, PluginOptions, Uppy } from '@uppy/core';
-import { ProviderOptions } from '@uppy/server-utils';
+import { ProviderOptions } from '@uppy/companion-client';
 
 export interface DropboxOptions extends PluginOptions, ProviderOptions {
   serverUrl: string;

+ 1 - 1
packages/@uppy/google-drive/package.json

@@ -23,7 +23,7 @@
   },
   "dependencies": {
     "@uppy/provider-views": "0.26.0",
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "preact": "^8.2.9"
   },

+ 1 - 1
packages/@uppy/google-drive/src/index.js

@@ -1,5 +1,5 @@
 const { Plugin } = require('@uppy/core')
-const { Provider } = require('@uppy/server-utils')
+const { Provider } = require('@uppy/companion-client')
 const ProviderViews = require('@uppy/provider-views')
 const { h } = require('preact')
 

+ 1 - 1
packages/@uppy/google-drive/types/index.d.ts

@@ -1,5 +1,5 @@
 import { Plugin, PluginOptions, Uppy } from '@uppy/core';
-import { ProviderOptions } from '@uppy/server-utils';
+import { ProviderOptions } from '@uppy/companion-client';
 
 export interface GoogleDriveOptions extends PluginOptions, ProviderOptions {
   serverUrl: string;

+ 1 - 1
packages/@uppy/instagram/package.json

@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@uppy/provider-views": "0.26.0",
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "preact": "^8.2.9"
   },

+ 1 - 1
packages/@uppy/instagram/src/index.js

@@ -1,5 +1,5 @@
 const { Plugin } = require('@uppy/core')
-const { Provider } = require('@uppy/server-utils')
+const { Provider } = require('@uppy/companion-client')
 const ProviderViews = require('@uppy/provider-views')
 const { h } = require('preact')
 

+ 1 - 1
packages/@uppy/instagram/types/index.d.ts

@@ -1,5 +1,5 @@
 import { Plugin, PluginOptions, Uppy } from '@uppy/core';
-import { ProviderOptions } from '@uppy/server-utils';
+import { ProviderOptions } from '@uppy/companion-client';
 
 export interface InstagramOptions extends PluginOptions, ProviderOptions {
   serverUrl: string;

+ 1 - 1
packages/@uppy/transloadit/package.json

@@ -29,7 +29,7 @@
   },
   "dependencies": {
     "@uppy/provider-views": "0.26.0",
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/tus": "0.26.0",
     "@uppy/utils": "0.26.0",
     "namespace-emitter": "^2.0.1",

+ 1 - 1
packages/@uppy/tus/package.json

@@ -23,7 +23,7 @@
     "url": "git+https://github.com/transloadit/uppy.git"
   },
   "dependencies": {
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "tus-js-client": "^1.5.1"
   },

+ 1 - 1
packages/@uppy/tus/src/index.js

@@ -1,6 +1,6 @@
 const { Plugin } = require('@uppy/core')
 const tus = require('tus-js-client')
-const { Provider, RequestClient, Socket } = require('@uppy/server-utils')
+const { Provider, RequestClient, Socket } = require('@uppy/companion-client')
 const emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress')
 const getSocketHost = require('@uppy/utils/lib/getSocketHost')
 const settle = require('@uppy/utils/lib/settle')

+ 1 - 1
packages/@uppy/url/package.json

@@ -23,7 +23,7 @@
     "url": "git+https://github.com/transloadit/uppy.git"
   },
   "dependencies": {
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "preact": "^8.2.9"
   },

+ 1 - 1
packages/@uppy/url/src/index.js

@@ -1,7 +1,7 @@
 const { Plugin } = require('@uppy/core')
 const Translator = require('@uppy/utils/lib/Translator')
 const { h } = require('preact')
-const { RequestClient } = require('@uppy/server-utils')
+const { RequestClient } = require('@uppy/companion-client')
 const UrlUI = require('./UrlUI.js')
 const toArray = require('@uppy/utils/lib/toArray')
 

+ 1 - 1
packages/@uppy/xhr-upload/package.json

@@ -25,7 +25,7 @@
     "url": "git+https://github.com/transloadit/uppy.git"
   },
   "dependencies": {
-    "@uppy/server-utils": "0.26.0",
+    "@uppy/companion-client": "0.26.0",
     "@uppy/utils": "0.26.0",
     "cuid": "^2.1.1"
   },

+ 1 - 1
packages/@uppy/xhr-upload/src/index.js

@@ -1,7 +1,7 @@
 const { Plugin } = require('@uppy/core')
 const cuid = require('cuid')
 const Translator = require('@uppy/utils/lib/Translator')
-const { Provider, Socket } = require('@uppy/server-utils')
+const { Provider, Socket } = require('@uppy/companion-client')
 const emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress')
 const getSocketHost = require('@uppy/utils/lib/getSocketHost')
 const settle = require('@uppy/utils/lib/settle')

Vissa filer visades inte eftersom för många filer har ändrats