Browse Source

Merge pull request #222 from transloadit/feature/restrictions

[WIP] File restrictions: by file type, size, number of files
Artur Paikin 7 years ago
parent
commit
531a3e1a09

+ 20 - 4
examples/bundled-example/main.js

@@ -23,8 +23,23 @@ const TUS_ENDPOINT = PROTOCOL + '://master.tus.io/files/'
 const uppy = Uppy({
   debug: true,
   autoProceed: false,
-  meta: {
-    username: 'John'
+  restrictions: {
+    maxFileSize: 300000,
+    maxNumberOfFiles: 5,
+    minNumberOfFiles: 2,
+    allowedFileTypes: ['image/*', 'video/*']
+  },
+  onBeforeFileAdded: (currentFile, files) => {
+    if (currentFile.name === 'pitercss-IMG_0616.jpg') {
+      return Promise.resolve()
+    }
+    return Promise.reject('this is not the file I was looking for')
+  },
+  onBeforeUpload: (files) => {
+    if (Object.keys(files).length < 2) {
+      return Promise.reject('too few files')
+    }
+    return Promise.resolve()
   }
 })
   .use(Dashboard, {
@@ -38,8 +53,9 @@ const uppy = Uppy({
     // replaceTargetContent: true,
     target: '.MyForm',
     locale: {
-      strings: { browse: 'wow' }
-    }
+      strings: {browse: 'wow'}
+    },
+    note: 'Images and video only, 300kb or less'
   })
   .use(GoogleDrive, {target: Dashboard, host: 'http://localhost:3020'})
   .use(Dropbox, {target: Dashboard, host: 'http://localhost:3020'})

+ 10 - 0
package-lock.json

@@ -5220,6 +5220,11 @@
       "integrity": "sha1-gg9XIpa70g7CXtVeW13oaeVDbrE=",
       "dev": true
     },
+    "mime-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
+      "integrity": "sha1-P4fDHprxpf1IX7nbE0Qosju7e6g="
+    },
     "mime-types": {
       "version": "2.1.15",
       "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.15.tgz",
@@ -8223,6 +8228,11 @@
       "integrity": "sha512-ijDLlyQ7s6x1JgCLur53osjm/UXUYD9+0PbYKrBsYisYXzCxN+HC3mYDNy/dWdmf3AwqwU3CXwDCvsNgGK1S0w==",
       "dev": true
     },
+    "wildcard": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
+      "integrity": "sha1-pwIEUwhNjNLv5wup02liY94XEKU="
+    },
     "win-spawn": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/win-spawn/-/win-spawn-2.0.0.tgz",

+ 1 - 0
package.json

@@ -78,6 +78,7 @@
     "es6-promise": "3.2.1",
     "get-form-data": "^1.2.5",
     "lodash.throttle": "4.1.1",
+    "mime-match": "^1.0.2",
     "namespace-emitter": "1.0.0",
     "nanoraf": "3.0.1",
     "on-load": "3.2.0",

+ 166 - 72
src/core/Core.js

@@ -3,6 +3,8 @@ const Translator = require('../core/Translator')
 const UppySocket = require('./UppySocket')
 const ee = require('namespace-emitter')
 const throttle = require('lodash.throttle')
+const prettyBytes = require('prettier-bytes')
+const match = require('mime-match')
 // const en_US = require('../locales/en_US')
 // const deepFreeze = require('deep-freeze-strict')
 
