Просмотр исходного кода

Merge branch 'master' into refactor-preact

Artur Paikin 7 лет назад
Родитель
Сommit
aec8d26d6c

+ 6 - 0
CHANGELOG.md

@@ -62,6 +62,7 @@ Ideas that will be planned and find their way into a release at one point
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] consider iframe / more security for Transloadit/Uppy integration widget and Uppy itself. Page can’t get files from Google Drive if its an iframe; possibility for folder restriction for provider plugins
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add https://github.com/pa11y/pa11y for automated accessibility testing?
 - [ ] test: add tests for `npm pack`
 - [ ] test: add tests for `npm pack`
+- [ ] test: add deepFreeze to test that state in not mutated anywhere by accident #320
 
 
 ## 1.0 Goals
 ## 1.0 Goals
 
 
@@ -129,6 +130,11 @@ Theme: 🎄 Christmas edition
 - [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: benchmarks / stress test, large file, uppy-server / tus / S3 (10 GB)
 - [ ] uppy-server: security audit, ask @acconut
 - [ ] uppy-server: security audit, ask @acconut
 - [ ] uppy: refine UI, look into text-based file type icons or more icons for file types? (@arturi)
 - [ ] uppy: refine UI, look into text-based file type icons or more icons for file types? (@arturi)
+- [x] uppy-server: remove pause/resume socket listeners when upload is done (@ifedapoolarewaju)
+- [x] uppy/uppy-server: remote server error handler #446 (@ifedapoolarewaju)
+- [x] provider: dropbox thumbnail view seems not to be working (@ifedapoolarewaju)
+- [ ] uppy-server: look into typescripting for a type safe servers (@ifedapoolarewaju)
+- [x] uppy-server: link uppy-server with https://snyk.io/ to aid vulnerability spotting (@ifedapoolarewaju)
 
 
 ## 0.21.1
 ## 0.21.1
 
 

+ 1 - 1
package-lock.json

@@ -15963,4 +15963,4 @@
       }
       }
     }
     }
   }
   }
-}
+}

+ 5 - 5
package.json

@@ -49,7 +49,7 @@
     "babel-cli": "6.11.4",
     "babel-cli": "6.11.4",
     "babel-core": "6.13.2",
     "babel-core": "6.13.2",
     "babel-eslint": "6.1.2",
     "babel-eslint": "6.1.2",
-    "babel-jest": "^20.0.3",
+    "babel-jest": "^22.0.0",
     "babel-plugin-add-module-exports": "0.2.1",
     "babel-plugin-add-module-exports": "0.2.1",
     "babel-plugin-es6-promise": "1.0.0",
     "babel-plugin-es6-promise": "1.0.0",
     "babel-plugin-transform-object-assign": "6.8.0",
     "babel-plugin-transform-object-assign": "6.8.0",
@@ -68,7 +68,7 @@
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard": "^10.2.1",
     "eslint-config-standard-preact": "^1.1.6",
     "eslint-config-standard-preact": "^1.1.6",
     "eslint-plugin-import": "^2.7.0",
     "eslint-plugin-import": "^2.7.0",
-    "eslint-plugin-jest": "^21.1.0",
+    "eslint-plugin-jest": "^21.5.0",
     "eslint-plugin-node": "^4.2.3",
     "eslint-plugin-node": "^4.2.3",
     "eslint-plugin-promise": "^3.5.0",
     "eslint-plugin-promise": "^3.5.0",
     "eslint-plugin-standard": "^3.0.1",
     "eslint-plugin-standard": "^3.0.1",
@@ -76,7 +76,7 @@
     "fakefile": "0.0.8",
     "fakefile": "0.0.8",
     "glob": "7.1.1",
     "glob": "7.1.1",
     "isomorphic-fetch": "2.2.1",
     "isomorphic-fetch": "2.2.1",
-    "jest": "^20.0.4",
+    "jest": "^22.0.0",
     "lint-staged": "2.0.2",
     "lint-staged": "2.0.2",
     "minify-stream": "^1.1.0",
     "minify-stream": "^1.1.0",
     "mkdirp": "0.5.1",
     "mkdirp": "0.5.1",
@@ -113,8 +113,8 @@
     "preact": "^8.2.7",
     "preact": "^8.2.7",
     "prettier-bytes": "1.0.4",
     "prettier-bytes": "1.0.4",
     "prop-types": "^15.5.10",
     "prop-types": "^15.5.10",
-    "socket.io-client": "2.0.1",
-    "tus-js-client": "^1.4.4",
+    "socket.io-client": "2.0.2",
+    "tus-js-client": "^1.4.5",
     "url-parse": "1.1.9",
     "url-parse": "1.1.9",
     "whatwg-fetch": "2.0.3"
     "whatwg-fetch": "2.0.3"
   },
   },

+ 33 - 2
src/core/Core.js

