index.ts 29 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010
  1. import BasePlugin, {
  2. type DefinePluginOpts,
  3. type PluginOpts,
  4. } from '@uppy/core/lib/BasePlugin.js'
  5. import { RequestClient } from '@uppy/companion-client'
  6. import type { RequestOptions } from '@uppy/utils/lib/CompanionClientProvider.ts'
  7. import type { Body as _Body, Meta, UppyFile } from '@uppy/utils/lib/UppyFile'
  8. import type { Uppy } from '@uppy/core'
  9. import EventManager from '@uppy/core/lib/EventManager.js'
  10. import { RateLimitedQueue } from '@uppy/utils/lib/RateLimitedQueue'
  11. import {
  12. filterNonFailedFiles,
  13. filterFilesToEmitUploadStarted,
  14. } from '@uppy/utils/lib/fileFilters'
  15. import { createAbortError } from '@uppy/utils/lib/AbortController'
  16. import getAllowedMetaFields from '@uppy/utils/lib/getAllowedMetaFields'
  17. import MultipartUploader from './MultipartUploader.ts'
  18. import { throwIfAborted } from './utils.ts'
  19. import type {
  20. UploadResult,
  21. UploadResultWithSignal,
  22. MultipartUploadResultWithSignal,
  23. UploadPartBytesResult,
  24. Body,
  25. } from './utils.ts'
  26. import createSignedURL from './createSignedURL.ts'
  27. import { HTTPCommunicationQueue } from './HTTPCommunicationQueue.ts'
  28. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  29. // @ts-ignore We don't want TS to generate types for the package.json
  30. import packageJson from '../package.json'
  31. interface MultipartFile<M extends Meta, B extends Body> extends UppyFile<M, B> {
  32. s3Multipart: UploadResult
  33. }
  34. type PartUploadedCallback<M extends Meta, B extends _Body> = (
  35. file: UppyFile<M, B>,
  36. part: { PartNumber: number; ETag: string },
  37. ) => void
  38. declare module '@uppy/core' {
  39. export interface UppyEventMap<M extends Meta, B extends _Body> {
  40. 's3-multipart:part-uploaded': PartUploadedCallback<M, B>
  41. }
  42. }
  43. function assertServerError<T>(res: T): T {
  44. if ((res as any)?.error) {
  45. const error = new Error((res as any).message)
  46. Object.assign(error, (res as any).error)
  47. throw error
  48. }
  49. return res
  50. }
  51. export interface AwsS3STSResponse {
  52. credentials: {
  53. AccessKeyId: string
  54. SecretAccessKey: string
  55. SessionToken: string
  56. Expiration?: string
  57. }
  58. bucket: string
  59. region: string
  60. }
  61. /**
  62. * Computes the expiry time for a request signed with temporary credentials. If
  63. * no expiration was provided, or an invalid value (e.g. in the past) is
  64. * provided, undefined is returned. This function assumes the client clock is in
  65. * sync with the remote server, which is a requirement for the signature to be
  66. * validated for AWS anyway.
  67. */
  68. function getExpiry(
  69. credentials: AwsS3STSResponse['credentials'],
  70. ): number | undefined {
  71. const expirationDate = credentials.Expiration
  72. if (expirationDate) {
  73. const timeUntilExpiry = Math.floor(
  74. ((new Date(expirationDate) as any as number) - Date.now()) / 1000,
  75. )
  76. if (timeUntilExpiry > 9) {
  77. return timeUntilExpiry
  78. }
  79. }
  80. return undefined
  81. }
  82. function getAllowedMetadata<M extends Record<string, any>>({
  83. meta,
  84. allowedMetaFields,
  85. querify = false,
  86. }: {
  87. meta: M
  88. allowedMetaFields?: string[] | null
  89. querify?: boolean
  90. }) {
  91. const metaFields = allowedMetaFields ?? Object.keys(meta)
  92. if (!meta) return {}
  93. return Object.fromEntries(
  94. metaFields
  95. .filter((key) => meta[key] != null)
  96. .map((key) => {
  97. const realKey = querify ? `metadata[${key}]` : key
  98. const value = String(meta[key])
  99. return [realKey, value]
  100. }),
  101. )
  102. }
  103. type MaybePromise<T> = T | Promise<T>
  104. type SignPartOptions = {
  105. uploadId: string
  106. key: string
  107. partNumber: number
  108. body: Blob
  109. signal?: AbortSignal
  110. }
  111. export type AwsS3UploadParameters =
  112. | {
  113. method: 'POST'
  114. url: string
  115. fields: Record<string, string>
  116. expires?: number
  117. headers?: Record<string, string>
  118. }
  119. | {
  120. method?: 'PUT'
  121. url: string
  122. fields?: Record<string, never>
  123. expires?: number
  124. headers?: Record<string, string>
  125. }
  126. export interface AwsS3Part {
  127. PartNumber?: number
  128. Size?: number
  129. ETag?: string
  130. }
  131. type AWSS3WithCompanion = {
  132. companionUrl: string
  133. companionHeaders?: Record<string, string>
  134. companionCookiesRule?: string
  135. getTemporarySecurityCredentials?: true
  136. }
  137. type AWSS3WithoutCompanion = {
  138. getTemporarySecurityCredentials?: (options?: {
  139. signal?: AbortSignal
  140. }) => MaybePromise<AwsS3STSResponse>
  141. uploadPartBytes?: (options: {
  142. signature: AwsS3UploadParameters
  143. body: FormData | Blob
  144. size?: number
  145. onProgress: any
  146. onComplete: any
  147. signal?: AbortSignal
  148. }) => Promise<UploadPartBytesResult>
  149. }
  150. type AWSS3NonMultipartWithCompanionMandatory = {
  151. // No related options
  152. }
  153. type AWSS3NonMultipartWithoutCompanionMandatory<
  154. M extends Meta,
  155. B extends Body,
  156. > = {
  157. getUploadParameters: (
  158. file: UppyFile<M, B>,
  159. options: RequestOptions,
  160. ) => MaybePromise<AwsS3UploadParameters>
  161. }
  162. type AWSS3NonMultipartWithCompanion = AWSS3WithCompanion &
  163. AWSS3NonMultipartWithCompanionMandatory & {
  164. shouldUseMultipart: false
  165. }
  166. type AWSS3NonMultipartWithoutCompanion<
  167. M extends Meta,
  168. B extends Body,
  169. > = AWSS3WithoutCompanion &
  170. AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
  171. shouldUseMultipart: false
  172. }
  173. type AWSS3MultipartWithoutCompanionMandatorySignPart<
  174. M extends Meta,
  175. B extends Body,
  176. > = {
  177. signPart: (
  178. file: UppyFile<M, B>,
  179. opts: SignPartOptions,
  180. ) => MaybePromise<AwsS3UploadParameters>
  181. }
  182. /** @deprecated Use signPart instead */
  183. type AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<
  184. M extends Meta,
  185. B extends Body,
  186. > = {
  187. /** @deprecated Use signPart instead */
  188. prepareUploadParts: (
  189. file: UppyFile<M, B>,
  190. partData: {
  191. uploadId: string
  192. key: string
  193. parts: [{ number: number; chunk: Blob }]
  194. signal?: AbortSignal
  195. },
  196. ) => MaybePromise<{
  197. presignedUrls: Record<number, string>
  198. headers?: Record<number, Record<string, string>>
  199. }>
  200. }
  201. type AWSS3MultipartWithoutCompanionMandatory<M extends Meta, B extends Body> = {
  202. getChunkSize?: (file: UppyFile<M, B>) => number
  203. createMultipartUpload: (file: UppyFile<M, B>) => MaybePromise<UploadResult>
  204. listParts: (
  205. file: UppyFile<M, B>,
  206. opts: UploadResultWithSignal,
  207. ) => MaybePromise<AwsS3Part[]>
  208. abortMultipartUpload: (
  209. file: UppyFile<M, B>,
  210. opts: UploadResultWithSignal,
  211. ) => MaybePromise<void>
  212. completeMultipartUpload: (
  213. file: UppyFile<M, B>,
  214. opts: {
  215. uploadId: string
  216. key: string
  217. parts: AwsS3Part[]
  218. signal: AbortSignal
  219. },
  220. ) => MaybePromise<{ location?: string }>
  221. } & (
  222. | AWSS3MultipartWithoutCompanionMandatorySignPart<M, B>
  223. | AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<M, B>
  224. )
  225. type AWSS3MultipartWithoutCompanion<
  226. M extends Meta,
  227. B extends Body,
  228. > = AWSS3WithoutCompanion &
  229. AWSS3MultipartWithoutCompanionMandatory<M, B> & {
  230. shouldUseMultipart?: true
  231. }
  232. type AWSS3MultipartWithCompanion<
  233. M extends Meta,
  234. B extends Body,
  235. > = AWSS3WithCompanion &
  236. Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> & {
  237. shouldUseMultipart?: true
  238. }
  239. type AWSS3MaybeMultipartWithCompanion<
  240. M extends Meta,
  241. B extends Body,
  242. > = AWSS3WithCompanion &
  243. Partial<AWSS3MultipartWithoutCompanionMandatory<M, B>> &
  244. AWSS3NonMultipartWithCompanionMandatory & {
  245. shouldUseMultipart: (file: UppyFile<M, B>) => boolean
  246. }
  247. type AWSS3MaybeMultipartWithoutCompanion<
  248. M extends Meta,
  249. B extends Body,
  250. > = AWSS3WithoutCompanion &
  251. AWSS3MultipartWithoutCompanionMandatory<M, B> &
  252. AWSS3NonMultipartWithoutCompanionMandatory<M, B> & {
  253. shouldUseMultipart: (file: UppyFile<M, B>) => boolean
  254. }
  255. type RequestClientOptions = Partial<
  256. ConstructorParameters<typeof RequestClient<any, any>>[1]
  257. >
  258. interface _AwsS3MultipartOptions extends PluginOpts, RequestClientOptions {
  259. allowedMetaFields?: string[] | boolean
  260. limit?: number
  261. retryDelays?: number[] | null
  262. }
  263. export type AwsS3MultipartOptions<
  264. M extends Meta,
  265. B extends Body,
  266. > = _AwsS3MultipartOptions &
  267. (
  268. | AWSS3NonMultipartWithCompanion
  269. | AWSS3NonMultipartWithoutCompanion<M, B>
  270. | AWSS3MultipartWithCompanion<M, B>
  271. | AWSS3MultipartWithoutCompanion<M, B>
  272. | AWSS3MaybeMultipartWithCompanion<M, B>
  273. | AWSS3MaybeMultipartWithoutCompanion<M, B>
  274. )
  275. const defaultOptions = {
  276. allowedMetaFields: true,
  277. limit: 6,
  278. getTemporarySecurityCredentials: false as any,
  279. shouldUseMultipart: ((file: UppyFile<any, any>) =>
  280. file.size !== 0) as any as true, // TODO: Switch default to:
  281. // eslint-disable-next-line no-bitwise
  282. // shouldUseMultipart: (file) => file.size >> 10 >> 10 > 100,
  283. retryDelays: [0, 1000, 3000, 5000],
  284. companionHeaders: {},
  285. } satisfies Partial<AwsS3MultipartOptions<any, any>>
  286. export default class AwsS3Multipart<
  287. M extends Meta,
  288. B extends Body,
  289. > extends BasePlugin<
  290. DefinePluginOpts<AwsS3MultipartOptions<M, B>, keyof typeof defaultOptions> &
  291. // We also have a few dynamic options defined below:
  292. Pick<
  293. AWSS3MultipartWithoutCompanionMandatory<M, B>,
  294. | 'getChunkSize'
  295. | 'createMultipartUpload'
  296. | 'listParts'
  297. | 'abortMultipartUpload'
  298. | 'completeMultipartUpload'
  299. > &
  300. Required<Pick<AWSS3WithoutCompanion, 'uploadPartBytes'>> &
  301. AWSS3MultipartWithoutCompanionMandatorySignPart<M, B> &
  302. AWSS3NonMultipartWithoutCompanionMandatory<M, B>,
  303. M,
  304. B
  305. > {
  306. static VERSION = packageJson.version
  307. #companionCommunicationQueue
  308. #client: RequestClient<M, B>
  309. protected requests: any
  310. protected uploaderEvents: Record<string, EventManager<M, B> | null>
  311. protected uploaders: Record<string, MultipartUploader<M, B> | null>
  312. protected uploaderSockets: Record<string, never>
  313. constructor(uppy: Uppy<M, B>, opts: AwsS3MultipartOptions<M, B>) {
  314. super(uppy, {
  315. ...defaultOptions,
  316. uploadPartBytes: AwsS3Multipart.uploadPartBytes,
  317. createMultipartUpload: null as any,
  318. listParts: null as any,
  319. abortMultipartUpload: null as any,
  320. completeMultipartUpload: null as any,
  321. signPart: null as any,
  322. getUploadParameters: null as any,
  323. ...opts,
  324. })
  325. // We need the `as any` here because of the dynamic default options.
  326. this.type = 'uploader'
  327. this.id = this.opts.id || 'AwsS3Multipart'
  328. // @ts-expect-error TODO: remove unused
  329. this.title = 'AWS S3 Multipart'
  330. // TODO: only initiate `RequestClient` is `companionUrl` is defined.
  331. this.#client = new RequestClient(uppy, opts as any)
  332. const dynamicDefaultOptions = {
  333. createMultipartUpload: this.createMultipartUpload,
  334. listParts: this.listParts,
  335. abortMultipartUpload: this.abortMultipartUpload,
  336. completeMultipartUpload: this.completeMultipartUpload,
  337. signPart:
  338. opts?.getTemporarySecurityCredentials ?
  339. this.createSignedURL
  340. : this.signPart,
  341. getUploadParameters:
  342. opts?.getTemporarySecurityCredentials ?
  343. (this.createSignedURL as any)
  344. : this.getUploadParameters,
  345. } satisfies Partial<AwsS3MultipartOptions<M, B>>
  346. for (const key of Object.keys(dynamicDefaultOptions)) {
  347. if (this.opts[key as keyof typeof dynamicDefaultOptions] == null) {
  348. this.opts[key as keyof typeof dynamicDefaultOptions] =
  349. dynamicDefaultOptions[key as keyof typeof dynamicDefaultOptions].bind(
  350. this,
  351. )
  352. }
  353. }
  354. if (
  355. (opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<M, B>)
  356. ?.prepareUploadParts != null &&
  357. (opts as AWSS3MultipartWithoutCompanionMandatorySignPart<M, B>)
  358. .signPart == null
  359. ) {
  360. this.opts.signPart = async (
  361. file: UppyFile<M, B>,
  362. { uploadId, key, partNumber, body, signal }: SignPartOptions,
  363. ) => {
  364. const { presignedUrls, headers } = await (
  365. opts as AWSS3MultipartWithoutCompanionMandatoryPrepareUploadParts<
  366. M,
  367. B
  368. >
  369. ).prepareUploadParts(file, {
  370. uploadId,
  371. key,
  372. parts: [{ number: partNumber, chunk: body }],
  373. signal,
  374. })
  375. return {
  376. url: presignedUrls?.[partNumber],
  377. headers: headers?.[partNumber],
  378. }
  379. }
  380. }
  381. /**
  382. * Simultaneous upload limiting is shared across all uploads with this plugin.
  383. *
  384. * @type {RateLimitedQueue}
  385. */
  386. this.requests =
  387. (this.opts as any).rateLimitedQueue ??
  388. new RateLimitedQueue(this.opts.limit)
  389. this.#companionCommunicationQueue = new HTTPCommunicationQueue(
  390. this.requests,
  391. this.opts,
  392. this.#setS3MultipartState,
  393. this.#getFile,
  394. )
  395. this.uploaders = Object.create(null)
  396. this.uploaderEvents = Object.create(null)
  397. this.uploaderSockets = Object.create(null)
  398. }
  399. private [Symbol.for('uppy test: getClient')]() {
  400. return this.#client
  401. }
  402. setOptions(newOptions: Partial<AwsS3MultipartOptions<M, B>>): void {
  403. this.#companionCommunicationQueue.setOptions(newOptions)
  404. super.setOptions(newOptions)
  405. this.#setCompanionHeaders()
  406. }
  407. /**
  408. * Clean up all references for a file's upload: the MultipartUploader instance,
  409. * any events related to the file, and the Companion WebSocket connection.
  410. *
  411. * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed.
  412. * This should be done when the user cancels the upload, not when the upload is completed or errored.
  413. */
  414. resetUploaderReferences(fileID: string, opts?: { abort: boolean }): void {
  415. if (this.uploaders[fileID]) {
  416. this.uploaders[fileID]!.abort({ really: opts?.abort || false })
  417. this.uploaders[fileID] = null
  418. }
  419. if (this.uploaderEvents[fileID]) {
  420. this.uploaderEvents[fileID]!.remove()
  421. this.uploaderEvents[fileID] = null
  422. }
  423. if (this.uploaderSockets[fileID]) {
  424. // @ts-expect-error TODO: remove this block in the next major
  425. this.uploaderSockets[fileID].close()
  426. // @ts-expect-error TODO: remove this block in the next major
  427. this.uploaderSockets[fileID] = null
  428. }
  429. }
  430. // TODO: make this a private method in the next major
  431. assertHost(method: string): void {
  432. if (!this.opts.companionUrl) {
  433. throw new Error(
  434. `Expected a \`companionUrl\` option containing a Companion address, or if you are not using Companion, a custom \`${method}\` implementation.`,
  435. )
  436. }
  437. }
  438. createMultipartUpload(
  439. file: UppyFile<M, B>,
  440. signal?: AbortSignal,
  441. ): Promise<UploadResult> {
  442. this.assertHost('createMultipartUpload')
  443. throwIfAborted(signal)
  444. const allowedMetaFields = getAllowedMetaFields(
  445. this.opts.allowedMetaFields,
  446. file.meta,
  447. )
  448. const metadata = getAllowedMetadata({ meta: file.meta, allowedMetaFields })
  449. return this.#client
  450. .post<UploadResult>(
  451. 's3/multipart',
  452. {
  453. filename: file.name,
  454. type: file.type,
  455. metadata,
  456. },
  457. { signal },
  458. )
  459. .then(assertServerError)
  460. }
  461. listParts(
  462. file: UppyFile<M, B>,
  463. { key, uploadId, signal }: UploadResultWithSignal,
  464. oldSignal?: AbortSignal,
  465. ): Promise<AwsS3Part[]> {
  466. signal ??= oldSignal // eslint-disable-line no-param-reassign
  467. this.assertHost('listParts')
  468. throwIfAborted(signal)
  469. const filename = encodeURIComponent(key)
  470. return this.#client
  471. .get<AwsS3Part[]>(`s3/multipart/${uploadId}?key=${filename}`, { signal })
  472. .then(assertServerError)
  473. }
  474. completeMultipartUpload(
  475. file: UppyFile<M, B>,
  476. { key, uploadId, parts, signal }: MultipartUploadResultWithSignal,
  477. oldSignal?: AbortSignal,
  478. ): Promise<B> {
  479. signal ??= oldSignal // eslint-disable-line no-param-reassign
  480. this.assertHost('completeMultipartUpload')
  481. throwIfAborted(signal)
  482. const filename = encodeURIComponent(key)
  483. const uploadIdEnc = encodeURIComponent(uploadId)
  484. return this.#client
  485. .post<B>(
  486. `s3/multipart/${uploadIdEnc}/complete?key=${filename}`,
  487. { parts },
  488. { signal },
  489. )
  490. .then(assertServerError)
  491. }
  492. #cachedTemporaryCredentials: MaybePromise<AwsS3STSResponse>
  493. async #getTemporarySecurityCredentials(options?: RequestOptions) {
  494. throwIfAborted(options?.signal)
  495. if (this.#cachedTemporaryCredentials == null) {
  496. // We do not await it just yet, so concurrent calls do not try to override it:
  497. if (this.opts.getTemporarySecurityCredentials === true) {
  498. this.assertHost('getTemporarySecurityCredentials')
  499. this.#cachedTemporaryCredentials = this.#client
  500. .get<AwsS3STSResponse>('s3/sts', options)
  501. .then(assertServerError)
  502. } else {
  503. this.#cachedTemporaryCredentials =
  504. this.opts.getTemporarySecurityCredentials(options)
  505. }
  506. this.#cachedTemporaryCredentials = await this.#cachedTemporaryCredentials
  507. setTimeout(
  508. () => {
  509. // At half the time left before expiration, we clear the cache. That's
  510. // an arbitrary tradeoff to limit the number of requests made to the
  511. // remote while limiting the risk of using an expired token in case the
  512. // clocks are not exactly synced.
  513. // The HTTP cache should be configured to ensure a client doesn't request
  514. // more tokens than it needs, but this timeout provides a second layer of
  515. // security in case the HTTP cache is disabled or misconfigured.
  516. this.#cachedTemporaryCredentials = null as any
  517. },
  518. (getExpiry(this.#cachedTemporaryCredentials.credentials) || 0) * 500,
  519. )
  520. }
  521. return this.#cachedTemporaryCredentials
  522. }
  523. async createSignedURL(
  524. file: UppyFile<M, B>,
  525. options: SignPartOptions,
  526. ): Promise<AwsS3UploadParameters> {
  527. const data = await this.#getTemporarySecurityCredentials(options)
  528. const expires = getExpiry(data.credentials) || 604_800 // 604 800 is the max value accepted by AWS.
  529. const { uploadId, key, partNumber } = options
  530. // Return an object in the correct shape.
  531. return {
  532. method: 'PUT',
  533. expires,
  534. fields: {},
  535. url: `${await createSignedURL({
  536. accountKey: data.credentials.AccessKeyId,
  537. accountSecret: data.credentials.SecretAccessKey,
  538. sessionToken: data.credentials.SessionToken,
  539. expires,
  540. bucketName: data.bucket,
  541. Region: data.region,
  542. Key: key ?? `${crypto.randomUUID()}-${file.name}`,
  543. uploadId,
  544. partNumber,
  545. })}`,
  546. // Provide content type header required by S3
  547. headers: {
  548. 'Content-Type': file.type as string,
  549. },
  550. }
  551. }
  552. signPart(
  553. file: UppyFile<M, B>,
  554. { uploadId, key, partNumber, signal }: SignPartOptions,
  555. ): Promise<AwsS3UploadParameters> {
  556. this.assertHost('signPart')
  557. throwIfAborted(signal)
  558. if (uploadId == null || key == null || partNumber == null) {
  559. throw new Error(
  560. 'Cannot sign without a key, an uploadId, and a partNumber',
  561. )
  562. }
  563. const filename = encodeURIComponent(key)
  564. return this.#client
  565. .get<AwsS3UploadParameters>(
  566. `s3/multipart/${uploadId}/${partNumber}?key=${filename}`,
  567. { signal },
  568. )
  569. .then(assertServerError)
  570. }
  571. abortMultipartUpload(
  572. file: UppyFile<M, B>,
  573. { key, uploadId, signal }: UploadResultWithSignal,
  574. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  575. oldSignal?: AbortSignal, // TODO: remove in next major
  576. ): Promise<void> {
  577. signal ??= oldSignal // eslint-disable-line no-param-reassign
  578. this.assertHost('abortMultipartUpload')
  579. const filename = encodeURIComponent(key)
  580. const uploadIdEnc = encodeURIComponent(uploadId)
  581. return this.#client
  582. .delete<void>(`s3/multipart/${uploadIdEnc}?key=${filename}`, undefined, {
  583. signal,
  584. })
  585. .then(assertServerError)
  586. }
  587. getUploadParameters(
  588. file: UppyFile<M, B>,
  589. options: RequestOptions,
  590. ): Promise<AwsS3UploadParameters> {
  591. const { meta } = file
  592. const { type, name: filename } = meta
  593. const allowedMetaFields = getAllowedMetaFields(
  594. this.opts.allowedMetaFields,
  595. file.meta,
  596. )
  597. const metadata = getAllowedMetadata({
  598. meta,
  599. allowedMetaFields,
  600. querify: true,
  601. })
  602. const query = new URLSearchParams({ filename, type, ...metadata } as Record<
  603. string,
  604. string
  605. >)
  606. return this.#client.get(`s3/params?${query}`, options)
  607. }
  608. static async uploadPartBytes({
  609. signature: { url, expires, headers, method = 'PUT' },
  610. body,
  611. size = (body as Blob).size,
  612. onProgress,
  613. onComplete,
  614. signal,
  615. }: {
  616. signature: AwsS3UploadParameters
  617. body: FormData | Blob
  618. size?: number
  619. onProgress: any
  620. onComplete: any
  621. signal?: AbortSignal
  622. }): Promise<UploadPartBytesResult> {
  623. throwIfAborted(signal)
  624. if (url == null) {
  625. throw new Error('Cannot upload to an undefined URL')
  626. }
  627. return new Promise((resolve, reject) => {
  628. const xhr = new XMLHttpRequest()
  629. xhr.open(method, url, true)
  630. if (headers) {
  631. Object.keys(headers).forEach((key) => {
  632. xhr.setRequestHeader(key, headers[key])
  633. })
  634. }
  635. xhr.responseType = 'text'
  636. if (typeof expires === 'number') {
  637. xhr.timeout = expires * 1000
  638. }
  639. function onabort() {
  640. xhr.abort()
  641. }
  642. function cleanup() {
  643. signal?.removeEventListener('abort', onabort)
  644. }
  645. signal?.addEventListener('abort', onabort)
  646. xhr.upload.addEventListener('progress', (ev) => {
  647. onProgress(ev)
  648. })
  649. xhr.addEventListener('abort', () => {
  650. cleanup()
  651. reject(createAbortError())
  652. })
  653. xhr.addEventListener('timeout', () => {
  654. cleanup()
  655. const error = new Error('Request has expired')
  656. ;(error as any).source = { status: 403 }
  657. reject(error)
  658. })
  659. xhr.addEventListener('load', (ev) => {
  660. cleanup()
  661. if (
  662. xhr.status === 403 &&
  663. xhr.responseText.includes('<Message>Request has expired</Message>')
  664. ) {
  665. const error = new Error('Request has expired')
  666. ;(error as any).source = xhr
  667. reject(error)
  668. return
  669. }
  670. if (xhr.status < 200 || xhr.status >= 300) {
  671. const error = new Error('Non 2xx')
  672. ;(error as any).source = xhr
  673. reject(error)
  674. return
  675. }
  676. // todo make a proper onProgress API (breaking change)
  677. onProgress?.({ loaded: size, lengthComputable: true })
  678. // NOTE This must be allowed by CORS.
  679. const etag = xhr.getResponseHeader('ETag')
  680. const location = xhr.getResponseHeader('Location')
  681. if (method.toUpperCase() === 'POST' && location === null) {
  682. // Not being able to read the Location header is not a fatal error.
  683. // eslint-disable-next-line no-console
  684. console.warn(
  685. '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.',
  686. )
  687. }
  688. if (etag === null) {
  689. reject(
  690. new Error(
  691. '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.',
  692. ),
  693. )
  694. return
  695. }
  696. onComplete?.(etag)
  697. resolve({
  698. ETag: etag,
  699. ...(location ? { location } : undefined),
  700. })
  701. })
  702. xhr.addEventListener('error', (ev) => {
  703. cleanup()
  704. const error = new Error('Unknown error')
  705. ;(error as any).source = ev.target
  706. reject(error)
  707. })
  708. xhr.send(body)
  709. })
  710. }
  711. #setS3MultipartState = (
  712. file: UppyFile<M, B>,
  713. { key, uploadId }: UploadResult,
  714. ) => {
  715. const cFile = this.uppy.getFile(file.id)
  716. if (cFile == null) {
  717. // file was removed from store
  718. return
  719. }
  720. this.uppy.setFileState(file.id, {
  721. s3Multipart: {
  722. ...(cFile as MultipartFile<M, B>).s3Multipart,
  723. key,
  724. uploadId,
  725. },
  726. } as Partial<MultipartFile<M, B>>)
  727. }
  728. #getFile = (file: UppyFile<M, B>) => {
  729. return this.uppy.getFile(file.id) || file
  730. }
  731. #uploadLocalFile(file: UppyFile<M, B>) {
  732. return new Promise<void | string>((resolve, reject) => {
  733. const onProgress = (bytesUploaded: number, bytesTotal: number) => {
  734. this.uppy.emit('upload-progress', this.uppy.getFile(file.id), {
  735. // @ts-expect-error TODO: figure out if we need this
  736. uploader: this,
  737. bytesUploaded,
  738. bytesTotal,
  739. })
  740. }
  741. const onError = (err: unknown) => {
  742. this.uppy.log(err as Error)
  743. this.uppy.emit('upload-error', file, err as Error)
  744. this.resetUploaderReferences(file.id)
  745. reject(err)
  746. }
  747. const onSuccess = (result: B) => {
  748. const uploadResp = {
  749. body: {
  750. ...result,
  751. },
  752. status: 200,
  753. uploadURL: result.location,
  754. }
  755. this.resetUploaderReferences(file.id)
  756. this.uppy.emit('upload-success', this.#getFile(file), uploadResp)
  757. if (result.location) {
  758. this.uppy.log(`Download ${file.name} from ${result.location}`)
  759. }
  760. resolve()
  761. }
  762. const upload = new MultipartUploader<M, B>(file.data, {
  763. // .bind to pass the file object to each handler.
  764. companionComm: this.#companionCommunicationQueue,
  765. log: (...args: Parameters<Uppy<M, B>['log']>) => this.uppy.log(...args),
  766. getChunkSize:
  767. this.opts.getChunkSize ? this.opts.getChunkSize.bind(this) : null,
  768. onProgress,
  769. onError,
  770. onSuccess,
  771. onPartComplete: (part) => {
  772. this.uppy.emit(
  773. 's3-multipart:part-uploaded',
  774. this.#getFile(file),
  775. part,
  776. )
  777. },
  778. file,
  779. shouldUseMultipart: this.opts.shouldUseMultipart,
  780. ...(file as MultipartFile<M, B>).s3Multipart,
  781. })
  782. this.uploaders[file.id] = upload
  783. const eventManager = new EventManager(this.uppy)
  784. this.uploaderEvents[file.id] = eventManager
  785. eventManager.onFileRemove(file.id, (removed) => {
  786. upload.abort()
  787. this.resetUploaderReferences(file.id, { abort: true })
  788. resolve(`upload ${removed} was removed`)
  789. })
  790. eventManager.onCancelAll(file.id, (options) => {
  791. if (options?.reason === 'user') {
  792. upload.abort()
  793. this.resetUploaderReferences(file.id, { abort: true })
  794. }
  795. resolve(`upload ${file.id} was canceled`)
  796. })
  797. eventManager.onFilePause(file.id, (isPaused) => {
  798. if (isPaused) {
  799. upload.pause()
  800. } else {
  801. upload.start()
  802. }
  803. })
  804. eventManager.onPauseAll(file.id, () => {
  805. upload.pause()
  806. })
  807. eventManager.onResumeAll(file.id, () => {
  808. upload.start()
  809. })
  810. upload.start()
  811. })
  812. }
  813. // eslint-disable-next-line class-methods-use-this
  814. #getCompanionClientArgs(file: UppyFile<M, B>) {
  815. return {
  816. ...file.remote?.body,
  817. protocol: 's3-multipart',
  818. size: file.data.size,
  819. metadata: file.meta,
  820. }
  821. }
  822. #upload = async (fileIDs: string[]) => {
  823. if (fileIDs.length === 0) return undefined
  824. const files = this.uppy.getFilesByIds(fileIDs)
  825. const filesFiltered = filterNonFailedFiles(files)
  826. const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
  827. this.uppy.emit('upload-start', filesToEmit)
  828. const promises = filesFiltered.map((file) => {
  829. if (file.isRemote) {
  830. const getQueue = () => this.requests
  831. this.#setResumableUploadsCapability(false)
  832. const controller = new AbortController()
  833. const removedHandler = (removedFile: UppyFile<M, B>) => {
  834. if (removedFile.id === file.id) controller.abort()
  835. }
  836. this.uppy.on('file-removed', removedHandler)
  837. const uploadPromise = this.uppy
  838. .getRequestClientForFile<RequestClient<M, B>>(file)
  839. .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
  840. signal: controller.signal,
  841. getQueue,
  842. })
  843. this.requests.wrapSyncFunction(
  844. () => {
  845. this.uppy.off('file-removed', removedHandler)
  846. },
  847. { priority: -1 },
  848. )()
  849. return uploadPromise
  850. }
  851. return this.#uploadLocalFile(file)
  852. })
  853. const upload = await Promise.all(promises)
  854. // After the upload is done, another upload may happen with only local files.
  855. // We reset the capability so that the next upload can use resumable uploads.
  856. this.#setResumableUploadsCapability(true)
  857. return upload
  858. }
  859. #setCompanionHeaders = () => {
  860. this.#client.setCompanionHeaders(this.opts.companionHeaders)
  861. }
  862. #setResumableUploadsCapability = (boolean: boolean) => {
  863. const { capabilities } = this.uppy.getState()
  864. this.uppy.setState({
  865. capabilities: {
  866. ...capabilities,
  867. resumableUploads: boolean,
  868. },
  869. })
  870. }
  871. #resetResumableCapability = () => {
  872. this.#setResumableUploadsCapability(true)
  873. }
  874. install(): void {
  875. this.#setResumableUploadsCapability(true)
  876. this.uppy.addPreProcessor(this.#setCompanionHeaders)
  877. this.uppy.addUploader(this.#upload)
  878. this.uppy.on('cancel-all', this.#resetResumableCapability)
  879. }
  880. uninstall(): void {
  881. this.uppy.removePreProcessor(this.#setCompanionHeaders)
  882. this.uppy.removeUploader(this.#upload)
  883. this.uppy.off('cancel-all', this.#resetResumableCapability)
  884. }
  885. }
  886. export type uploadPartBytes = (typeof AwsS3Multipart<
  887. any,
  888. any
  889. >)['uploadPartBytes']