@@ -13,12 +15,36 @@ const throttle = require('lodash.throttle')
  */
 class Uppy {
   constructor (opts) {
+    const defaultLocale = {
+      strings: {
+        youCanOnlyUploadX: {
+          0: 'You can only upload %{smart_count} file',
+          1: 'You can only upload %{smart_count} files'
+        },
+        youHaveToAtLeastSelectX: {
+          0: 'You have to select at least %{smart_count} file',
+          1: 'You have to select at least %{smart_count} files'
+        },
+        exceedsSize: 'This file exceeds maximum allowed size of',
+        youCanOnlyUploadFileTypes: 'You can only upload:'
+      }
+    }
+
     // set default options
     const defaultOptions = {
       // load English as the default locale
       // locale: en_US,
       autoProceed: true,
-      debug: false
+      debug: false,
+      restrictions: {
+        maxFileSize: false,
+        maxNumberOfFiles: false,
+        minNumberOfFiles: false,
+        allowedFileTypes: false
+      },
+      onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
+      onBeforeUpload: (files, done) => Promise.resolve(),
+      locale: defaultLocale
     }
 
     // Merge default options with the ones set by user
@@ -28,6 +54,13 @@ class Uppy {
     // this.types = [ 'presetter', 'orchestrator', 'progressindicator',
     //                 'acquirer', 'modifier', 'uploader', 'presenter', 'debugger']
 
+    this.locale = Object.assign({}, defaultLocale, this.opts.locale)
+    this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
+
+    // i18n
+    this.translator = new Translator({locale: this.locale})
+    this.i18n = this.translator.translate.bind(this.translator)
+
     // Container for different types of plugins
     this.plugins = {}
 
@@ -159,59 +192,108 @@ class Uppy {
     this.setState({files: updatedFiles})
   }
 
-  addFile (file) {
-    Utils.getFileType(file).then((fileType) => {
-      const updatedFiles = Object.assign({}, this.state.files)
-      const fileName = file.name || 'noname'
-      const fileExtension = Utils.getFileNameAndExtension(fileName)[1]
-      const isRemote = file.isRemote || false
-
-      const fileID = Utils.generateFileID(fileName)
-      const fileTypeGeneral = fileType[0]
-      const fileTypeSpecific = fileType[1]
-
-      const newFile = {
-        source: file.source || '',
-        id: fileID,
-        name: fileName,
-        extension: fileExtension || '',
-        meta: Object.assign({}, { name: fileName }, this.getState().meta),
-        type: {
-          general: fileTypeGeneral,
-          specific: fileTypeSpecific
-        },
-        data: file.data,
-        progress: {
-          percentage: 0,
-          bytesUploaded: 0,
-          bytesTotal: file.data.size || 0,
-          uploadComplete: false,
-          uploadStarted: false
-        },
-        size: file.data.size || 'N/A',
-        isRemote: isRemote,
-        remote: file.remote || '',
-        preview: file.preview
-      }
+  checkRestrictions (checkMinNumberOfFiles, file, fileType) {
+    const {maxFileSize, maxNumberOfFiles, minNumberOfFiles, allowedFileTypes} = this.opts.restrictions
 
-      if (Utils.isPreviewSupported(fileTypeSpecific) && !isRemote) {
-        newFile.preview = Utils.getThumbnail(file)
+    if (checkMinNumberOfFiles && minNumberOfFiles) {
+      console.log(Object.keys(this.state.files).length)
+      if (Object.keys(this.state.files).length < minNumberOfFiles) {
+        this.emit('informer', `${this.i18n('youHaveToAtLeastSelectX', {smart_count: minNumberOfFiles})}`, 'error', 5000)
+        return false
       }
+      return true
+    }
 
-      updatedFiles[fileID] = newFile
-      this.setState({files: updatedFiles})
+    if (maxNumberOfFiles) {
+      if (Object.keys(this.state.files).length + 1 > maxNumberOfFiles) {
+        this.emit('informer', `${this.i18n('youCanOnlyUploadX', {smart_count: maxNumberOfFiles})}`, 'error', 5000)
+        return false
+      }
+    }
 
-      this.bus.emit('core:file-added', fileID)
-      this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
+    if (allowedFileTypes) {
+      const isCorrectFileType = allowedFileTypes.filter(match(fileType.join('/'))).length > 0
+      if (!isCorrectFileType) {
+        const allowedFileTypesString = allowedFileTypes.join(', ')
+        this.emit('informer', `${this.i18n('youCanOnlyUploadFileTypes')} ${allowedFileTypesString}`, 'error', 5000)
+        return false
+      }
+    }
 
-      if (this.opts.autoProceed && !this.scheduledAutoProceed) {
-        this.scheduledAutoProceed = setTimeout(() => {
-          this.scheduledAutoProceed = null
-          this.upload().catch((err) => {
-            console.error(err.stack || err.message)
-          })
-        }, 4)
+    if (maxFileSize) {
+      if (file.data.size > maxFileSize) {
+        this.emit('informer', `${this.i18n('exceedsSize')} ${prettyBytes(maxFileSize)}`, 'error', 5000)
+        return false
       }
+    }
+
+    return true
+  }
+
+  addFile (file) {
+    return this.opts.onBeforeFileAdded(file, this.getState().files).then(() => {
+      return Utils.getFileType(file).then((fileType) => {
+        const updatedFiles = Object.assign({}, this.state.files)
+        const fileName = file.name || 'noname'
+        const fileExtension = Utils.getFileNameAndExtension(fileName)[1]
+        const isRemote = file.isRemote || false
+
+        const fileID = Utils.generateFileID(fileName)
+        const fileTypeGeneral = fileType[0]
+        const fileTypeSpecific = fileType[1]
+
+        const newFile = {
+          source: file.source || '',
+          id: fileID,
+          name: fileName,
+          extension: fileExtension || '',
+          meta: {
+            name: fileName
+          },
+          type: {
+            general: fileTypeGeneral,
+            specific: fileTypeSpecific
+          },
+          data: file.data,
+          progress: {
+            percentage: 0,
+            bytesUploaded: 0,
+            bytesTotal: file.data.size || 0,
+            uploadComplete: false,
+            uploadStarted: false
+          },
+          size: file.data.size || 'N/A',
+          isRemote: isRemote,
+          remote: file.remote || '',
+          preview: file.preview
+        }
+
+        if (Utils.isPreviewSupported(fileTypeSpecific) && !isRemote) {
+          newFile.preview = Utils.getThumbnail(file)
+        }
+
+        const isFileAllowed = this.checkRestrictions(false, newFile, fileType)
+        if (!isFileAllowed) return
+
+        updatedFiles[fileID] = newFile
+        this.setState({files: updatedFiles})
+
+        this.bus.emit('file-added', fileID)
+        this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
+
+        if (this.opts.autoProceed && !this.scheduledAutoProceed) {
+          this.scheduledAutoProceed = setTimeout(() => {
+            this.scheduledAutoProceed = null
+            this.upload().catch((err) => {
+              console.error(err.stack || err.message)
+            })
+          }, 4)
+        }
+      })
+    })
+    .catch((err) => {
+      this.emit('informer', err, 'error', 5000)
+      return Promise.reject(`onBeforeFileAdded: ${err}`)
     })
   }
 
@@ -585,33 +667,45 @@ class Uppy {
   }
 
   upload () {
-    this.emit('core:upload')
-
-    const waitingFileIDs = []
-    Object.keys(this.state.files).forEach((fileID) => {
-      const file = this.state.files[fileID]
-      // TODO: replace files[file].isRemote with some logic
-      //
-      // filter files that are now yet being uploaded / haven’t been uploaded
-      // and remote too
-      if (!file.progress.uploadStarted || file.isRemote) {
-        waitingFileIDs.push(file.id)
-      }
-    })
+    const isMinNumberOfFilesReached = this.checkRestrictions(true)
+    if (!isMinNumberOfFilesReached) {
+      return Promise.reject('Minimum number of files has not been reached')
+    }
+
+    return this.opts.onBeforeUpload(this.getState().files).then(() => {
+      this.emit('core:upload')
+
+      const waitingFileIDs = []
+      Object.keys(this.state.files).forEach((fileID) => {
+        const file = this.state.files[fileID]
+        // TODO: replace files[file].isRemote with some logic
+        //
+        // filter files that are now yet being uploaded / haven’t been uploaded
+        // and remote too
+        if (!file.progress.uploadStarted || file.isRemote) {
+          waitingFileIDs.push(file.id)
+        }
+      })
 
-    const promise = Utils.runPromiseSequence(
-      [...this.preProcessors, ...this.uploaders, ...this.postProcessors],
-      waitingFileIDs
-    )
+      const promise = Utils.runPromiseSequence(
+        [...this.preProcessors, ...this.uploaders, ...this.postProcessors],
+        waitingFileIDs
+      )
 
-    // Not returning the `catch`ed promise, because we still want to return a rejected
-    // promise from this method if the upload failed.
-    promise.catch((err) => {
-      this.emit('core:error', err)
-    })
+      // Not returning the `catch`ed promise, because we still want to return a rejected
+      // promise from this method if the upload failed.
+      promise.catch((err) => {
+        this.emit('core:error', err)
+      })
 
-    return promise.then(() => {
-      this.emit('core:success')
+      return promise.then(() => {
+        // return number of uploaded files
+        this.emit('core:success', waitingFileIDs)
+      })
+    })
+    .catch((err) => {
+      this.emit('informer', err, 'error', 5000)
+      return Promise.reject(`onBeforeUpload: ${err}`)
     })
   }
 }

+ 1 - 0
src/plugins/Dashboard/Dashboard.js

@@ -102,6 +102,7 @@ module.exports = function Dashboard (props) {
             totalProgress: props.totalProgress,
             totalFileCount: props.totalFileCount,
             info: props.info,
+            note: props.note,
             i18n: props.i18n,
             log: props.log,
             removeFile: props.removeFile,

+ 4 - 0
src/plugins/Dashboard/FileList.js

@@ -16,6 +16,10 @@ module.exports = (props) => {
               i18n: props.i18n
             })}
           </h3>
+          ${props.note
+            ? html`<p class="UppyDashboard-note">${props.note}</p>`
+            : ''
+          }
           <input class="UppyDashboard-input" type="file" name="files[]" multiple="true"
                  onchange=${props.handleInputChange} />
          </div>`

+ 3 - 2
src/plugins/Dashboard/index.js

@@ -49,7 +49,7 @@ module.exports = class DashboardUI extends Plugin {
       semiTransparent: false,
       defaultTabIcon: defaultTabIcon(),
       showProgressDetails: false,
-      setMetaFromTargetForm: true,
+      note: false,
       locale: defaultLocale
     }
 
@@ -316,7 +316,7 @@ module.exports = class DashboardUI extends Plugin {
     const startUpload = (ev) => {
       this.core.upload().catch((err) => {
         // Log error.
-        console.error(err.stack || err.message)
+        console.error(err.stack || err.message || err)
       })
     }
 
@@ -369,6 +369,7 @@ module.exports = class DashboardUI extends Plugin {
       addFile: this.core.addFile,
       removeFile: removeFile,
       info: info,
+      note: this.opts.note,
       metaFields: state.metaFields,
       resumableUploads: resumableUploads,
       startUpload: startUpload,

+ 12 - 1
src/scss/_dashboard.scss

@@ -423,6 +423,18 @@
   }
 }
 
+.UppyDashboard-note {
+  font-size: 12px;
+  line-height: 1.2;
+  text-align: center;
+  margin-top: 20px;
+  color: $color-asphalt-gray;
+
+  .UppyDashboard--wide & {
+    font-size: 13px;
+  }
+}
+
 .UppyDashboardItem {
   list-style: none;
   margin: 10px 0;
@@ -556,7 +568,6 @@
     border-bottom-right-radius: 6px;
     border: 1px solid rgba($color-gray, 0.2);
     border-top: 0;
-    // height: 60px;
   }
 }