@@ -101,6 +101,7 @@ class Uppy {
     this.setState({
     this.setState({
       plugins: {},
       plugins: {},
       files: {},
       files: {},
+      currentUploads: {},
       capabilities: {
       capabilities: {
         resumableUploads: false
         resumableUploads: false
       },
       },
@@ -385,11 +386,36 @@ class Uppy {
   }
   }
 
 
   removeFile (fileID) {
   removeFile (fileID) {
-    const updatedFiles = Object.assign({}, this.getState().files)
+    const { files, currentUploads } = this.state
+    const updatedFiles = Object.assign({}, files)
     const removedFile = updatedFiles[fileID]
     const removedFile = updatedFiles[fileID]
     delete updatedFiles[fileID]
     delete updatedFiles[fileID]
 
 
-    this.setState({files: updatedFiles})
+    // Remove this file from its `currentUpload`.
+    const updatedUploads = Object.assign({}, currentUploads)
+    const removeUploads = []
+    Object.keys(updatedUploads).forEach((uploadID) => {
+      const newFileIDs = currentUploads[uploadID].fileIDs.filter((uploadFileID) => uploadFileID !== fileID)
+      // Remove the upload if no files are associated with it anymore.
+      if (newFileIDs.length === 0) {
+        removeUploads.push(uploadID)
+        return
+      }
+
+      updatedUploads[uploadID] = Object.assign({}, currentUploads[uploadID], {
+        fileIDs: newFileIDs
+      })
+    })
+
+    this.setState({
+      currentUploads: updatedUploads,
+      files: updatedFiles
+    })
+
+    removeUploads.forEach((uploadID) => {
+      this.removeUpload(uploadID)
+    })
+
     this._calculateTotalProgress()
     this._calculateTotalProgress()
     this.emit('file-removed', fileID)
     this.emit('file-removed', fileID)
 
 
@@ -693,6 +719,11 @@ class Uppy {
       this.setState({ files: files })
       this.setState({ files: files })
     })
     })
 
 
+    this.on('restored', () => {
+      // Files may have changed--ensure progress is still accurate.
+      this._calculateTotalProgress()
+    })
+
     // show informer if offline
     // show informer if offline
     if (typeof window !== 'undefined') {
     if (typeof window !== 'undefined') {
       window.addEventListener('online', () => this.updateOnlineStatus())
       window.addEventListener('online', () => this.updateOnlineStatus())

+ 11 - 5
src/core/Core.test.js

@@ -152,6 +152,7 @@ describe('src/Core', () => {
         bee: 'boo',
         bee: 'boo',
         capabilities: { resumableUploads: false },
         capabilities: { resumableUploads: false },
         files: {},
         files: {},
+        currentUploads: {},
         foo: 'baar',
         foo: 'baar',
         info: { isHidden: true, message: '', type: 'info' },
         info: { isHidden: true, message: '', type: 'info' },
         meta: {},
         meta: {},
@@ -174,6 +175,7 @@ describe('src/Core', () => {
         bee: 'boo',
         bee: 'boo',
         capabilities: { resumableUploads: false },
         capabilities: { resumableUploads: false },
         files: {},
         files: {},
+        currentUploads: {},
         foo: 'bar',
         foo: 'bar',
         info: { isHidden: true, message: '', type: 'info' },
         info: { isHidden: true, message: '', type: 'info' },
         meta: {},
         meta: {},
@@ -185,6 +187,7 @@ describe('src/Core', () => {
         bee: 'boo',
         bee: 'boo',
         capabilities: { resumableUploads: false },
         capabilities: { resumableUploads: false },
         files: {},
         files: {},
+        currentUploads: {},
         foo: 'baar',
         foo: 'baar',
         info: { isHidden: true, message: '', type: 'info' },
         info: { isHidden: true, message: '', type: 'info' },
         meta: {},
         meta: {},
@@ -201,6 +204,7 @@ describe('src/Core', () => {
       expect(core.getState()).toEqual({
       expect(core.getState()).toEqual({
         capabilities: { resumableUploads: false },
         capabilities: { resumableUploads: false },
         files: {},
         files: {},
+        currentUploads: {},
         foo: 'bar',
         foo: 'bar',
         info: { isHidden: true, message: '', type: 'info' },
         info: { isHidden: true, message: '', type: 'info' },
         meta: {},
         meta: {},
@@ -227,6 +231,7 @@ describe('src/Core', () => {
     expect(coreStateUpdateEventMock.mock.calls[1][1]).toEqual({
     expect(coreStateUpdateEventMock.mock.calls[1][1]).toEqual({
       capabilities: { resumableUploads: false },
       capabilities: { resumableUploads: false },
       files: {},
       files: {},
+      currentUploads: {},
       foo: 'bar',
       foo: 'bar',
       info: { isHidden: true, message: '', type: 'info' },
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
       meta: {},
@@ -254,6 +259,7 @@ describe('src/Core', () => {
     expect(coreStateUpdateEventMock.mock.calls[0][1]).toEqual({
     expect(coreStateUpdateEventMock.mock.calls[0][1]).toEqual({
       capabilities: { resumableUploads: false },
       capabilities: { resumableUploads: false },
       files: {},
       files: {},
+      currentUploads: {},
       info: { isHidden: true, message: '', type: 'info' },
       info: { isHidden: true, message: '', type: 'info' },
       meta: {},
       meta: {},
       plugins: {},
       plugins: {},
@@ -595,7 +601,7 @@ describe('src/Core', () => {
         name: 'foo.jpg',
         name: 'foo.jpg',
         type: 'image/jpeg',
         type: 'image/jpeg',
         data: null
         data: null
-      })).rejects.toMatchObject({ message: 'onBeforeFileAdded: a plain string' })
+      })).rejects.toMatchObject(new Error('onBeforeFileAdded: a plain string'))
     })
     })
 
 
     it('should call utils.generatePreview when file-added is triggered and thumbnail generation is allowed', () => {
     it('should call utils.generatePreview when file-added is triggered and thumbnail generation is allowed', () => {
@@ -929,7 +935,7 @@ describe('src/Core', () => {
         name: 'foo2.jpg',
         name: 'foo2.jpg',
         type: 'image/jpeg',
         type: 'image/jpeg',
         data: utils.dataURItoFile(sampleImageDataURI, {})
         data: utils.dataURItoFile(sampleImageDataURI, {})
-      })).rejects.toMatchObject({ message: 'File not allowed' }).then(() => {
+      })).rejects.toMatchObject(new Error('File not allowed')).then(() => {
         expect(core.state.info.message).toEqual('You can only upload 1 file')
         expect(core.state.info.message).toEqual('You can only upload 1 file')
       })
       })
     })
     })
@@ -949,7 +955,7 @@ describe('src/Core', () => {
         name: 'foo2.jpg',
         name: 'foo2.jpg',
         type: 'image/jpeg',
         type: 'image/jpeg',
         data: utils.dataURItoFile(sampleImageDataURI, {})
         data: utils.dataURItoFile(sampleImageDataURI, {})
-      })).rejects.toMatchObject({ message: 'File not allowed' }).then(() => {
+      })).rejects.toMatchObject(new Error('File not allowed')).then(() => {
         expect(core.state.info.message).toEqual('You can only upload: image/gif, image/png')
         expect(core.state.info.message).toEqual('You can only upload: image/gif, image/png')
       })
       })
     })
     })
@@ -967,7 +973,7 @@ describe('src/Core', () => {
         name: 'foo.jpg',
         name: 'foo.jpg',
         type: 'image/jpeg',
         type: 'image/jpeg',
         data: utils.dataURItoFile(sampleImageDataURI, {})
         data: utils.dataURItoFile(sampleImageDataURI, {})
-      })).rejects.toMatchObject({ message: 'File not allowed' }).then(() => {
+      })).rejects.toMatchObject(new Error('File not allowed')).then(() => {
         expect(core.state.info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
         expect(core.state.info.message).toEqual('This file exceeds maximum allowed size of 1.2 KB')
       })
       })
     })
     })
@@ -987,7 +993,7 @@ describe('src/Core', () => {
       core.state.files['fileId'] = {
       core.state.files['fileId'] = {
         name: 'filename'
         name: 'filename'
       }
       }
-      core.emit('upload-error', 'fileId', { message: 'this is the error' })
+      core.emit('upload-error', 'fileId', new Error('this is the error'))
       expect(core.state.info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
       expect(core.state.info).toEqual({'message': 'Failed to upload filename', 'details': 'this is the error', 'isHidden': false, 'type': 'error'})
     })
     })
 
 

+ 4 - 1
src/core/Utils.js

@@ -121,7 +121,10 @@ function getFileType (file) {
     'markdown': 'text/markdown',
     'markdown': 'text/markdown',
     'mp4': 'video/mp4',
     'mp4': 'video/mp4',
     'mp3': 'audio/mp3',
     'mp3': 'audio/mp3',
-    'svg': 'image/svg+xml'
+    'svg': 'image/svg+xml',
+    'jpg': 'image/jpeg',
+    'png': 'image/png',
+    'gif': 'image/gif'
   }
   }
 
 
   const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null
   const fileExtension = file.name ? getFileNameAndExtension(file.name).extension : null

