瀏覽代碼

move uppy-server to "service-dog"

Ifedapo Olarewaju 6 年之前
父節點
當前提交
55cc20a31c
共有 67 個文件被更改,包括 5021 次插入65 次删除
  1. 1 0
      .travis.yml
  2. 0 48
      bin/build-js.js
  3. 1 1
      bin/build-lib.js
  4. 11 0
      bin/service-dog
  5. 0 15
      bin/start-server.js
  6. 2 1
      package.json
  7. 7 0
      packages/@uppy/service-dog/.eslintrc
  8. 44 0
      packages/@uppy/service-dog/.gitignore
  9. 84 0
      packages/@uppy/service-dog/ARCHITECTURE.md
  10. 20 0
      packages/@uppy/service-dog/Dockerfile
  11. 18 0
      packages/@uppy/service-dog/Dockerfile.test
  12. 115 0
      packages/@uppy/service-dog/KUBERNETES.md
  13. 21 0
      packages/@uppy/service-dog/LICENSE
  14. 40 0
      packages/@uppy/service-dog/Makefile
  15. 99 0
      packages/@uppy/service-dog/README.md
  16. 16 0
      packages/@uppy/service-dog/docker-compose-dev.yml
  17. 8 0
      packages/@uppy/service-dog/docker-compose-test.yml
  18. 12 0
      packages/@uppy/service-dog/docker-compose.yml
  19. 17 0
      packages/@uppy/service-dog/env.test.sh
  20. 53 0
      packages/@uppy/service-dog/examples/serverless/index.js
  21. 12 0
      packages/@uppy/service-dog/examples/serverless/package.json
  22. 32 0
      packages/@uppy/service-dog/examples/serverless/serverless.yml
  23. 49 0
      packages/@uppy/service-dog/infra/kube/gcloud-deploy.sh
  24. 112 0
      packages/@uppy/service-dog/infra/kube/service-dog/service-dog-kube.yaml
  25. 115 0
      packages/@uppy/service-dog/infra/kube/service-dog/service-dog-redis.yaml
  26. 13 0
      packages/@uppy/service-dog/nodemon.json
  27. 1051 0
      packages/@uppy/service-dog/package-lock.json
  28. 107 0
      packages/@uppy/service-dog/package.json
  29. 405 0
      packages/@uppy/service-dog/src/server/Uploader.js
  30. 30 0
      packages/@uppy/service-dog/src/server/controllers/authorized.js
  31. 51 0
      packages/@uppy/service-dog/src/server/controllers/callback.js
  32. 25 0
      packages/@uppy/service-dog/src/server/controllers/connect.js
  33. 51 0
      packages/@uppy/service-dog/src/server/controllers/get.js
  34. 10 0
      packages/@uppy/service-dog/src/server/controllers/index.js
  35. 13 0
      packages/@uppy/service-dog/src/server/controllers/list.js
  36. 28 0
      packages/@uppy/service-dog/src/server/controllers/logout.js
  37. 26 0
      packages/@uppy/service-dog/src/server/controllers/oauth-redirect.js
  38. 282 0
      packages/@uppy/service-dog/src/server/controllers/s3.js
  39. 15 0
      packages/@uppy/service-dog/src/server/controllers/thumbnail.js
  40. 95 0
      packages/@uppy/service-dog/src/server/controllers/url.js
  41. 5 0
      packages/@uppy/service-dog/src/server/emitter/default-emitter.js
  42. 16 0
      packages/@uppy/service-dog/src/server/emitter/index.js
  43. 63 0
      packages/@uppy/service-dog/src/server/emitter/redis-emitter.js
  44. 64 0
      packages/@uppy/service-dog/src/server/header-blacklist.js
  45. 44 0
      packages/@uppy/service-dog/src/server/helpers/jwt.js
  46. 29 0
      packages/@uppy/service-dog/src/server/helpers/oauth-state.js
  47. 112 0
      packages/@uppy/service-dog/src/server/helpers/utils.js
  48. 55 0
      packages/@uppy/service-dog/src/server/jobs.js
  49. 49 0
      packages/@uppy/service-dog/src/server/logger.js
  50. 42 0
      packages/@uppy/service-dog/src/server/middlewares.js
  51. 73 0
      packages/@uppy/service-dog/src/server/provider/drive.js
  52. 133 0
      packages/@uppy/service-dog/src/server/provider/dropbox.js
  53. 177 0
      packages/@uppy/service-dog/src/server/provider/index.js
  54. 91 0
      packages/@uppy/service-dog/src/server/provider/instagram.js
  55. 20 0
      packages/@uppy/service-dog/src/server/redis.js
  56. 197 0
      packages/@uppy/service-dog/src/standalone/helper.js
  57. 143 0
      packages/@uppy/service-dog/src/standalone/index.js
  58. 11 0
      packages/@uppy/service-dog/src/standalone/start-server.js
  59. 251 0
      packages/@uppy/service-dog/src/uppy.js
  60. 43 0
      packages/@uppy/service-dog/test/__mocks__/purest.js
  61. 7 0
      packages/@uppy/service-dog/test/__mocks__/tus-js-client.js
  62. 58 0
      packages/@uppy/service-dog/test/__tests__/header-blacklist.js
  63. 67 0
      packages/@uppy/service-dog/test/__tests__/provider-manager.js
  64. 149 0
      packages/@uppy/service-dog/test/__tests__/service-dog.js
  65. 16 0
      packages/@uppy/service-dog/test/mockserver.js
  66. 0 0
      packages/@uppy/service-dog/test/output/.keep
  67. 15 0
      packages/@uppy/service-dog/tsconfig.json

