|
@@ -1,10 +1,10 @@
|
|
|
+// eslint-disable-next-line max-classes-per-file
|
|
|
const tus = require('tus-js-client')
|
|
|
const uuid = require('uuid')
|
|
|
const isObject = require('isobject')
|
|
|
const validator = require('validator')
|
|
|
const request = require('request')
|
|
|
-// eslint-disable-next-line no-unused-vars
|
|
|
-const { Readable, pipeline: pipelineCb } = require('stream')
|
|
|
+const { pipeline: pipelineCb } = require('stream')
|
|
|
const { join } = require('path')
|
|
|
const fs = require('fs')
|
|
|
const { promisify } = require('util')
|
|
@@ -40,8 +40,21 @@ function exceedsMaxFileSize (maxFileSize, size) {
|
|
|
return maxFileSize && size && size > maxFileSize
|
|
|
}
|
|
|
|
|
|
+// TODO remove once we migrate away from form-data
|
|
|
+function sanitizeMetadata (inputMetadata) {
|
|
|
+ if (inputMetadata == null) return {}
|
|
|
+
|
|
|
+ const outputMetadata = {}
|
|
|
+ Object.keys(inputMetadata).forEach((key) => {
|
|
|
+ outputMetadata[key] = String(inputMetadata[key])
|
|
|
+ })
|
|
|
+ return outputMetadata
|
|
|
+}
|
|
|
+
|
|
|
class AbortError extends Error {}
|
|
|
|
|
|
+class ValidationError extends Error {}
|
|
|
+
|
|
|
class Uploader {
|
|
|
/**
|
|
|
* Uploads file to destination based on the supplied protocol (tus, s3-multipart, multipart)
|
|
@@ -67,15 +80,12 @@ class Uploader {
|
|
|
* @param {UploaderOptions} options
|
|
|
*/
|
|
|
constructor (options) {
|
|
|
- if (!this.validateOptions(options)) {
|
|
|
- logger.debug(this._errRespMessage, 'uploader.validator.fail')
|
|
|
- return
|
|
|
- }
|
|
|
+ this.validateOptions(options)
|
|
|
|
|
|
this.options = options
|
|
|
this.token = uuid.v4()
|
|
|
this.fileName = `${Uploader.FILE_NAME_PREFIX}-${this.token}`
|
|
|
- this.options.metadata = this.options.metadata || {}
|
|
|
+ this.options.metadata = sanitizeMetadata(this.options.metadata)
|
|
|
this.options.fieldname = this.options.fieldname || DEFAULT_FIELD_NAME
|
|
|
this.size = options.size
|
|
|
this.uploadFileName = this.options.metadata.name
|
|
@@ -173,7 +183,7 @@ class Uploader {
|
|
|
|
|
|
/**
|
|
|
*
|
|
|
- * @param {Readable} stream
|
|
|
+ * @param {import('stream').Readable} stream
|
|
|
*/
|
|
|
async uploadStream (stream) {
|
|
|
try {
|
|
@@ -188,13 +198,30 @@ class Uploader {
|
|
|
// The stream will then typically come from a "Transfer-Encoding: chunked" response
|
|
|
await this._downloadStreamAsFile(this.readStream)
|
|
|
}
|
|
|
- if (this.uploadStopped) return
|
|
|
+ if (this.uploadStopped) return undefined
|
|
|
|
|
|
const { url, extraData } = await Promise.race([
|
|
|
this._uploadByProtocol(),
|
|
|
// If we don't handle stream errors, we get unhandled error in node.
|
|
|
new Promise((resolve, reject) => this.readStream.on('error', reject)),
|
|
|
])
|
|
|
+ return { url, extraData }
|
|
|
+ } finally {
|
|
|
+ logger.debug('cleanup', this.shortToken)
|
|
|
+ if (this.readStream && !this.readStream.destroyed) this.readStream.destroy()
|
|
|
+ if (this.tmpPath) unlink(this.tmpPath).catch(() => {})
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ *
|
|
|
+ * @param {import('stream').Readable} stream
|
|
|
+ */
|
|
|
+ async tryUploadStream (stream) {
|
|
|
+ try {
|
|
|
+ const ret = await this.uploadStream(stream)
|
|
|
+ if (!ret) return
|
|
|
+ const { url, extraData } = ret
|
|
|
this.emitSuccess(url, extraData)
|
|
|
} catch (err) {
|
|
|
if (err instanceof AbortError) {
|
|
@@ -205,7 +232,9 @@ class Uploader {
|
|
|
logger.error(err, 'uploader.error', this.shortToken)
|
|
|
this.emitError(err)
|
|
|
} finally {
|
|
|
- this.cleanUp()
|
|
|
+ emitter().removeAllListeners(`pause:${this.token}`)
|
|
|
+ emitter().removeAllListeners(`resume:${this.token}`)
|
|
|
+ emitter().removeAllListeners(`cancel:${this.token}`)
|
|
|
}
|
|
|
}
|
|
|
|
|
@@ -250,51 +279,43 @@ class Uploader {
|
|
|
* Validate the options passed down to the uplaoder
|
|
|
*
|
|
|
* @param {UploaderOptions} options
|
|
|
- * @returns {boolean}
|
|
|
*/
|
|
|
validateOptions (options) {
|
|
|
// validate HTTP Method
|
|
|
if (options.httpMethod) {
|
|
|
if (typeof options.httpMethod !== 'string') {
|
|
|
- this._errRespMessage = 'unsupported HTTP METHOD specified'
|
|
|
- return false
|
|
|
+ throw new ValidationError('unsupported HTTP METHOD specified')
|
|
|
}
|
|
|
|
|
|
const method = options.httpMethod.toLowerCase()
|
|
|
if (method !== 'put' && method !== 'post') {
|
|
|
- this._errRespMessage = 'unsupported HTTP METHOD specified'
|
|
|
- return false
|
|
|
+ throw new ValidationError('unsupported HTTP METHOD specified')
|
|
|
}
|
|
|
}
|
|
|
|
|
|
if (exceedsMaxFileSize(options.companionOptions.maxFileSize, options.size)) {
|
|
|
- this._errRespMessage = 'maxFileSize exceeded'
|
|
|
- return false
|
|
|
+ throw new ValidationError('maxFileSize exceeded')
|
|
|
}
|
|
|
|
|
|
// validate fieldname
|
|
|
if (options.fieldname && typeof options.fieldname !== 'string') {
|
|
|
- this._errRespMessage = 'fieldname must be a string'
|
|
|
- return false
|
|
|
+ throw new ValidationError('fieldname must be a string')
|
|
|
}
|
|
|
|
|
|
// validate metadata
|
|
|
- if (options.metadata && !isObject(options.metadata)) {
|
|
|
- this._errRespMessage = 'metadata must be an object'
|
|
|
- return false
|
|
|
+ if (options.metadata != null) {
|
|
|
+ if (!isObject(options.metadata)) throw new ValidationError('metadata must be an object')
|
|
|
}
|
|
|
|
|
|
// validate headers
|
|
|
if (options.headers && !isObject(options.headers)) {
|
|
|
- this._errRespMessage = 'headers must be an object'
|
|
|
- return false
|
|
|
+ throw new ValidationError('headers must be an object')
|
|
|
}
|
|
|
|
|
|
// validate protocol
|
|
|
// @todo this validation should not be conditional once the protocol field is mandatory
|
|
|
if (options.protocol && !Object.keys(PROTOCOLS).some((key) => PROTOCOLS[key] === options.protocol)) {
|
|
|
- this._errRespMessage = 'unsupported protocol specified'
|
|
|
- return false
|
|
|
+ throw new ValidationError('unsupported protocol specified')
|
|
|
}
|
|
|
|
|
|
// s3 uploads don't require upload destination
|
|
@@ -302,39 +323,27 @@ class Uploader {
|
|
|
// by the server's s3 config
|
|
|
if (options.protocol !== PROTOCOLS.s3Multipart) {
|
|
|
if (!options.endpoint && !options.uploadUrl) {
|
|
|
- this._errRespMessage = 'no destination specified'
|
|
|
- return false
|
|
|
+ throw new ValidationError('no destination specified')
|
|
|
}
|
|
|
|
|
|
const validateUrl = (url) => {
|
|
|
const validatorOpts = { require_protocol: true, require_tld: false }
|
|
|
if (url && !validator.isURL(url, validatorOpts)) {
|
|
|
- this._errRespMessage = 'invalid destination url'
|
|
|
- return false
|
|
|
+ throw new ValidationError('invalid destination url')
|
|
|
}
|
|
|
|
|
|
const allowedUrls = options.companionOptions.uploadUrls
|
|
|
if (allowedUrls && url && !hasMatch(url, allowedUrls)) {
|
|
|
- this._errRespMessage = 'upload destination does not match any allowed destinations'
|
|
|
- return false
|
|
|
+ throw new ValidationError('upload destination does not match any allowed destinations')
|
|
|
}
|
|
|
-
|
|
|
- return true
|
|
|
}
|
|
|
|
|
|
- if (![options.endpoint, options.uploadUrl].every(validateUrl)) return false
|
|
|
+ [options.endpoint, options.uploadUrl].forEach(validateUrl)
|
|
|
}
|
|
|
|
|
|
if (options.chunkSize != null && typeof options.chunkSize !== 'number') {
|
|
|
- this._errRespMessage = 'incorrect chunkSize'
|
|
|
- return false
|
|
|
+ throw new ValidationError('incorrect chunkSize')
|
|
|
}
|
|
|
-
|
|
|
- return true
|
|
|
- }
|
|
|
-
|
|
|
- hasError () {
|
|
|
- return this._errRespMessage != null
|
|
|
}
|
|
|
|
|
|
/**
|
|
@@ -353,24 +362,6 @@ class Uploader {
|
|
|
logger.debug('socket connection received', 'uploader.socket.wait', this.shortToken)
|
|
|
}
|
|
|
|
|
|
- cleanUp () {
|
|
|
- logger.debug('cleanup', this.shortToken)
|
|
|
- if (this.readStream && !this.readStream.destroyed) this.readStream.destroy()
|
|
|
-
|
|
|
- if (this.tmpPath) unlink(this.tmpPath).catch(() => {})
|
|
|
-
|
|
|
- emitter().removeAllListeners(`pause:${this.token}`)
|
|
|
- emitter().removeAllListeners(`resume:${this.token}`)
|
|
|
- emitter().removeAllListeners(`cancel:${this.token}`)
|
|
|
- }
|
|
|
-
|
|
|
- getResponse () {
|
|
|
- if (this._errRespMessage) {
|
|
|
- return { body: { message: this._errRespMessage }, status: 400 }
|
|
|
- }
|
|
|
- return { body: { token: this.token }, status: 200 }
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* @typedef {{action: string, payload: object}} State
|
|
|
* @param {State} state
|
|
@@ -649,3 +640,4 @@ Uploader.FILE_NAME_PREFIX = 'uppy-file'
|
|
|
Uploader.STORAGE_PREFIX = 'companion'
|
|
|
|
|
|
module.exports = Uploader
|
|
|
+module.exports.ValidationError = ValidationError
|