+ 2 - 2
src/core/Utils.test.js

@@ -363,7 +363,7 @@ describe('core/utils', () => {
         ])
         ])
       ).resolves.toMatchObject({
       ).resolves.toMatchObject({
         successful: [],
         successful: [],
-        failed: [{ message: 'oops' }, { message: 'this went wrong' }]
+        failed: [ new Error('oops'), new Error('this went wrong') ]
       })
       })
     })
     })
 
 
@@ -376,7 +376,7 @@ describe('core/utils', () => {
         ])
         ])
       ).resolves.toMatchObject({
       ).resolves.toMatchObject({
         successful: ['resolved', 'also-resolved'],
         successful: ['resolved', 'also-resolved'],
-        failed: [{ message: 'rejected' }]
+        failed: [new Error('rejected')]
       })
       })
     })
     })
   })
   })

+ 33 - 14
src/plugins/GoldenRetriever/index.js

@@ -48,8 +48,13 @@ module.exports = class GoldenRetriever extends Plugin {
     const savedState = this.MetaDataStore.load()
     const savedState = this.MetaDataStore.load()
 
 
     if (savedState) {
     if (savedState) {
-      this.uppy.log('Recovered some state from Local Storage')
-      this.uppy.setState(savedState)
+      this.uppy.log('[GoldenRetriever] Recovered some state from Local Storage')
+      this.uppy.setState({
+        currentUploads: savedState.currentUploads || {},
+        files: savedState.files || {}
+      })
+
+      this.savedPluginData = savedState.pluginData
     }
     }
   }
   }
 
 
@@ -99,9 +104,18 @@ module.exports = class GoldenRetriever extends Plugin {
       this.getUploadingFiles()
       this.getUploadingFiles()
     )
     )
 
 
+    const pluginData = {}
+    // TODO Find a better way to do this?
+    // Other plugins can attach a restore:get-data listener that receives this callback.
+    // Plugins can then use this callback (sync) to provide data to be stored.
+    this.uppy.emit('restore:get-data', (data) => {
+      Object.assign(pluginData, data)
+    })
+
     this.MetaDataStore.save({
     this.MetaDataStore.save({
       currentUploads: this.uppy.state.currentUploads,
       currentUploads: this.uppy.state.currentUploads,
-      files: filesToSave
+      files: filesToSave,
+      pluginData: pluginData
     })
     })
   }
   }
 
 