+ 1 - 0
.travis.yml

@@ -21,6 +21,7 @@ script:
 - npm run web:install
 - npm run build
 - npm run test
+- npm run test:service-dog
 - if [ "${TRAVIS_PULL_REQUEST}" = "false" ]; then npm run test:acceptance; fi
 - npm run uploadcdn
 env:

+ 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__|service-dog/
 
 async function buildLib () {
   const files = await glob(SOURCE)

+ 11 - 0
bin/service-dog

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+sh ../env.sh
+
+export UPPYSERVER_DATADIR="./packages/@uppy/service-dog/output"
+export UPPYSERVER_DOMAIN="localhost:3020"
+export UPPYSERVER_PROTOCOL="http"
+export UPPYSERVER_PORT=3020
+export UPPY_ENDPOINTS=""
+export UPPYSERVER_SECRET="development"
+
+nodemon --exec node ./packages/@uppy/service-dog/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')

+ 2 - 1
package.json

@@ -90,7 +90,7 @@
     "lint": "eslint . --cache",
     "lint-staged": "lint-staged",
     "release": "./bin/release",
-    "start:server": "node bin/start-server",
+    "start:service-dog": "sh ./bin/service-dog",
     "start": "npm-run-all --parallel watch start:server web:preview",
     "test:registry": "verdaccio --listen 4002 --config test/endtoend/verdaccio.yaml",
     "test:build": "./bin/endtoend-build",
@@ -101,6 +101,7 @@
     "test:unit": "jest --testPathPattern=./src --coverage",
     "test:type": "tsc -p .",
     "test": "npm run lint && npm run test:unit && npm run test:type",
+    "test:service-dog": "cd ./packages/@uppy/service-dog && npm run test",
     "test:watch": "jest --watch --testPathPattern=src",
     "travis:deletecache": "travis cache --delete",
     "watch:css": "onchange 'packages/**/*.scss' --initial --verbose -- npm run build:css",

+ 7 - 0
packages/@uppy/service-dog/.eslintrc

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

+ 44 - 0
packages/@uppy/service-dog/.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/service-dog/uppy-env.yaml
+scripts/.tl-deploy-hosts-danger.txt

+ 84 - 0
packages/@uppy/service-dog/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 service-dog doesn't have to save users' oAuth tokens (which is good from the security perspective).
+This json web token would be sent to service-dog 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 `service-dog`'s source (adding files to `server/providers`).

+ 20 - 0
packages/@uppy/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/docker-compose-dev.yml

@@ -0,0 +1,16 @@
+version: '2'
+
+services:
+  uppy:
+    image: service-dog
+    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/service-dog/docker-compose-test.yml

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

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

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

+ 17 - 0
packages/@uppy/service-dog/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/service-dog/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/service-dog')
+
+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/service-dog/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/service-dog": "^0.11.2"
+  }
+}

+ 32 - 0
packages/@uppy/service-dog/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+}'

+ 49 - 0
packages/@uppy/service-dog/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

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

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

+ 115 - 0
packages/@uppy/service-dog/infra/kube/service-dog/service-dog-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
+

+ 13 - 0
packages/@uppy/service-dog/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/service-dog/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="
+				}
+			}
+		}
+	}
+}

+ 107 - 0
packages/@uppy/service-dog/package.json

@@ -0,0 +1,107 @@
+{
+  "name": "@uppy/service-dog",
+  "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": {
+    "service-dog": "./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",
+    "eslint": "^4.19.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.4.5",
+    "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/service-dog-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"
+  }
+}

+ 405 - 0
packages/@uppy/service-dog/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 service-dog 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/service-dog/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 service-dog 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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/src/server/emitter/default-emitter.js

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

+ 16 - 0
packages/@uppy/service-dog/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/service-dog/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 service-dog 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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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 service-dog.
+      // 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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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/service-dog/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 service-dog 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 service-dog 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 service-dog 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/service-dog/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 service-dog.
+  // here we also enforce that only the protocol allowed by service-dog 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/service-dog/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/service-dog/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 service-dog 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([
+          'Service-Dog 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/service-dog/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/service-dog/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 }

+ 58 - 0
packages/@uppy/service-dog/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/service-dog/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()
+  })
+})

+ 149 - 0
packages/@uppy/service-dog/test/__tests__/service-dog.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)
+  })
+})

+ 16 - 0
packages/@uppy/service-dog/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/service-dog/test/output/.keep


+ 15 - 0
packages/@uppy/service-dog/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/**/*"
+  ]
+}