Uploader.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689
  1. // eslint-disable-next-line max-classes-per-file
  2. const tus = require('tus-js-client')
  3. const uuid = require('uuid')
  4. const isObject = require('isobject')
  5. const validator = require('validator')
  6. const request = require('request')
  7. const { pipeline: pipelineCb } = require('stream')
  8. const { join } = require('path')
  9. const fs = require('fs')
  10. const { promisify } = require('util')
  11. // TODO move to `require('streams/promises').pipeline` when dropping support for Node.js 14.x.
  12. const pipeline = promisify(pipelineCb)
  13. const { createReadStream, createWriteStream, ReadStream } = fs
  14. const { stat, unlink } = fs.promises
  15. /** @type {any} */
  16. // @ts-ignore - typescript resolves this this to a hoisted version of
  17. // serialize-error that ships with a declaration file, we are using a version
  18. // here that does not have a declaration file
  19. const serializeError = require('serialize-error')
  20. const emitter = require('./emitter')
  21. const { jsonStringify, hasMatch } = require('./helpers/utils')
  22. const logger = require('./logger')
  23. const headerSanitize = require('./header-blacklist')
  24. const redis = require('./redis')
  25. // Need to limit length or we can get
  26. // "MetadataTooLarge: Your metadata headers exceed the maximum allowed metadata size" in tus / S3
  27. const MAX_FILENAME_LENGTH = 500
  28. const DEFAULT_FIELD_NAME = 'files[]'
  29. const PROTOCOLS = Object.freeze({
  30. multipart: 'multipart',
  31. s3Multipart: 's3-multipart',
  32. tus: 'tus',
  33. })
  34. function exceedsMaxFileSize (maxFileSize, size) {
  35. return maxFileSize && size && size > maxFileSize
  36. }
  37. // TODO remove once we migrate away from form-data
  38. function sanitizeMetadata (inputMetadata) {
  39. if (inputMetadata == null) return {}
  40. const outputMetadata = {}
  41. Object.keys(inputMetadata).forEach((key) => {
  42. outputMetadata[key] = String(inputMetadata[key])
  43. })
  44. return outputMetadata
  45. }
  46. class AbortError extends Error {}
  47. class ValidationError extends Error {}
  48. /**
  49. * Validate the options passed down to the uplaoder
  50. *
  51. * @param {UploaderOptions} options
  52. */
  53. function validateOptions (options) {
  54. // validate HTTP Method
  55. if (options.httpMethod) {
  56. if (typeof options.httpMethod !== 'string') {
  57. throw new ValidationError('unsupported HTTP METHOD specified')
  58. }
  59. const method = options.httpMethod.toLowerCase()
  60. if (method !== 'put' && method !== 'post') {
  61. throw new ValidationError('unsupported HTTP METHOD specified')
  62. }
  63. }
  64. if (exceedsMaxFileSize(options.companionOptions.maxFileSize, options.size)) {
  65. throw new ValidationError('maxFileSize exceeded')
  66. }
  67. // validate fieldname
  68. if (options.fieldname && typeof options.fieldname !== 'string') {
  69. throw new ValidationError('fieldname must be a string')
  70. }
  71. // validate metadata
  72. if (options.metadata != null) {
  73. if (!isObject(options.metadata)) throw new ValidationError('metadata must be an object')
  74. }
  75. // validate headers
  76. if (options.headers && !isObject(options.headers)) {
  77. throw new ValidationError('headers must be an object')
  78. }
  79. // validate protocol
  80. // @todo this validation should not be conditional once the protocol field is mandatory
  81. if (options.protocol && !Object.keys(PROTOCOLS).some((key) => PROTOCOLS[key] === options.protocol)) {
  82. throw new ValidationError('unsupported protocol specified')
  83. }
  84. // s3 uploads don't require upload destination
  85. // validation, because the destination is determined
  86. // by the server's s3 config
  87. if (options.protocol !== PROTOCOLS.s3Multipart) {
  88. if (!options.endpoint && !options.uploadUrl) {
  89. throw new ValidationError('no destination specified')
  90. }
  91. const validateUrl = (url) => {
  92. const validatorOpts = { require_protocol: true, require_tld: false }
  93. if (url && !validator.isURL(url, validatorOpts)) {
  94. throw new ValidationError('invalid destination url')
  95. }
  96. const allowedUrls = options.companionOptions.uploadUrls
  97. if (allowedUrls && url && !hasMatch(url, allowedUrls)) {
  98. throw new ValidationError('upload destination does not match any allowed destinations')
  99. }
  100. }
  101. [options.endpoint, options.uploadUrl].forEach(validateUrl)
  102. }
  103. if (options.chunkSize != null && typeof options.chunkSize !== 'number') {
  104. throw new ValidationError('incorrect chunkSize')
  105. }
  106. }
  107. class Uploader {
  108. /**
  109. * Uploads file to destination based on the supplied protocol (tus, s3-multipart, multipart)
  110. * For tus uploads, the deferredLength option is enabled, because file size value can be unreliable
  111. * for some providers (Instagram particularly)
  112. *
  113. * @typedef {object} UploaderOptions
  114. * @property {string} endpoint
  115. * @property {string} [uploadUrl]
  116. * @property {string} protocol
  117. * @property {number} [size]
  118. * @property {string} [fieldname]
  119. * @property {string} pathPrefix
  120. * @property {any} [s3]
  121. * @property {any} metadata
  122. * @property {any} companionOptions
  123. * @property {any} [storage]
  124. * @property {any} [headers]
  125. * @property {string} [httpMethod]
  126. * @property {boolean} [useFormData]
  127. * @property {number} [chunkSize]
  128. *
  129. * @param {UploaderOptions} options
  130. */
  131. constructor (options) {
  132. validateOptions(options)
  133. this.options = options
  134. this.token = uuid.v4()
  135. this.fileName = `${Uploader.FILE_NAME_PREFIX}-${this.token}`
  136. this.options.metadata = sanitizeMetadata(this.options.metadata)
  137. this.options.fieldname = this.options.fieldname || DEFAULT_FIELD_NAME
  138. this.size = options.size
  139. this.uploadFileName = this.options.metadata.name
  140. ? this.options.metadata.name.substring(0, MAX_FILENAME_LENGTH)
  141. : this.fileName
  142. this.uploadStopped = false
  143. this.emittedProgress = {}
  144. this.storage = options.storage
  145. this._paused = false
  146. this.downloadedBytes = 0
  147. this.readStream = null
  148. if (this.options.protocol === PROTOCOLS.tus) {
  149. emitter().on(`pause:${this.token}`, () => {
  150. logger.debug('Received from client: pause', 'uploader', this.shortToken)
  151. this._paused = true
  152. if (this.tus) {
  153. this.tus.abort()
  154. }
  155. })
  156. emitter().on(`resume:${this.token}`, () => {
  157. logger.debug('Received from client: resume', 'uploader', this.shortToken)
  158. this._paused = false
  159. if (this.tus) {
  160. this.tus.start()
  161. }
  162. })
  163. }
  164. emitter().on(`cancel:${this.token}`, () => {
  165. logger.debug('Received from client: cancel', 'uploader', this.shortToken)
  166. this._paused = true
  167. if (this.tus) {
  168. const shouldTerminate = !!this.tus.url
  169. this.tus.abort(shouldTerminate).catch(() => {})
  170. }
  171. this.abortReadStream(new AbortError())
  172. })
  173. }
  174. abortReadStream (err) {
  175. this.uploadStopped = true
  176. if (this.readStream) this.readStream.destroy(err)
  177. }
  178. async _uploadByProtocol () {
  179. // @todo a default protocol should not be set. We should ensure that the user specifies their protocol.
  180. const protocol = this.options.protocol || PROTOCOLS.multipart
  181. switch (protocol) {
  182. case PROTOCOLS.multipart:
  183. return this._uploadMultipart(this.readStream)
  184. case PROTOCOLS.s3Multipart:
  185. return this._uploadS3Multipart(this.readStream)
  186. case PROTOCOLS.tus:
  187. return this._uploadTus(this.readStream)
  188. default:
  189. throw new Error('Invalid protocol')
  190. }
  191. }
  192. async _downloadStreamAsFile (stream) {
  193. this.tmpPath = join(this.options.pathPrefix, this.fileName)
  194. logger.debug('fully downloading file', 'uploader.download', this.shortToken)
  195. const writeStream = createWriteStream(this.tmpPath)
  196. const onData = (chunk) => {
  197. this.downloadedBytes += chunk.length
  198. if (exceedsMaxFileSize(this.options.companionOptions.maxFileSize, this.downloadedBytes)) this.abortReadStream(new Error('maxFileSize exceeded'))
  199. this.onProgress(0, undefined)
  200. }
  201. stream.on('data', onData)
  202. await pipeline(stream, writeStream)
  203. logger.debug('finished fully downloading file', 'uploader.download', this.shortToken)
  204. const { size } = await stat(this.tmpPath)
  205. this.size = size
  206. const fileStream = createReadStream(this.tmpPath)
  207. this.readStream = fileStream
  208. }
  209. _needDownloadFirst () {
  210. return !this.options.size || !this.options.companionOptions.streamingUpload
  211. }
  212. /**
  213. *
  214. * @param {import('stream').Readable} stream
  215. */
  216. async uploadStream (stream) {
  217. try {
  218. if (this.uploadStopped) throw new Error('Cannot upload stream after upload stopped')
  219. if (this.readStream) throw new Error('Already uploading')
  220. this.readStream = stream
  221. if (this._needDownloadFirst()) {
  222. logger.debug('need to download the whole file first', 'controller.get.provider.size', this.shortToken)
  223. // Some streams need to be downloaded entirely first, because we don't know their size from the provider
  224. // This is true for zoom and drive (exported files) or some URL downloads.
  225. // The stream will then typically come from a "Transfer-Encoding: chunked" response
  226. await this._downloadStreamAsFile(this.readStream)
  227. }
  228. if (this.uploadStopped) return undefined
  229. const { url, extraData } = await Promise.race([
  230. this._uploadByProtocol(),
  231. // If we don't handle stream errors, we get unhandled error in node.
  232. new Promise((resolve, reject) => this.readStream.on('error', reject)),
  233. ])
  234. return { url, extraData }
  235. } finally {
  236. logger.debug('cleanup', this.shortToken)
  237. if (this.readStream && !this.readStream.destroyed) this.readStream.destroy()
  238. if (this.tmpPath) unlink(this.tmpPath).catch(() => {})
  239. }
  240. }
  241. /**
  242. *
  243. * @param {import('stream').Readable} stream
  244. */
  245. async tryUploadStream (stream) {
  246. try {
  247. emitter().emit('upload-start', { token: this.token })
  248. const ret = await this.uploadStream(stream)
  249. if (!ret) return
  250. const { url, extraData } = ret
  251. this.emitSuccess(url, extraData)
  252. } catch (err) {
  253. if (err instanceof AbortError) {
  254. logger.error('Aborted upload', 'uploader.aborted', this.shortToken)
  255. return
  256. }
  257. // console.log(err)
  258. logger.error(err, 'uploader.error', this.shortToken)
  259. this.emitError(err)
  260. } finally {
  261. emitter().removeAllListeners(`pause:${this.token}`)
  262. emitter().removeAllListeners(`resume:${this.token}`)
  263. emitter().removeAllListeners(`cancel:${this.token}`)
  264. }
  265. }
  266. /**
  267. * returns a substring of the token. Used as traceId for logging
  268. * we avoid using the entire token because this is meant to be a short term
  269. * access token between uppy client and companion websocket
  270. *
  271. * @param {string} token the token to Shorten
  272. * @returns {string}
  273. */
  274. static shortenToken (token) {
  275. return token.substring(0, 8)
  276. }
  277. static reqToOptions (req, size) {
  278. const useFormDataIsSet = Object.prototype.hasOwnProperty.call(req.body, 'useFormData')
  279. const useFormData = useFormDataIsSet ? req.body.useFormData : true
  280. return {
  281. // Client provided info (must be validated and not blindly trusted):
  282. headers: req.body.headers,
  283. httpMethod: req.body.httpMethod,
  284. protocol: req.body.protocol,
  285. endpoint: req.body.endpoint,
  286. uploadUrl: req.body.uploadUrl,
  287. metadata: req.body.metadata,
  288. fieldname: req.body.fieldname,
  289. useFormData,
  290. // Info coming from companion server configuration:
  291. size,
  292. companionOptions: req.companion.options,
  293. pathPrefix: `${req.companion.options.filePath}`,
  294. storage: redis.client(),
  295. s3: req.companion.s3Client ? {
  296. client: req.companion.s3Client,
  297. options: req.companion.options.providerOptions.s3,
  298. } : null,
  299. chunkSize: req.companion.options.chunkSize,
  300. }
  301. }
  302. /**
  303. * returns a substring of the token. Used as traceId for logging
  304. * we avoid using the entire token because this is meant to be a short term
  305. * access token between uppy client and companion websocket
  306. */
  307. get shortToken () {
  308. return Uploader.shortenToken(this.token)
  309. }
  310. async awaitReady (timeout) {
  311. logger.debug('waiting for socket connection', 'uploader.socket.wait', this.shortToken)
  312. // TODO: replace the Promise constructor call when dropping support for Node.js <16 with
  313. // await once(emitter, eventName, timeout && { signal: AbortSignal.timeout(timeout) })
  314. await new Promise((resolve, reject) => {
  315. const eventName = `connection:${this.token}`
  316. let timer
  317. let onEvent
  318. function cleanup () {
  319. emitter().removeListener(eventName, onEvent)
  320. clearTimeout(timer)
  321. }
  322. if (timeout) {
  323. // Need to timeout after a while, or we could leak emitters
  324. timer = setTimeout(() => {
  325. cleanup()
  326. reject(new Error('Timed out waiting for socket connection'))
  327. }, timeout)
  328. }
  329. onEvent = () => {
  330. cleanup()
  331. resolve()
  332. }
  333. emitter().once(eventName, onEvent)
  334. })
  335. logger.debug('socket connection received', 'uploader.socket.wait', this.shortToken)
  336. }
  337. /**
  338. * @typedef {{action: string, payload: object}} State
  339. * @param {State} state
  340. */
  341. saveState (state) {
  342. if (!this.storage) return
  343. this.storage.set(`${Uploader.STORAGE_PREFIX}:${this.token}`, jsonStringify(state))
  344. }
  345. /**
  346. *
  347. * @param {number} [bytesUploaded]
  348. * @param {number | null} [bytesTotalIn]
  349. */
  350. onProgress (bytesUploaded = 0, bytesTotalIn = 0) {
  351. const bytesTotal = bytesTotalIn || this.size || 0
  352. // If fully downloading before uploading, combine downloaded and uploaded bytes
  353. // This will make sure that the user sees half of the progress before upload starts (while downloading)
  354. let combinedBytes = bytesUploaded
  355. if (this._needDownloadFirst()) {
  356. combinedBytes = Math.floor((combinedBytes + (this.downloadedBytes || 0)) / 2)
  357. }
  358. // Prevent divide by zero
  359. let percentage = 0
  360. if (bytesTotal > 0) percentage = Math.min(Math.max(0, ((combinedBytes / bytesTotal) * 100)), 100)
  361. const formattedPercentage = percentage.toFixed(2)
  362. logger.debug(
  363. `${combinedBytes} ${bytesTotal} ${formattedPercentage}%`,
  364. 'uploader.total.progress',
  365. this.shortToken,
  366. )
  367. if (this._paused || this.uploadStopped) {
  368. return
  369. }
  370. const payload = { progress: formattedPercentage, bytesUploaded: combinedBytes, bytesTotal }
  371. const dataToEmit = {
  372. action: 'progress',
  373. payload,
  374. }
  375. this.saveState(dataToEmit)
  376. const isEqual = (p1, p2) => (p1.progress === p2.progress
  377. && p1.bytesUploaded === p2.bytesUploaded
  378. && p1.bytesTotal === p2.bytesTotal)
  379. // avoid flooding the client with progress events.
  380. if (!isEqual(this.emittedProgress, payload)) {
  381. this.emittedProgress = payload
  382. emitter().emit(this.token, dataToEmit)
  383. }
  384. }
  385. /**
  386. *
  387. * @param {string} url
  388. * @param {object} extraData
  389. */
  390. emitSuccess (url, extraData) {
  391. const emitData = {
  392. action: 'success',
  393. payload: { ...extraData, complete: true, url },
  394. }
  395. this.saveState(emitData)
  396. emitter().emit(this.token, emitData)
  397. }
  398. /**
  399. *
  400. * @param {Error} err
  401. */
  402. emitError (err) {
  403. // delete stack to avoid sending server info to client
  404. // todo remove also extraData from serializedErr in next major
  405. const { stack, ...serializedErr } = serializeError(err)
  406. const dataToEmit = {
  407. action: 'error',
  408. // @ts-ignore
  409. payload: { ...err.extraData, error: serializedErr },
  410. }
  411. this.saveState(dataToEmit)
  412. emitter().emit(this.token, dataToEmit)
  413. }
  414. /**
  415. * start the tus upload
  416. *
  417. * @param {any} stream
  418. */
  419. async _uploadTus (stream) {
  420. const uploader = this
  421. const isFileStream = stream instanceof ReadStream
  422. // chunkSize needs to be a finite value if the stream is not a file stream (fs.createReadStream)
  423. // https://github.com/tus/tus-js-client/blob/4479b78032937ac14da9b0542e489ac6fe7e0bc7/lib/node/fileReader.js#L50
  424. const chunkSize = this.options.chunkSize || (isFileStream ? Infinity : 50e6)
  425. return new Promise((resolve, reject) => {
  426. this.tus = new tus.Upload(stream, {
  427. endpoint: this.options.endpoint,
  428. uploadUrl: this.options.uploadUrl,
  429. uploadLengthDeferred: false,
  430. retryDelays: [0, 1000, 3000, 5000],
  431. uploadSize: this.size,
  432. chunkSize,
  433. headers: headerSanitize(this.options.headers),
  434. addRequestId: true,
  435. metadata: {
  436. // file name and type as required by the tusd tus server
  437. // https://github.com/tus/tusd/blob/5b376141903c1fd64480c06dde3dfe61d191e53d/unrouted_handler.go#L614-L646
  438. filename: this.uploadFileName,
  439. filetype: this.options.metadata.type,
  440. ...this.options.metadata,
  441. },
  442. /**
  443. *
  444. * @param {Error} error
  445. */
  446. onError (error) {
  447. logger.error(error, 'uploader.tus.error')
  448. // deleting tus originalRequest field because it uses the same http-agent
  449. // as companion, and this agent may contain sensitive request details (e.g headers)
  450. // previously made to providers. Deleting the field would prevent it from getting leaked
  451. // to the frontend etc.
  452. // @ts-ignore
  453. delete error.originalRequest
  454. // @ts-ignore
  455. delete error.originalResponse
  456. reject(error)
  457. },
  458. /**
  459. *
  460. * @param {number} [bytesUploaded]
  461. * @param {number} [bytesTotal]
  462. */
  463. onProgress (bytesUploaded, bytesTotal) {
  464. uploader.onProgress(bytesUploaded, bytesTotal)
  465. },
  466. onSuccess () {
  467. resolve({ url: uploader.tus.url })
  468. },
  469. })
  470. if (!this._paused) {
  471. this.tus.start()
  472. }
  473. })
  474. }
  475. async _uploadMultipart (stream) {
  476. if (!this.options.endpoint) {
  477. throw new Error('No multipart endpoint set')
  478. }
  479. // upload progress
  480. let bytesUploaded = 0
  481. stream.on('data', (data) => {
  482. bytesUploaded += data.length
  483. this.onProgress(bytesUploaded, undefined)
  484. })
  485. const httpMethod = (this.options.httpMethod || '').toLowerCase() === 'put' ? 'put' : 'post'
  486. const headers = headerSanitize(this.options.headers)
  487. const reqOptions = { url: this.options.endpoint, headers, encoding: null }
  488. const runRequest = request[httpMethod]
  489. if (this.options.useFormData) {
  490. reqOptions.formData = {
  491. ...this.options.metadata,
  492. [this.options.fieldname]: {
  493. value: stream,
  494. options: {
  495. filename: this.uploadFileName,
  496. contentType: this.options.metadata.type,
  497. knownLength: this.size,
  498. },
  499. },
  500. }
  501. } else {
  502. reqOptions.headers['content-length'] = this.size
  503. reqOptions.body = stream
  504. }
  505. const { response, body } = await new Promise((resolve, reject) => {
  506. runRequest(reqOptions, (error, response2, body2) => {
  507. if (error) {
  508. logger.error(error, 'upload.multipart.error')
  509. reject(error)
  510. return
  511. }
  512. resolve({ response: response2, body: body2 })
  513. })
  514. })
  515. // remove browser forbidden headers
  516. delete response.headers['set-cookie']
  517. delete response.headers['set-cookie2']
  518. const respObj = {
  519. responseText: body.toString(),
  520. status: response.statusCode,
  521. statusText: response.statusMessage,
  522. headers: response.headers,
  523. }
  524. if (response.statusCode >= 400) {
  525. logger.error(`upload failed with status: ${response.statusCode}`, 'upload.multipart.error')
  526. const err = new Error(response.statusMessage)
  527. // @ts-ignore
  528. err.extraData = respObj
  529. throw err
  530. }
  531. if (bytesUploaded !== this.size) {
  532. const errMsg = `uploaded only ${bytesUploaded} of ${this.size} with status: ${response.statusCode}`
  533. logger.error(errMsg, 'upload.multipart.mismatch.error')
  534. throw new Error(errMsg)
  535. }
  536. return { url: null, extraData: { response: respObj, bytesUploaded } }
  537. }
  538. /**
  539. * Upload the file to S3 using a Multipart upload.
  540. */
  541. async _uploadS3Multipart (stream) {
  542. if (!this.options.s3) {
  543. throw new Error('The S3 client is not configured on this companion instance.')
  544. }
  545. const filename = this.uploadFileName
  546. const { client, options } = this.options.s3
  547. function getPartSize (chunkSize) {
  548. // backwards compatibility https://github.com/transloadit/uppy/pull/3511#issuecomment-1050797935
  549. // requires min 5MiB and max 5GiB partSize
  550. // todo remove this logic in the next major semver
  551. if (chunkSize == null || chunkSize >= 5368709120 || chunkSize <= 5242880) return undefined
  552. return chunkSize
  553. }
  554. const params = {
  555. Bucket: options.bucket,
  556. Key: options.getKey(null, filename, this.options.metadata),
  557. ACL: options.acl,
  558. ContentType: this.options.metadata.type,
  559. Metadata: this.options.metadata,
  560. Body: stream,
  561. }
  562. if (options.acl != null) params.ACL = options.acl
  563. const upload = client.upload(params, {
  564. partSize: getPartSize(this.options.chunkSize),
  565. })
  566. upload.on('httpUploadProgress', ({ loaded, total }) => {
  567. this.onProgress(loaded, total)
  568. })
  569. return new Promise((resolve, reject) => {
  570. upload.send((error, data) => {
  571. if (error) {
  572. reject(error)
  573. return
  574. }
  575. resolve({
  576. url: data && data.Location ? data.Location : null,
  577. extraData: {
  578. response: {
  579. responseText: JSON.stringify(data),
  580. headers: {
  581. 'content-type': 'application/json',
  582. },
  583. },
  584. },
  585. })
  586. })
  587. })
  588. }
  589. }
  590. Uploader.FILE_NAME_PREFIX = 'uppy-file'
  591. Uploader.STORAGE_PREFIX = 'companion'
  592. module.exports = Uploader
  593. module.exports.ValidationError = ValidationError