index.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. import BasePlugin from '@uppy/core/lib/BasePlugin.js'
  2. import { RequestClient } from '@uppy/companion-client'
  3. import EventManager from '@uppy/utils/lib/EventManager'
  4. import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  5. import { filterNonFailedFiles, filterFilesToEmitUploadStarted } from '@uppy/utils/lib/fileFilters'
  6. import { createAbortError } from '@uppy/utils/lib/AbortController'
  7. import MultipartUploader, { pausingUploadReason } from './MultipartUploader.js'
  8. import createSignedURL from './createSignedURL.js'
  9. import packageJson from '../package.json'
  10. function assertServerError (res) {
  11. if (res && res.error) {
  12. const error = new Error(res.message)
  13. Object.assign(error, res.error)
  14. throw error
  15. }
  16. return res
  17. }
  18. function removeMetadataFromURL (urlString) {
  19. const urlObject = new URL(urlString)
  20. urlObject.search = ''
  21. urlObject.hash = ''
  22. return urlObject.href
  23. }
  24. /**
  25. * Computes the expiry time for a request signed with temporary credentials. If
  26. * no expiration was provided, or an invalid value (e.g. in the past) is
  27. * provided, undefined is returned. This function assumes the client clock is in
  28. * sync with the remote server, which is a requirement for the signature to be
  29. * validated for AWS anyway.
  30. *
  31. * @param {import('../types/index.js').AwsS3STSResponse['credentials']} credentials
  32. * @returns {number | undefined}
  33. */
  34. function getExpiry (credentials) {
  35. const expirationDate = credentials.Expiration
  36. if (expirationDate) {
  37. const timeUntilExpiry = Math.floor((new Date(expirationDate) - Date.now()) / 1000)
  38. if (timeUntilExpiry > 9) {
  39. return timeUntilExpiry
  40. }
  41. }
  42. return undefined
  43. }
  44. function getAllowedMetadata ({ meta, allowedMetaFields, querify = false }) {
  45. const metaFields = allowedMetaFields ?? Object.keys(meta)
  46. if (!meta) return {}
  47. return Object.fromEntries(
  48. metaFields
  49. .filter(key => meta[key] != null)
  50. .map((key) => {
  51. const realKey = querify ? `metadata[${key}]` : key
  52. const value = String(meta[key])
  53. return [realKey, value]
  54. }),
  55. )
  56. }
  57. function throwIfAborted (signal) {
  58. if (signal?.aborted) { throw createAbortError('The operation was aborted', { cause: signal.reason }) }
  59. }
  60. class HTTPCommunicationQueue {
  61. #abortMultipartUpload
  62. #cache = new WeakMap()
  63. #createMultipartUpload
  64. #fetchSignature
  65. #getUploadParameters
  66. #listParts
  67. #previousRetryDelay
  68. #requests
  69. #retryDelays
  70. #sendCompletionRequest
  71. #setS3MultipartState
  72. #uploadPartBytes
  73. #getFile
  74. constructor (requests, options, setS3MultipartState, getFile) {
  75. this.#requests = requests
  76. this.#setS3MultipartState = setS3MultipartState
  77. this.#getFile = getFile
  78. this.setOptions(options)
  79. }
  80. setOptions (options) {
  81. const requests = this.#requests
  82. if ('abortMultipartUpload' in options) {
  83. this.#abortMultipartUpload = requests.wrapPromiseFunction(options.abortMultipartUpload, { priority:1 })
  84. }
  85. if ('createMultipartUpload' in options) {
  86. this.#createMultipartUpload = requests.wrapPromiseFunction(options.createMultipartUpload, { priority:-1 })
  87. }
  88. if ('signPart' in options) {
  89. this.#fetchSignature = requests.wrapPromiseFunction(options.signPart)
  90. }
  91. if ('listParts' in options) {
  92. this.#listParts = requests.wrapPromiseFunction(options.listParts)
  93. }
  94. if ('completeMultipartUpload' in options) {
  95. this.#sendCompletionRequest = requests.wrapPromiseFunction(options.completeMultipartUpload, { priority:1 })
  96. }
  97. if ('retryDelays' in options) {
  98. this.#retryDelays = options.retryDelays ?? []
  99. }
  100. if ('uploadPartBytes' in options) {
  101. this.#uploadPartBytes = requests.wrapPromiseFunction(options.uploadPartBytes, { priority:Infinity })
  102. }
  103. if ('getUploadParameters' in options) {
  104. this.#getUploadParameters = requests.wrapPromiseFunction(options.getUploadParameters)
  105. }
  106. }
  107. async #shouldRetry (err, retryDelayIterator) {
  108. const requests = this.#requests
  109. const status = err?.source?.status
  110. // TODO: this retry logic is taken out of Tus. We should have a centralized place for retrying,
  111. // perhaps the rate limited queue, and dedupe all plugins with that.
  112. if (status == null) {
  113. return false
  114. }
  115. if (status === 403 && err.message === 'Request has expired') {
  116. if (!requests.isPaused) {
  117. // We don't want to exhaust the retryDelayIterator as long as there are
  118. // more than one request in parallel, to give slower connection a chance
  119. // to catch up with the expiry set in Companion.
  120. if (requests.limit === 1 || this.#previousRetryDelay == null) {
  121. const next = retryDelayIterator.next()
  122. if (next == null || next.done) {
  123. return false
  124. }
  125. // If there are more than 1 request done in parallel, the RLQ limit is
  126. // decreased and the failed request is requeued after waiting for a bit.
  127. // If there is only one request in parallel, the limit can't be
  128. // decreased, so we iterate over `retryDelayIterator` as we do for
  129. // other failures.
  130. // `#previousRetryDelay` caches the value so we can re-use it next time.
  131. this.#previousRetryDelay = next.value
  132. }
  133. // No need to stop the other requests, we just want to lower the limit.
  134. requests.rateLimit(0)
  135. await new Promise(resolve => setTimeout(resolve, this.#previousRetryDelay))
  136. }
  137. } else if (status === 429) {
  138. // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
  139. if (!requests.isPaused) {
  140. const next = retryDelayIterator.next()
  141. if (next == null || next.done) {
  142. return false
  143. }
  144. requests.rateLimit(next.value)
  145. }
  146. } else if (status > 400 && status < 500 && status !== 409) {
  147. // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
  148. return false
  149. } else if (typeof navigator !== 'undefined' && navigator.onLine === false) {
  150. // The navigator is offline, let's wait for it to come back online.
  151. if (!requests.isPaused) {
  152. requests.pause()
  153. window.addEventListener('online', () => {
  154. requests.resume()
  155. }, { once: true })
  156. }
  157. } else {
  158. // Other error code means the request can be retried later.
  159. const next = retryDelayIterator.next()
  160. if (next == null || next.done) {
  161. return false
  162. }
  163. await new Promise(resolve => setTimeout(resolve, next.value))
  164. }
  165. return true
  166. }
  167. async getUploadId (file, signal) {
  168. let cachedResult
  169. // As the cache is updated asynchronously, there could be a race condition
  170. // where we just miss a new result so we loop here until we get nothing back,
  171. // at which point it's out turn to create a new cache entry.
  172. while ((cachedResult = this.#cache.get(file.data)) != null) {
  173. try {
  174. return await cachedResult
  175. } catch {
  176. // In case of failure, we want to ignore the cached error.
  177. // At this point, either there's a new cached value, or we'll exit the loop a create a new one.
  178. }
  179. }
  180. const promise = this.#createMultipartUpload(this.#getFile(file), signal)
  181. const abortPromise = () => {
  182. promise.abort(signal.reason)
  183. this.#cache.delete(file.data)
  184. }
  185. signal.addEventListener('abort', abortPromise, { once: true })
  186. this.#cache.set(file.data, promise)
  187. promise.then(async (result) => {
  188. signal.removeEventListener('abort', abortPromise)
  189. this.#setS3MultipartState(file, result)
  190. this.#cache.set(file.data, result)
  191. }, () => {
  192. signal.removeEventListener('abort', abortPromise)
  193. this.#cache.delete(file.data)
  194. })
  195. return promise
  196. }
  197. async abortFileUpload (file) {
  198. const result = this.#cache.get(file.data)
  199. if (result == null) {
  200. // If the createMultipartUpload request never was made, we don't
  201. // need to send the abortMultipartUpload request.
  202. return
  203. }
  204. // Remove the cache entry right away for follow-up requests do not try to
  205. // use the soon-to-be aborted chached values.
  206. this.#cache.delete(file.data)
  207. this.#setS3MultipartState(file, Object.create(null))
  208. let awaitedResult
  209. try {
  210. awaitedResult = await result
  211. } catch {
  212. // If the cached result rejects, there's nothing to abort.
  213. return
  214. }
  215. await this.#abortMultipartUpload(this.#getFile(file), awaitedResult)
  216. }
  217. async #nonMultipartUpload (file, chunk, signal) {
  218. const {
  219. method = 'POST',
  220. url,
  221. fields,
  222. headers,
  223. } = await this.#getUploadParameters(this.#getFile(file), { signal }).abortOn(signal)
  224. let body
  225. const data = chunk.getData()
  226. if (method.toUpperCase() === 'POST') {
  227. const formData = new FormData()
  228. Object.entries(fields).forEach(([key, value]) => formData.set(key, value))
  229. formData.set('file', data)
  230. body = formData
  231. } else {
  232. body = data
  233. }
  234. const { onProgress, onComplete } = chunk
  235. const result = await this.#uploadPartBytes({
  236. signature: { url, headers, method },
  237. body,
  238. size: data.size,
  239. onProgress,
  240. onComplete,
  241. signal,
  242. }).abortOn(signal)
  243. return 'location' in result ? result : {
  244. location: removeMetadataFromURL(url),
  245. ...result,
  246. }
  247. }
  248. /**
  249. * @param {import("@uppy/core").UppyFile} file
  250. * @param {import("../types/chunk").Chunk[]} chunks
  251. * @param {AbortSignal} signal
  252. * @returns {Promise<void>}
  253. */
  254. async uploadFile (file, chunks, signal) {
  255. throwIfAborted(signal)
  256. if (chunks.length === 1 && !chunks[0].shouldUseMultipart) {
  257. return this.#nonMultipartUpload(file, chunks[0], signal)
  258. }
  259. const { uploadId, key } = await this.getUploadId(file, signal)
  260. throwIfAborted(signal)
  261. try {
  262. const parts = await Promise.all(chunks.map((chunk, i) => this.uploadChunk(file, i + 1, chunk, signal)))
  263. throwIfAborted(signal)
  264. return await this.#sendCompletionRequest(
  265. this.#getFile(file),
  266. { key, uploadId, parts, signal },
  267. signal,
  268. ).abortOn(signal)
  269. } catch (err) {
  270. if (err?.cause !== pausingUploadReason && err?.name !== 'AbortError') {
  271. // We purposefully don't wait for the promise and ignore its status,
  272. // because we want the error `err` to bubble up ASAP to report it to the
  273. // user. A failure to abort is not that big of a deal anyway.
  274. this.abortFileUpload(file)
  275. }
  276. throw err
  277. }
  278. }
  279. restoreUploadFile (file, uploadIdAndKey) {
  280. this.#cache.set(file.data, uploadIdAndKey)
  281. }
  282. async resumeUploadFile (file, chunks, signal) {
  283. throwIfAborted(signal)
  284. if (chunks.length === 1 && chunks[0] != null && !chunks[0].shouldUseMultipart) {
  285. return this.#nonMultipartUpload(file, chunks[0], signal)
  286. }
  287. const { uploadId, key } = await this.getUploadId(file, signal)
  288. throwIfAborted(signal)
  289. const alreadyUploadedParts = await this.#listParts(
  290. this.#getFile(file),
  291. { uploadId, key, signal },
  292. signal,
  293. ).abortOn(signal)
  294. throwIfAborted(signal)
  295. const parts = await Promise.all(
  296. chunks
  297. .map((chunk, i) => {
  298. const partNumber = i + 1
  299. const alreadyUploadedInfo = alreadyUploadedParts.find(({ PartNumber }) => PartNumber === partNumber)
  300. if (alreadyUploadedInfo == null) {
  301. return this.uploadChunk(file, partNumber, chunk, signal)
  302. }
  303. // Already uploaded chunks are set to null. If we are restoring the upload, we need to mark it as already uploaded.
  304. chunk?.setAsUploaded?.()
  305. return { PartNumber: partNumber, ETag: alreadyUploadedInfo.ETag }
  306. }),
  307. )
  308. throwIfAborted(signal)
  309. return this.#sendCompletionRequest(
  310. this.#getFile(file),
  311. { key, uploadId, parts, signal },
  312. signal,
  313. ).abortOn(signal)
  314. }
  315. /**
  316. *
  317. * @param {import("@uppy/core").UppyFile} file
  318. * @param {number} partNumber
  319. * @param {import("../types/chunk").Chunk} chunk
  320. * @param {AbortSignal} signal
  321. * @returns {Promise<object>}
  322. */
  323. async uploadChunk (file, partNumber, chunk, signal) {
  324. throwIfAborted(signal)
  325. const { uploadId, key } = await this.getUploadId(file, signal)
  326. const signatureRetryIterator = this.#retryDelays.values()
  327. const chunkRetryIterator = this.#retryDelays.values()
  328. const shouldRetrySignature = () => {
  329. const next = signatureRetryIterator.next()
  330. if (next == null || next.done) {
  331. return null
  332. }
  333. return next.value
  334. }
  335. for (;;) {
  336. throwIfAborted(signal)
  337. const chunkData = chunk.getData()
  338. const { onProgress, onComplete } = chunk
  339. let signature
  340. try {
  341. signature = await this.#fetchSignature(this.#getFile(file), {
  342. uploadId, key, partNumber, body: chunkData, signal,
  343. }).abortOn(signal)
  344. } catch (err) {
  345. const timeout = shouldRetrySignature()
  346. if (timeout == null || signal.aborted) {
  347. throw err
  348. }
  349. await new Promise(resolve => setTimeout(resolve, timeout))
  350. // eslint-disable-next-line no-continue
  351. continue
  352. }
  353. throwIfAborted(signal)
  354. try {
  355. return {
  356. PartNumber: partNumber,
  357. ...await this.#uploadPartBytes({
  358. signature, body: chunkData, size: chunkData.size, onProgress, onComplete, signal,
  359. }).abortOn(signal),
  360. }
  361. } catch (err) {
  362. if (!await this.#shouldRetry(err, chunkRetryIterator)) throw err
  363. }
  364. }
  365. }
  366. }
  367. export default class AwsS3Multipart extends BasePlugin {
  368. static VERSION = packageJson.version
  369. #companionCommunicationQueue
  370. #client
  371. constructor (uppy, opts) {
  372. super(uppy, opts)
  373. this.type = 'uploader'
  374. this.id = this.opts.id || 'AwsS3Multipart'
  375. this.title = 'AWS S3 Multipart'
  376. this.#client = new RequestClient(uppy, opts)
  377. const defaultOptions = {
  378. // TODO: null here means “include all”, [] means include none.
  379. // This is inconsistent with @uppy/aws-s3 and @uppy/transloadit
  380. allowedMetaFields: null,
  381. limit: 6,
  382. shouldUseMultipart: (file) => file.size !== 0, // TODO: Switch default to:
  383. // eslint-disable-next-line no-bitwise
  384. // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100,
  385. retryDelays: [0, 1000, 3000, 5000],
  386. createMultipartUpload: this.createMultipartUpload.bind(this),
  387. listParts: this.listParts.bind(this),
  388. abortMultipartUpload: this.abortMultipartUpload.bind(this),
  389. completeMultipartUpload: this.completeMultipartUpload.bind(this),
  390. getTemporarySecurityCredentials: false,
  391. signPart: opts?.getTemporarySecurityCredentials ? this.createSignedURL.bind(this) : this.signPart.bind(this),
  392. uploadPartBytes: AwsS3Multipart.uploadPartBytes,
  393. getUploadParameters: opts?.getTemporarySecurityCredentials
  394. ? this.createSignedURL.bind(this)
  395. : this.getUploadParameters.bind(this),
  396. companionHeaders: {},
  397. }
  398. this.opts = { ...defaultOptions, ...opts }
  399. if (opts?.prepareUploadParts != null && opts.signPart == null) {
  400. this.opts.signPart = async (file, { uploadId, key, partNumber, body, signal }) => {
  401. const { presignedUrls, headers } = await opts
  402. .prepareUploadParts(file, { uploadId, key, parts: [{ number: partNumber, chunk: body }], signal })
  403. return { url: presignedUrls?.[partNumber], headers: headers?.[partNumber] }
  404. }
  405. }
  406. /**
  407. * Simultaneous upload limiting is shared across all uploads with this plugin.
  408. *
  409. * @type {RateLimitedQueue}
  410. */
  411. this.requests = this.opts.rateLimitedQueue ?? new RateLimitedQueue(this.opts.limit)
  412. this.#companionCommunicationQueue = new HTTPCommunicationQueue(
  413. this.requests,
  414. this.opts,
  415. this.#setS3MultipartState,
  416. this.#getFile,
  417. )
  418. this.uploaders = Object.create(null)
  419. this.uploaderEvents = Object.create(null)
  420. this.uploaderSockets = Object.create(null)
  421. }
  422. [Symbol.for('uppy test: getClient')] () { return this.#client }
  423. setOptions (newOptions) {
  424. this.#companionCommunicationQueue.setOptions(newOptions)
  425. super.setOptions(newOptions)
  426. this.#setCompanionHeaders()
  427. }
  428. /**
  429. * Clean up all references for a file's upload: the MultipartUploader instance,
  430. * any events related to the file, and the Companion WebSocket connection.
  431. *
  432. * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
  433. * This should be done when the user cancels the upload, not when the upload is completed or errored.
  434. */
  435. resetUploaderReferences (fileID, opts = {}) {
  436. if (this.uploaders[fileID]) {
  437. this.uploaders[fileID].abort({ really: opts.abort || false })
  438. this.uploaders[fileID] = null
  439. }
  440. if (this.uploaderEvents[fileID]) {
  441. this.uploaderEvents[fileID].remove()
  442. this.uploaderEvents[fileID] = null
  443. }
  444. if (this.uploaderSockets[fileID]) {
  445. this.uploaderSockets[fileID].close()
  446. this.uploaderSockets[fileID] = null
  447. }
  448. }
  449. // TODO: make this a private method in the next major
  450. assertHost (method) {
  451. if (!this.opts.companionUrl) {
  452. throw new Error(`Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`)
  453. }
  454. }
  455. createMultipartUpload (file, signal) {
  456. this.assertHost('createMultipartUpload')
  457. throwIfAborted(signal)
  458. const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields: this.opts.allowedMetaFields })
  459. return this.#client.post('s3/multipart', {
  460. filename: file.name,
  461. type: file.type,
  462. metadata,
  463. }, { signal }).then(assertServerError)
  464. }
  465. listParts (file, { key, uploadId }, signal) {
  466. this.assertHost('listParts')
  467. throwIfAborted(signal)
  468. const filename = encodeURIComponent(key)
  469. return this.#client.get(`s3/multipart/${uploadId}?key=${filename}`, { signal })
  470. .then(assertServerError)
  471. }
  472. completeMultipartUpload (file, { key, uploadId, parts }, signal) {
  473. this.assertHost('completeMultipartUpload')
  474. throwIfAborted(signal)
  475. const filename = encodeURIComponent(key)
  476. const uploadIdEnc = encodeURIComponent(uploadId)
  477. return this.#client.post(`s3/multipart/${uploadIdEnc}/complete?key=${filename}`, { parts }, { signal })
  478. .then(assertServerError)
  479. }
  480. /**
  481. * @type {import("../types").AwsS3STSResponse | Promise<import("../types").AwsS3STSResponse>}
  482. */
  483. #cachedTemporaryCredentials
  484. async #getTemporarySecurityCredentials (options) {
  485. throwIfAborted(options?.signal)
  486. if (this.#cachedTemporaryCredentials == null) {
  487. // We do not await it just yet, so concurrent calls do not try to override it:
  488. if (this.opts.getTemporarySecurityCredentials === true) {
  489. this.assertHost('getTemporarySecurityCredentials')
  490. this.#cachedTemporaryCredentials = this.#client.get('s3/sts', null, options).then(assertServerError)
  491. } else {
  492. this.#cachedTemporaryCredentials = this.opts.getTemporarySecurityCredentials(options)
  493. }
  494. this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
  495. setTimeout(() => {
  496. // At half the time left before expiration, we clear the cache. That's
  497. // an arbitrary tradeoff to limit the number of requests made to the
  498. // remote while limiting the risk of using an expired token in case the
  499. // clocks are not exactly synced.
  500. // The HTTP cache should be configured to ensure a client doesn't request
  501. // more tokens than it needs, but this timeout provides a second layer of
  502. // security in case the HTTP cache is disabled or misconfigured.
  503. this.#cachedTemporaryCredentials = null
  504. }, (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500)
  505. }
  506. return this.#cachedTemporaryCredentials
  507. }
  508. async createSignedURL (file, options) {
  509. const data = await this.#getTemporarySecurityCredentials(options)
  510. const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
  511. const { uploadId, key, partNumber, signal } = options
  512. // Return an object in the correct shape.
  513. return {
  514. method: 'PUT',
  515. expires,
  516. fields: {},
  517. url: `${await createSignedURL({
  518. accountKey: data.credentials.AccessKeyId,
  519. accountSecret: data.credentials.SecretAccessKey,
  520. sessionToken: data.credentials.SessionToken,
  521. expires,
  522. bucketName: data.bucket,
  523. Region: data.region,
  524. Key: key ?? `${crypto.randomUUID()}-${file.name}`,
  525. uploadId,
  526. partNumber,
  527. signal,
  528. })}`,
  529. // Provide content type header required by S3
  530. headers: {
  531. 'Content-Type': file.type,
  532. },
  533. }
  534. }
  535. signPart (file, { uploadId, key, partNumber, signal }) {
  536. this.assertHost('signPart')
  537. throwIfAborted(signal)
  538. if (uploadId == null || key == null || partNumber == null) {
  539. throw new Error('Cannot sign without a key, an uploadId, and a partNumber')
  540. }
  541. const filename = encodeURIComponent(key)
  542. return this.#client.get(`s3/multipart/${uploadId}/${partNumber}?key=${filename}`, { signal })
  543. .then(assertServerError)
  544. }
  545. abortMultipartUpload (file, { key, uploadId }, signal) {
  546. this.assertHost('abortMultipartUpload')
  547. const filename = encodeURIComponent(key)
  548. const uploadIdEnc = encodeURIComponent(uploadId)
  549. return this.#client.delete(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, { signal })
  550. .then(assertServerError)
  551. }
  552. getUploadParameters (file, options) {
  553. const { meta } = file
  554. const { type, name: filename } = meta
  555. const metadata = getAllowedMetadata({ meta, allowedMetaFields: this.opts.allowedMetaFields, querify: true })
  556. const query = new URLSearchParams({ filename, type, ...metadata })
  557. return this.#client.get(`s3/params?${query}`, options)
  558. }
  559. static async uploadPartBytes ({ signature: { url, expires, headers, method = 'PUT' }, body, size = body.size, onProgress, onComplete, signal }) {
  560. throwIfAborted(signal)
  561. if (url == null) {
  562. throw new Error('Cannot upload to an undefined URL')
  563. }
  564. return new Promise((resolve, reject) => {
  565. const xhr = new XMLHttpRequest()
  566. xhr.open(method, url, true)
  567. if (headers) {
  568. Object.keys(headers).forEach((key) => {
  569. xhr.setRequestHeader(key, headers[key])
  570. })
  571. }
  572. xhr.responseType = 'text'
  573. if (typeof expires === 'number') {
  574. xhr.timeout = expires * 1000
  575. }
  576. function onabort () {
  577. xhr.abort()
  578. }
  579. function cleanup () {
  580. signal.removeEventListener('abort', onabort)
  581. }
  582. signal.addEventListener('abort', onabort)
  583. xhr.upload.addEventListener('progress', (ev) => {
  584. onProgress(ev)
  585. })
  586. xhr.addEventListener('abort', () => {
  587. cleanup()
  588. reject(createAbortError())
  589. })
  590. xhr.addEventListener('timeout', () => {
  591. cleanup()
  592. const error = new Error('Request has expired')
  593. error.source = { status: 403 }
  594. reject(error)
  595. })
  596. xhr.addEventListener('load', (ev) => {
  597. cleanup()
  598. if (ev.target.status === 403 && ev.target.responseText.includes('<Message>Request has expired</Message>')) {
  599. const error = new Error('Request has expired')
  600. error.source = ev.target
  601. reject(error)
  602. return
  603. } if (ev.target.status < 200 || ev.target.status >= 300) {
  604. const error = new Error('Non 2xx')
  605. error.source = ev.target
  606. reject(error)
  607. return
  608. }
  609. // todo make a proper onProgress API (breaking change)
  610. onProgress?.({ loaded: size, lengthComputable: true })
  611. // NOTE This must be allowed by CORS.
  612. const etag = ev.target.getResponseHeader('ETag')
  613. const location = ev.target.getResponseHeader('Location')
  614. if (method.toUpperCase() === 'POST' && location === null) {
  615. // Not being able to read the Location header is not a fatal error.
  616. // eslint-disable-next-line no-console
  617. console.warn('AwsS3/Multipart: Could not read the Location header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.')
  618. }
  619. if (etag === null) {
  620. reject(new Error('AwsS3/Multipart: Could not read the ETag header. This likely means CORS is not configured correctly on the S3 Bucket. See https://uppy.io/docs/aws-s3-multipart#S3-Bucket-Configuration for instructions.'))
  621. return
  622. }
  623. onComplete?.(etag)
  624. resolve({
  625. ETag: etag,
  626. ...(location ? { location } : undefined),
  627. })
  628. })
  629. xhr.addEventListener('error', (ev) => {
  630. cleanup()
  631. const error = new Error('Unknown error')
  632. error.source = ev.target
  633. reject(error)
  634. })
  635. xhr.send(body)
  636. })
  637. }
  638. #setS3MultipartState = (file, { key, uploadId }) => {
  639. const cFile = this.uppy.getFile(file.id)
  640. if (cFile == null) {
  641. // file was removed from store
  642. return
  643. }
  644. this.uppy.setFileState(file.id, {
  645. s3Multipart: {
  646. ...cFile.s3Multipart,
  647. key,
  648. uploadId,
  649. },
  650. })
  651. }
  652. #getFile = (file) => {
  653. return this.uppy.getFile(file.id) || file
  654. }
  655. #uploadLocalFile (file) {
  656. return new Promise((resolve, reject) => {
  657. const onProgress = (bytesUploaded, bytesTotal) => {
  658. this.uppy.emit('upload-progress', this.uppy.getFile(file.id), {
  659. uploader: this,
  660. bytesUploaded,
  661. bytesTotal,
  662. })
  663. }
  664. const onError = (err) => {
  665. this.uppy.log(err)
  666. this.uppy.emit('upload-error', file, err)
  667. this.resetUploaderReferences(file.id)
  668. reject(err)
  669. }
  670. const onSuccess = (result) => {
  671. const uploadResp = {
  672. body: {
  673. ...result,
  674. },
  675. uploadURL: result.location,
  676. }
  677. this.resetUploaderReferences(file.id)
  678. this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
  679. if (result.location) {
  680. this.uppy.log(`Download ${file.name} from ${result.location}`)
  681. }
  682. resolve()
  683. }
  684. const onPartComplete = (part) => {
  685. this.uppy.emit('s3-multipart:part-uploaded', this.#getFile(file), part)
  686. }
  687. const upload = new MultipartUploader(file.data, {
  688. // .bind to pass the file object to each handler.
  689. companionComm: this.#companionCommunicationQueue,
  690. log: (...args) => this.uppy.log(...args),
  691. getChunkSize: this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null,
  692. onProgress,
  693. onError,
  694. onSuccess,
  695. onPartComplete,
  696. file,
  697. shouldUseMultipart: this.opts.shouldUseMultipart,
  698. ...file.s3Multipart,
  699. })
  700. this.uploaders[file.id] = upload
  701. const eventManager = new EventManager(this.uppy)
  702. this.uploaderEvents[file.id] = eventManager
  703. eventManager.onFileRemove(file.id, (removed) => {
  704. upload.abort()
  705. this.resetUploaderReferences(file.id, { abort: true })
  706. resolve(`upload ${removed.id} was removed`)
  707. })
  708. eventManager.onCancelAll(file.id, ({ reason } = {}) => {
  709. if (reason === 'user') {
  710. upload.abort()
  711. this.resetUploaderReferences(file.id, { abort: true })
  712. }
  713. resolve(`upload ${file.id} was canceled`)
  714. })
  715. eventManager.onFilePause(file.id, (isPaused) => {
  716. if (isPaused) {
  717. upload.pause()
  718. } else {
  719. upload.start()
  720. }
  721. })
  722. eventManager.onPauseAll(file.id, () => {
  723. upload.pause()
  724. })
  725. eventManager.onResumeAll(file.id, () => {
  726. upload.start()
  727. })
  728. upload.start()
  729. })
  730. }
  731. // eslint-disable-next-line class-methods-use-this
  732. #getCompanionClientArgs (file) {
  733. return {
  734. ...file.remote.body,
  735. protocol: 's3-multipart',
  736. size: file.data.size,
  737. metadata: file.meta,
  738. }
  739. }
  740. #upload = async (fileIDs) => {
  741. if (fileIDs.length === 0) return undefined
  742. const files = this.uppy.getFilesByIds(fileIDs)
  743. const filesFiltered = filterNonFailedFiles(files)
  744. const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
  745. this.uppy.emit('upload-start', filesToEmit)
  746. const promises = filesFiltered.map((file) => {
  747. if (file.isRemote) {
  748. const getQueue = () => this.requests
  749. this.#setResumableUploadsCapability(false)
  750. const controller = new AbortController()
  751. const removedHandler = (removedFile) => {
  752. if (removedFile.id === file.id) controller.abort()
  753. }
  754. this.uppy.on('file-removed', removedHandler)
  755. const uploadPromise = this.uppy.getRequestClientForFile(file).uploadRemoteFile(
  756. file,
  757. this.#getCompanionClientArgs(file),
  758. { signal: controller.signal, getQueue },
  759. )
  760. this.requests.wrapSyncFunction(() => {
  761. this.uppy.off('file-removed', removedHandler)
  762. }, { priority: -1 })()
  763. return uploadPromise
  764. }
  765. return this.#uploadLocalFile(file)
  766. })
  767. const upload = await Promise.all(promises)
  768. // After the upload is done, another upload may happen with only local files.
  769. // We reset the capability so that the next upload can use resumable uploads.
  770. this.#setResumableUploadsCapability(true)
  771. return upload
  772. }
  773. #setCompanionHeaders = () => {
  774. this.#client.setCompanionHeaders(this.opts.companionHeaders)
  775. }
  776. #setResumableUploadsCapability = (boolean) => {
  777. const { capabilities } = this.uppy.getState()
  778. this.uppy.setState({
  779. capabilities: {
  780. ...capabilities,
  781. resumableUploads: boolean,
  782. },
  783. })
  784. }
  785. #resetResumableCapability = () => {
  786. this.#setResumableUploadsCapability(true)
  787. }
  788. install () {
  789. this.#setResumableUploadsCapability(true)
  790. this.uppy.addPreProcessor(this.#setCompanionHeaders)
  791. this.uppy.addUploader(this.#upload)
  792. this.uppy.on('cancel-all', this.#resetResumableCapability)
  793. }
  794. uninstall () {
  795. this.uppy.removePreProcessor(this.#setCompanionHeaders)
  796. this.uppy.removeUploader(this.#upload)
  797. this.uppy.off('cancel-all', this.#resetResumableCapability)
  798. }
  799. }