Uploader.js 22 KB

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