Uploader.js 21 KB

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