@@ -110,11 +124,11 @@ module.exports = class GoldenRetriever extends Plugin {
       const numberOfFilesRecovered = Object.keys(blobs).length
       const numberOfFilesRecovered = Object.keys(blobs).length
       const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
       const numberOfFilesTryingToRecover = Object.keys(this.uppy.state.files).length
       if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
       if (numberOfFilesRecovered === numberOfFilesTryingToRecover) {
-        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
+        this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Service Worker!`)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
         this.onBlobsLoaded(blobs)
         this.onBlobsLoaded(blobs)
       } else {
       } else {
-        this.uppy.log('Failed to recover blobs from Service Worker, trying IndexedDB now...')
+        this.uppy.log('[GoldenRetriever] Failed to recover blobs from Service Worker, trying IndexedDB now...')
         this.loadFileBlobsFromIndexedDB()
         this.loadFileBlobsFromIndexedDB()
       }
       }
     })
     })
@@ -125,11 +139,11 @@ module.exports = class GoldenRetriever extends Plugin {
       const numberOfFilesRecovered = Object.keys(blobs).length
       const numberOfFilesRecovered = Object.keys(blobs).length
 
 
       if (numberOfFilesRecovered > 0) {
       if (numberOfFilesRecovered > 0) {
-        this.uppy.log(`Successfully recovered ${numberOfFilesRecovered} blobs from Indexed DB!`)
+        this.uppy.log(`[GoldenRetriever] Successfully recovered ${numberOfFilesRecovered} blobs from Indexed DB!`)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
         this.uppy.info(`Successfully recovered ${numberOfFilesRecovered} files`, 'success', 3000)
         return this.onBlobsLoaded(blobs)
         return this.onBlobsLoaded(blobs)
       }
       }
-      this.uppy.log('Couldn’t recover anything from IndexedDB :(')
+      this.uppy.log('[GoldenRetriever] Couldn’t recover anything from IndexedDB :(')
     })
     })
   }
   }
 
 
@@ -154,14 +168,16 @@ module.exports = class GoldenRetriever extends Plugin {
 
 
       this.uppy.generatePreview(updatedFile)
       this.uppy.generatePreview(updatedFile)
     })
     })
+
     this.uppy.setState({
     this.uppy.setState({
       files: updatedFiles
       files: updatedFiles
     })
     })
-    this.uppy.emit('restored')
+
+    this.uppy.emit('restored', this.savedPluginData)
 
 
     if (obsoleteBlobs.length) {
     if (obsoleteBlobs.length) {
       this.deleteBlobs(obsoleteBlobs).then(() => {
       this.deleteBlobs(obsoleteBlobs).then(() => {
-        this.uppy.log(`[GoldenRetriever] cleaned up ${obsoleteBlobs.length} old files`)
+        this.uppy.log(`[GoldenRetriever] Cleaned up ${obsoleteBlobs.length} old files`)
       })
       })
     }
     }
   }
   }
@@ -184,12 +200,15 @@ module.exports = class GoldenRetriever extends Plugin {
 
 
     if (Object.keys(this.uppy.state.files).length > 0) {
     if (Object.keys(this.uppy.state.files).length > 0) {
       if (this.ServiceWorkerStore) {
       if (this.ServiceWorkerStore) {
-        this.uppy.log('Attempting to load files from Service Worker...')
+        this.uppy.log('[GoldenRetriever] Attempting to load files from Service Worker...')
         this.loadFileBlobsFromServiceWorker()
         this.loadFileBlobsFromServiceWorker()
       } else {
       } else {
-        this.uppy.log('Attempting to load files from Indexed DB...')
+        this.uppy.log('[GoldenRetriever] Attempting to load files from Indexed DB...')
         this.loadFileBlobsFromIndexedDB()
         this.loadFileBlobsFromIndexedDB()
       }
       }
+    } else {
+      this.uppy.log('[GoldenRetriever] No files need to be loaded, only restoring processing state...')
+      this.onBlobsLoaded([])
     }
     }
 
 
     this.uppy.on('file-added', (file) => {
     this.uppy.on('file-added', (file) => {
@@ -197,13 +216,13 @@ module.exports = class GoldenRetriever extends Plugin {
 
 
       if (this.ServiceWorkerStore) {
       if (this.ServiceWorkerStore) {
         this.ServiceWorkerStore.put(file).catch((err) => {
         this.ServiceWorkerStore.put(file).catch((err) => {
-          this.uppy.log('Could not store file', 'error')
+          this.uppy.log('[GoldenRetriever] Could not store file', 'error')
           this.uppy.log(err)
           this.uppy.log(err)
         })
         })
       }
       }
 
 
       this.IndexedDBStore.put(file).catch((err) => {
       this.IndexedDBStore.put(file).catch((err) => {
-        this.uppy.log('Could not store file', 'error')
+        this.uppy.log('[GoldenRetriever] Could not store file', 'error')
         this.uppy.log(err)
         this.uppy.log(err)
       })
       })
     })
     })
@@ -216,7 +235,7 @@ module.exports = class GoldenRetriever extends Plugin {
     this.uppy.on('complete', ({ successful }) => {
     this.uppy.on('complete', ({ successful }) => {
       const fileIDs = successful.map((file) => file.id)
       const fileIDs = successful.map((file) => file.id)
       this.deleteBlobs(fileIDs).then(() => {
       this.deleteBlobs(fileIDs).then(() => {
-        this.uppy.log(`[GoldenRetriever] removed ${successful.length} files that finished uploading`)
+        this.uppy.log(`[GoldenRetriever] Removed ${successful.length} files that finished uploading`)
       })
       })
     })
     })
 
 

+ 247 - 13
src/plugins/Transloadit/index.js

@@ -3,6 +3,14 @@ const Plugin = require('../../core/Plugin')
 const Client = require('./Client')
 const Client = require('./Client')
 const StatusSocket = require('./Socket')
 const StatusSocket = require('./Socket')
 
 
+function defaultGetAssemblyOptions (file, options) {
+  return {
+    params: options.params,
+    signature: options.signature,
+    fields: options.fields
+  }
+}
+
 /**
 /**
  * Upload files to Transloadit using Tus.
  * Upload files to Transloadit using Tus.
  */
  */
@@ -29,13 +37,7 @@ module.exports = class Transloadit extends Plugin {
       signature: null,
       signature: null,
       params: null,
       params: null,
       fields: {},
       fields: {},
-      getAssemblyOptions (file, options) {
-        return {
-          params: options.params,
-          signature: options.signature,
-          fields: options.fields
-        }
-      },
+      getAssemblyOptions: defaultGetAssemblyOptions,
       locale: defaultLocale
       locale: defaultLocale
     }
     }
 
 
@@ -50,6 +52,8 @@ module.exports = class Transloadit extends Plugin {
     this.prepareUpload = this.prepareUpload.bind(this)
     this.prepareUpload = this.prepareUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
     this.afterUpload = this.afterUpload.bind(this)
     this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
     this.onFileUploadURLAvailable = this.onFileUploadURLAvailable.bind(this)
+    this.onRestored = this.onRestored.bind(this)
+    this.getPersistentData = this.getPersistentData.bind(this)
 
 
     if (this.opts.params) {
     if (this.opts.params) {
       this.validateParams(this.opts.params)
       this.validateParams(this.opts.params)
@@ -120,7 +124,7 @@ module.exports = class Transloadit extends Plugin {
   createAssembly (fileIDs, uploadID, options) {
   createAssembly (fileIDs, uploadID, options) {
     const pluginOptions = this.opts
     const pluginOptions = this.opts
 
 
-    this.uppy.log('Transloadit: create assembly')
+    this.uppy.log('[Transloadit] create assembly')
 
 
     return this.client.createAssembly({
     return this.client.createAssembly({
       params: options.params,
       params: options.params,
@@ -187,7 +191,7 @@ module.exports = class Transloadit extends Plugin {
       return this.connectSocket(assembly)
       return this.connectSocket(assembly)
         .then(() => assembly)
         .then(() => assembly)
     }).then((assembly) => {
     }).then((assembly) => {
-      this.uppy.log('Transloadit: Created assembly')
+      this.uppy.log('[Transloadit] Created assembly')
       return assembly
       return assembly
     }).catch((err) => {
     }).catch((err) => {
       this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
       this.uppy.info(this.i18n('creatingAssemblyFailed'), 'error', 0)
@@ -237,9 +241,20 @@ module.exports = class Transloadit extends Plugin {
       if (!files.hasOwnProperty(id)) {
       if (!files.hasOwnProperty(id)) {
         continue
         continue
       }
       }
+      // Completed file upload.
       if (files[id].uploadURL === uploadedFile.tus_upload_url) {
       if (files[id].uploadURL === uploadedFile.tus_upload_url) {
         return files[id]
         return files[id]
       }
       }
+      // In-progress file upload.
+      if (files[id].tus && files[id].tus.uploadUrl === uploadedFile.tus_upload_url) {
+        return files[id]
+      }
+      if (!uploadedFile.is_tus_file) {
+        // Fingers-crossed check for non-tus uploads, eg imported from S3.
+        if (files[id].name === uploadedFile.name && files[id].size === uploadedFile.size) {
+          return files[id]
+        }
+      }
     }
     }
   }
   }
 
 
@@ -249,6 +264,7 @@ module.exports = class Transloadit extends Plugin {
     this.setPluginState({
     this.setPluginState({
       files: Object.assign({}, state.files, {
       files: Object.assign({}, state.files, {
         [uploadedFile.id]: {
         [uploadedFile.id]: {
+          assembly: assemblyId,
           id: file.id,
           id: file.id,
           uploadedFile
           uploadedFile
         }
         }
@@ -263,8 +279,15 @@ module.exports = class Transloadit extends Plugin {
     // The `file` may not exist if an import robot was used instead of a file upload.
     // The `file` may not exist if an import robot was used instead of a file upload.
     result.localId = file ? file.id : null
     result.localId = file ? file.id : null
 
 
+    const entry = {
+      result,
+      stepName,
+      id: result.id,
+      assembly: assemblyId
+    }
+
     this.setPluginState({
     this.setPluginState({
-      results: state.results.concat(result)
+      results: [...state.results, entry]
     })
     })
     this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
     this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assemblyId))
   }
   }
@@ -281,6 +304,200 @@ module.exports = class Transloadit extends Plugin {
     })
     })
   }
   }
 
 
+  getPersistentData (setData) {
+    const state = this.getPluginState()
+    const assemblies = state.assemblies
+    const uploadsAssemblies = state.uploadsAssemblies
+    const uploads = Object.keys(state.files)
+    const results = state.results.map((result) => result.id)
+
+    setData({
+      [this.id]: {
+        assemblies,
+        uploadsAssemblies,
+        uploads,
+        results
+      }
+    })
+  }
+
+  /**
+   * Emit the necessary events that must have occured to get from the `prevState`,
+   * to the current state.
+   * For completed uploads, `transloadit:upload` is emitted.
+   * For new results, `transloadit:result` is emitted.
+   * For completed or errored assemblies, `transloadit:complete` or `transloadit:assembly-error` is emitted.
+   */
+  emitEventsDiff (prevState) {
+    const opts = this.opts
+    const state = this.getPluginState()
+
+    const emitMissedEvents = () => {
+      // Emit events for completed uploads and completed results
+      // that we've missed while we were away.
+      const newUploads = Object.keys(state.files).filter((fileID) => {
+        return !prevState.files.hasOwnProperty(fileID)
+      }).map((fileID) => state.files[fileID])
+      const newResults = state.results.filter((result) => {
+        return !prevState.results.some((prev) => prev.id === result.id)
+      })
+
+      this.uppy.log('[Transloadit] New fully uploaded files since restore:')
+      this.uppy.log(newUploads)
+      newUploads.forEach(({ assembly, uploadedFile }) => {
+        this.uppy.log(`[Transloadit]  emitting transloadit:upload ${uploadedFile.id}`)
+        this.uppy.emit('transloadit:upload', uploadedFile, this.getAssembly(assembly))
+      })
+      this.uppy.log('[Transloadit] New results since restore:')
+      this.uppy.log(newResults)
+      newResults.forEach(({ assembly, stepName, result, id }) => {
+        this.uppy.log(`[Transloadit]  emitting transloadit:result ${stepName}, ${id}`)
+        this.uppy.emit('transloadit:result', stepName, result, this.getAssembly(assembly))
+      })
+
+      const newAssemblies = state.assemblies
+      const previousAssemblies = prevState.assemblies
+      this.uppy.log('[Transloadit] Current assembly status after restore')
+      this.uppy.log(newAssemblies)
+      this.uppy.log('[Transloadit] Assembly status before restore')
+      this.uppy.log(previousAssemblies)
+      Object.keys(newAssemblies).forEach((assemblyId) => {
+        const oldAssembly = previousAssemblies[assemblyId]
+        diffAssemblyStatus(oldAssembly, newAssemblies[assemblyId])
+      })
+    }
+
+    // Emit events for assemblies that have completed or errored while we were away.
+    const diffAssemblyStatus = (prev, next) => {
+      this.uppy.log('[Transloadit] Diff assemblies')
+      this.uppy.log(prev)
+      this.uppy.log(next)
+
+      if (opts.waitForEncoding && next.ok === 'ASSEMBLY_COMPLETED' && prev.ok !== 'ASSEMBLY_COMPLETED') {
+        this.uppy.log(`[Transloadit]  Emitting transloadit:complete for ${next.assembly_id}`)
+        this.uppy.log(next)
+        this.uppy.emit('transloadit:complete', next)
+      } else if (opts.waitForMetadata && next.upload_meta_data_extracted && !prev.upload_meta_data_extracted) {
+        this.uppy.log(`[Transloadit]  Emitting transloadit:complete after metadata extraction for ${next.assembly_id}`)
+        this.uppy.log(next)
+        this.uppy.emit('transloadit:complete', next)
+      }
+
+      if (next.error && !prev.error) {
+        this.uppy.log(`[Transloadit]  !!! Emitting transloadit:assembly-error for ${next.assembly_id}`)
+        this.uppy.log(next)
+        this.uppy.emit('transloadit:assembly-error', next, new Error(next.message))
+      }
+    }
+
+    emitMissedEvents()
+  }
+
+  onRestored (pluginData) {
+    const savedState = pluginData && pluginData[this.id] ? pluginData[this.id] : {}
+    const knownUploads = savedState.files || []
+    const knownResults = savedState.results || []
+    const previousAssemblies = savedState.assemblies || {}
+    const uploadsAssemblies = savedState.uploadsAssemblies || {}
+
+    if (Object.keys(uploadsAssemblies).length === 0) {
+      // Nothing to restore.
+      return
+    }
+
+    // Fetch up-to-date assembly statuses.
+    const loadAssemblies = () => {
+      const assemblyIDs = []
+      Object.keys(uploadsAssemblies).forEach((uploadID) => {
+        assemblyIDs.push(...uploadsAssemblies[uploadID])
+      })
+
+      return Promise.all(
+        assemblyIDs.map((assemblyID) => {
+          const url = `https://api2.transloadit.com/assemblies/${assemblyID}`
+          return this.client.getAssemblyStatus(url)
+        })
+      )
+    }
+
+    const reconnectSockets = (assemblies) => {
+      return Promise.all(assemblies.map((assembly) => {
+        // No need to connect to the socket if the assembly has completed by now.
+        if (assembly.ok === 'ASSEMBLY_COMPLETE') {
+          return null
+        }
+        return this.connectSocket(assembly)
+      }))
+    }
+
+    // Convert loaded assembly statuses to a Transloadit plugin state object.
+    const restoreState = (assemblies) => {
+      const assembliesById = {}
+      const files = {}
+      const results = []
+      assemblies.forEach((assembly) => {
+        assembliesById[assembly.assembly_id] = assembly
+
+        assembly.uploads.forEach((uploadedFile) => {
+          const file = this.findFile(uploadedFile)
+          files[uploadedFile.id] = {
+            id: file.id,
+            assembly: assembly.assembly_id,
+            uploadedFile
+          }
+        })
+
+        const state = this.getPluginState()
+        Object.keys(assembly.results).forEach((stepName) => {
+          assembly.results[stepName].forEach((result) => {
+            const file = state.files[result.original_id]
+            result.localId = file ? file.id : null
+            results.push({
+              id: result.id,
+              result,
+              stepName,
+              assembly: assembly.assembly_id
+            })
+          })
+        })
+      })
+
+      this.setPluginState({
+        assemblies: assembliesById,
+        files: files,
+        results: results,
+        uploadsAssemblies: uploadsAssemblies
+      })
+    }
+
+    // Restore all assembly state.
+    this.restored = Promise.resolve()
+      .then(loadAssemblies)
+      .then((assemblies) => {
+        restoreState(assemblies)
+        return reconnectSockets(assemblies)
+      })
+      .then(() => {
+        // Return a callback that will be called by `afterUpload`
+        // once it has attached event listeners etc.
+        const newState = this.getPluginState()
+        const previousFiles = {}
+        knownUploads.forEach((id) => {
+          previousFiles[id] = newState.files[id]
+        })
+        return () => this.emitEventsDiff({
+          assemblies: previousAssemblies,
+          files: previousFiles,
+          results: newState.results.filter(({ id }) => knownResults.indexOf(id) !== -1),
+          uploadsAssemblies
+        })
+      })
+
+    this.restored.then(() => {
+      this.restored = null
+    })
+  }
+
   connectSocket (assembly) {
   connectSocket (assembly) {
     const socket = new StatusSocket(
     const socket = new StatusSocket(
       assembly.websocket_url,
       assembly.websocket_url,
@@ -304,7 +521,6 @@ module.exports = class Transloadit extends Plugin {
     } else if (this.opts.waitForMetadata) {
     } else if (this.opts.waitForMetadata) {
       socket.on('metadata', () => {
       socket.on('metadata', () => {
         this.onAssemblyFinished(assembly.assembly_ssl_url)
         this.onAssemblyFinished(assembly.assembly_ssl_url)
-        this.uppy.emit('transloadit:complete', assembly)
       })
       })
     }
     }
 
 
@@ -312,7 +528,7 @@ module.exports = class Transloadit extends Plugin {
       socket.on('connect', resolve)
       socket.on('connect', resolve)
       socket.on('error', reject)
       socket.on('error', reject)
     }).then(() => {
     }).then(() => {
-      this.uppy.log('Transloadit: Socket is ready')
+      this.uppy.log('[Transloadit] Socket is ready')
     })
     })
   }
   }
 
 
@@ -374,6 +590,16 @@ module.exports = class Transloadit extends Plugin {
     fileIDs = fileIDs.filter((file) => !file.error)
     fileIDs = fileIDs.filter((file) => !file.error)
 
 
     const state = this.getPluginState()
     const state = this.getPluginState()
+
+    // If we're still restoring state, wait for that to be done.
+    if (this.restored) {
+      return this.restored.then((emitMissedEvents) => {
+        const promise = this.afterUpload(fileIDs, uploadID)
+        emitMissedEvents()
+        return promise
+      })
+    }
+
     const assemblyIDs = state.uploadsAssemblies[uploadID]
     const assemblyIDs = state.uploadsAssemblies[uploadID]
 
 
     // If we don't have to wait for encoding metadata or results, we can close
     // If we don't have to wait for encoding metadata or results, we can close
@@ -405,8 +631,10 @@ module.exports = class Transloadit extends Plugin {
       const onAssemblyFinished = (assembly) => {
       const onAssemblyFinished = (assembly) => {
         // An assembly for a different upload just finished. We can ignore it.
         // An assembly for a different upload just finished. We can ignore it.
         if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
         if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
+          this.uppy.log(`[Transloadit] afterUpload(): Ignoring finished assembly ${assembly.assembly_id}`)
           return
           return
         }
         }
+        this.uppy.log(`[Transloadit] afterUpload(): Got assembly finish ${assembly.assembly_id}`)
 
 
         // TODO set the `file.uploadURL` to a result?
         // TODO set the `file.uploadURL` to a result?
         // We will probably need an option here so the plugin user can tell us
         // We will probably need an option here so the plugin user can tell us
@@ -421,10 +649,13 @@ module.exports = class Transloadit extends Plugin {
       }
       }
 
 
       const onAssemblyError = (assembly, error) => {
       const onAssemblyError = (assembly, error) => {
-        // An assembly for a different upload just finished. We can ignore it.
+        // An assembly for a different upload just errored. We can ignore it.
         if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
         if (assemblyIDs.indexOf(assembly.assembly_id) === -1) {
+          this.uppy.log(`[Transloadit] afterUpload(): Ignoring errored assembly ${assembly.assembly_id}`)
           return
           return
         }
         }
+        this.uppy.log(`[Transloadit] afterUpload(): Got assembly error ${assembly.assembly_id}`)
+        this.uppy.log(error)
 
 
         // Clear postprocessing state for all our files.
         // Clear postprocessing state for all our files.
         const files = this.getAssemblyFiles(assembly.assembly_id)
         const files = this.getAssemblyFiles(assembly.assembly_id)
@@ -486,6 +717,9 @@ module.exports = class Transloadit extends Plugin {
       this.uppy.on('upload-success', this.onFileUploadURLAvailable)
       this.uppy.on('upload-success', this.onFileUploadURLAvailable)
     }
     }
 
 
+    this.uppy.on('restore:get-data', this.getPersistentData)
+    this.uppy.on('restored', this.onRestored)
+
     this.setPluginState({
     this.setPluginState({
       // Contains assembly status objects, indexed by their ID.
       // Contains assembly status objects, indexed by their ID.
       assemblies: {},
       assemblies: {},

+ 5 - 1
src/plugins/Tus.js

@@ -232,7 +232,7 @@ module.exports = class Tus extends Plugin {
           }))
           }))
         })
         })
         .then((res) => {
         .then((res) => {
-          if (res.status < 200 && res.status > 300) {
+          if (res.status < 200 || res.status > 300) {
             return reject(res.statusText)
             return reject(res.statusText)
           }
           }
 
 
@@ -288,6 +288,10 @@ module.exports = class Tus extends Plugin {
 
 
     socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
     socket.on('progress', (progressData) => emitSocketProgress(this, progressData, file))
 
 
+    socket.on('error', (errData) => {
+      this.uppy.emit('core:upload-error', file.id, new Error(errData.error))
+    })
+
     socket.on('success', (data) => {
     socket.on('success', (data) => {
       this.uppy.emit('upload-success', file.id, data, data.url)
       this.uppy.emit('upload-success', file.id, data, data.url)
       this.resetUploaderReferences(file.id)
       this.resetUploaderReferences(file.id)

+ 48 - 0
website/src/docs/fileinput.md

@@ -0,0 +1,48 @@
+---
+type: docs
+order: 25
+title: "FileInput"
+permalink: docs/fileinput/
+---
+
+`FileInput` is the most barebones UI for selecting files—it shows a single button that, when clicked, opens up the browser's file selector.
+
+[Try it live](/examples/xhrupload) - The XHRUpload example uses a `FileInput`.
+
+## Options
+
+```js
+uppy.use(FileInput, {
+  target: '.UppyForm',
+  getMetaFromForm: true,
+  replaceTargetContent: true,
+  multipleFiles: true,
+  pretty: true,
+  locale: {
+    strings: {
+      selectToUpload: 'Select to upload'
+    }
+  },
+  inputName: 'files[]'
+})
+```
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount the file input into.
+
+### `multipleFiles: true`
+
+Whether to allow the user to select multiple files at once.
+
+### `pretty: true`
+
+When true, display a styled button (see [example](/examples/xhrupload)) that, when clicked, opens the file selector UI. When false, a plain old browser `<input type="file">` element is shown.
+
+### `inputName: 'files[]'`
+
+The `name` attribute for the `<input type="file">` element.
+
+### `locale: {}`
+
+Custom text to show on the button when `pretty` is true. There is only one string that can be configured: `strings.selectToUpload`.

+ 31 - 0
website/src/docs/informer.md

@@ -0,0 +1,31 @@
+---
+type: docs
+order: 24
+title: "Informer"
+permalink: docs/informer/
+---
+
+The Informer is a pop-up bar for showing notifications. When plugins have some exciting news (or error) to share, they can show a notification here.
+
+[Try it live](/examples/dashboard/) - The Informer is included in the Dashboard by default.
+
+## Options
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount the informer into.
+
+### `typeColors: {}`
+
+Customize the background and foreground colors for different types of notifications. Supported types are `info`, `warning`, `error`, and `success`. To customize colors, pass an object containing `{ bg, text }` color pairs for each type of notification:
+
+```js
+uppy.use(Informer, {
+  typeColors: {
+    info:    { text: '#fff', bg: '#000000' },
+    warning: { text: '#fff', bg: '#f6a623' },
+    error:   { text: '#fff', bg: '#e74c3c' },
+    success: { text: '#fff', bg: '#7ac824' }
+  }
+})
+```

+ 8 - 7
website/src/docs/plugins.md

@@ -7,19 +7,20 @@ order: 10
 
 
 Plugins are what makes Uppy useful: they help select, manipulate and upload files.
 Plugins are what makes Uppy useful: they help select, manipulate and upload files.
 
 
-- **Acquirers (neat UIs for picking files):**
+- **Acquirers (various ways of picking files):**
   - [Dashboard](/docs/dashboard) — full featured sleek UI with file previews, metadata editing, upload/pause/resume/cancel buttons and more
   - [Dashboard](/docs/dashboard) — full featured sleek UI with file previews, metadata editing, upload/pause/resume/cancel buttons and more
   - [DragDrop](/docs/dragdrop) — plain and simple drag and drop area
   - [DragDrop](/docs/dragdrop) — plain and simple drag and drop area
-  - FileInput — even more plain and simple, just a button
+  - [FileInput](/docs/fileinput) — even more plain and simple, just a button
   - [Provider Plugins](#Provider-Plugins) (remote sources that work through [Uppy Server](/docs/uppy-server/)): Instagram, GoogleDrive, Dropbox
   - [Provider Plugins](#Provider-Plugins) (remote sources that work through [Uppy Server](/docs/uppy-server/)): Instagram, GoogleDrive, Dropbox
+  - [Webcam](/docs/webcam) — upload selfies or audio / video recordings
 - **Uploaders:**
 - **Uploaders:**
-  - Tus — uploads using the tus resumable upload protocol
-  - XHRUpload — classic multipart form uploads or binary uploads using XMLHTTPRequest
+  - [Tus](/docs/tus) — uploads using the [tus](https://tus.io) resumable upload protocol
+  - [XHRUpload](/docs/xhrupload) — classic multipart form uploads or binary uploads using XMLHTTPRequest
   - [AwsS3](/docs/aws-s3) — uploader for AWS S3
   - [AwsS3](/docs/aws-s3) — uploader for AWS S3
 - **Progress:**
 - **Progress:**
-  - ProgressBar — add a small YouTube-style progress bar at the top of the page
+  - [ProgressBar](/docs/progressbar) — add a small YouTube-style progress bar at the top of the page
   - [StatusBar](/docs/statusbar) — advanced upload progress status bar
   - [StatusBar](/docs/statusbar) — advanced upload progress status bar
-  - Informer — show notifications
+  - [Informer](/docs/informer) — show notifications
 - **Helpers:**
 - **Helpers:**
   - [GoldenRetriever](/docs/golden-retriever) — restore files and continue uploading after a page refresh or a browser crash
   - [GoldenRetriever](/docs/golden-retriever) — restore files and continue uploading after a page refresh or a browser crash
 - **Encoding Services:**
 - **Encoding Services:**
@@ -59,7 +60,7 @@ In the example above the `Dashboard` gets rendered into an element with ID `uppy
 
 
 ### `endpoint`
 ### `endpoint`
 
 
-Used by uploader plugins, such as Tus and XHRUpload. Expects a `string` with a url that will be used for file uploading.
+Used by uploader plugins, such as [Tus](/docs/tus) and [XHRUpload](/docs/xhrupload). Expects a `string` with a url that will be used for file uploading.
 
 
 ### `host`
 ### `host`
 
 

+ 34 - 0
website/src/docs/progressbar.md

@@ -0,0 +1,34 @@
+---
+type: docs
+order: 23
+title: "ProgressBar"
+permalink: docs/progressbar/
+---
+
+ProgressBar is a minimalist plugin that shows the current upload progress in a thin bar element, similar to the ones used by YouTube and GitHub when navigating between pages.
+
+[Try it live](/examples/dragdrop/) - The DragDrop example uses ProgressBars to show progress.
+
+## Options
+
+```js
+uppy.use(ProgressBar, {
+  target: '.UploadForm',
+  fixed: false
+})
+```
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount the progress bar into.
+
+### `fixed: false`
+
+When true, show the progress bar at the top of the page with `position: fixed`. When false, show the progress bar inline wherever it is mounted.
+
+```js
+uppy.use(ProgressBar, {
+  target: 'body',
+  fixed: true
+})
+```

+ 57 - 0
website/src/docs/webcam.md

@@ -0,0 +1,57 @@
+---
+type: docs
+order: 26
+title: "Webcam"
+permalink: docs/webcam/
+---
+
+
+[Try it live](/examples/dashboard/) - The Informer is included in the Dashboard by default.
+
+## Options
+
+```js
+uppy.use(Webcam, {
+  onBeforeSnapshot: () => Promise.resolve(),
+  countdown: false,
+  modes: [
+    'video-audio',
+    'video-only',
+    'audio-only',
+    'picture'
+  ],
+  locale: {
+    strings: {
+      smile: 'Smile!'
+    }
+  }
+})
+```
+
+### `target: null`
+
+DOM element, CSS selector, or plugin to mount the informer into.
+
+### `countdown: false`
+
+When taking a picture: the amount of seconds to wait before actually taking a snapshot. If `false` or 0, the timeout is disabled entirely.
+This also shows a 'Smile!' message in the [Informer](/docs/informer) before the picture is taken.
+
+### `onBeforeSnapshot: () => Promise.resolve()`
+
+A hook function to call before a snapshot is taken. The Webcam plugin will wait for the returned Promise to resolve before taking the snapshot. This can be used to implement variations on the `countdown` option for example.
+
+### `modes: []`
+
+The types of recording modes to allow.
+
+ - `video-audio` - Record a video file, capturing both audio and video.
+ - `video-only` - Record a video file with the webcam, but don't record audio.
+ - `audio-only` - Record an audio file with the user's microphone.
+ - `picture` - Take a picture with the webcam.
+
+By default, all modes are allowed, and the Webcam plugin will show controls for recording video as well as taking pictures.
+
+### `locale: {}`
+
+There is only one localizable string: `strings.smile`. It's shown before a picture is taken, when the `countdown` option is set to true.

+ 2 - 2
website/src/examples/xhrupload/index.ejs

@@ -28,9 +28,9 @@ Uppy recommends using tus resumable file uploads, but if you want you can also u
 <p>
 <p>
   On this page we're using the following HTML snippet:
   On this page we're using the following HTML snippet:
 </p>
 </p>
-{% include_code lang:html multipart/app.html %}
+{% include_code lang:html xhrupload/app.html %}
 
 
 <p>
 <p>
   Along with this JavaScript:
   Along with this JavaScript:
 </p>
 </p>
-{% include_code lang:js multipart/app.es6 %}
+{% include_code lang:js xhrupload/app.es6 %}