index.js 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. /**
  2. * This plugin is currently a A Big Hack™! The core reason for that is how this plugin
  3. * interacts with Uppy's current pipeline design. The pipeline can handle files in steps,
  4. * including preprocessing, uploading, and postprocessing steps. This plugin initially
  5. * was designed to do its work in a preprocessing step, and let XHRUpload deal with the
  6. * actual file upload as an uploading step. However, Uppy runs steps on all files at once,
  7. * sequentially: first, all files go through a preprocessing step, then, once they are all
  8. * done, they go through the uploading step.
  9. *
  10. * For S3, this causes severely broken behaviour when users upload many files. The
  11. * preprocessing step will request S3 upload URLs that are valid for a short time only,
  12. * but it has to do this for _all_ files, which can take a long time if there are hundreds
  13. * or even thousands of files. By the time the uploader step starts, the first URLs may
  14. * already have expired. If not, the uploading might take such a long time that later URLs
  15. * will expire before some files can be uploaded.
  16. *
  17. * The long-term solution to this problem is to change the upload pipeline so that files
  18. * can be sent to the next step individually. That requires a breaking change, so it is
  19. * planned for some future Uppy version.
  20. *
  21. * In the mean time, this plugin is stuck with a hackier approach: the necessary parts
  22. * of the XHRUpload implementation were copied into this plugin, as the MiniXHRUpload
  23. * class, and this plugin calls into it immediately once it receives an upload URL.
  24. * This isn't as nicely modular as we'd like and requires us to maintain two copies of
  25. * the XHRUpload code, but at least it's not horrifically broken :)
  26. */
  27. const BasePlugin = require('@uppy/core/lib/BasePlugin')
  28. const { RateLimitedQueue, internalRateLimitedQueue } = require('@uppy/utils/lib/RateLimitedQueue')
  29. const { RequestClient } = require('@uppy/companion-client')
  30. const MiniXHRUpload = require('./MiniXHRUpload')
  31. const isXml = require('./isXml')
  32. const locale = require('./locale')
  33. function resolveUrl (origin, link) {
  34. return new URL(link, origin || undefined).toString()
  35. }
  36. /**
  37. * Get the contents of a named tag in an XML source string.
  38. *
  39. * @param {string} source - The XML source string.
  40. * @param {string} tagName - The name of the tag.
  41. * @returns {string} The contents of the tag, or the empty string if the tag does not exist.
  42. */
  43. function getXmlValue (source, tagName) {
  44. const start = source.indexOf(`<${tagName}>`)
  45. const end = source.indexOf(`</${tagName}>`, start)
  46. return start !== -1 && end !== -1
  47. ? source.slice(start + tagName.length + 2, end)
  48. : ''
  49. }
  50. function assertServerError (res) {
  51. if (res && res.error) {
  52. const error = new Error(res.message)
  53. Object.assign(error, res.error)
  54. throw error
  55. }
  56. return res
  57. }
  58. function validateParameters (file, params) {
  59. const valid = params != null
  60. && typeof params.url === 'string'
  61. && (typeof params.fields === 'object' || params.fields == null)
  62. if (!valid) {
  63. const err = new TypeError(`AwsS3: got incorrect result from 'getUploadParameters()' for file '${file.name}', expected an object '{ url, method, fields, headers }' but got '${JSON.stringify(params)}' instead.\nSee https://uppy.io/docs/aws-s3/#getUploadParameters-file for more on the expected format.`)
  64. throw err
  65. }
  66. const methodIsValid = params.method == null || /^p(u|os)t$/i.test(params.method)
  67. if (!methodIsValid) {
  68. const err = new TypeError(`AwsS3: got incorrect method from 'getUploadParameters()' for file '${file.name}', expected 'put' or 'post' but got '${params.method}' instead.\nSee https://uppy.io/docs/aws-s3/#getUploadParameters-file for more on the expected format.`)
  69. throw err
  70. }
  71. }
  72. // Get the error data from a failed XMLHttpRequest instance.
  73. // `content` is the S3 response as a string.
  74. // `xhr` is the XMLHttpRequest instance.
  75. function defaultGetResponseError (content, xhr) {
  76. // If no response, we don't have a specific error message, use the default.
  77. if (!isXml(content, xhr)) {
  78. return undefined
  79. }
  80. const error = getXmlValue(content, 'Message')
  81. return new Error(error)
  82. }
  83. // warning deduplication flag: see `getResponseData()` XHRUpload option definition
  84. let warnedSuccessActionStatus = false
  85. module.exports = class AwsS3 extends BasePlugin {
  86. // eslint-disable-next-line global-require
  87. static VERSION = require('../package.json').version
  88. #client
  89. #requests
  90. #uploader
  91. constructor (uppy, opts) {
  92. super(uppy, opts)
  93. this.type = 'uploader'
  94. this.id = this.opts.id || 'AwsS3'
  95. this.title = 'AWS S3'
  96. this.defaultLocale = locale
  97. const defaultOptions = {
  98. timeout: 30 * 1000,
  99. limit: 0,
  100. metaFields: [], // have to opt in
  101. getUploadParameters: this.getUploadParameters.bind(this),
  102. }
  103. this.opts = { ...defaultOptions, ...opts }
  104. // TODO: remove i18n once we can depend on XHRUpload instead of MiniXHRUpload
  105. this.i18nInit()
  106. this.#client = new RequestClient(uppy, opts)
  107. this.#requests = new RateLimitedQueue(this.opts.limit)
  108. }
  109. getUploadParameters (file) {
  110. if (!this.opts.companionUrl) {
  111. throw new Error('Expected a `companionUrl` option containing a Companion address.')
  112. }
  113. const filename = file.meta.name
  114. const { type } = file.meta
  115. const metadata = Object.fromEntries(
  116. this.opts.metaFields
  117. .filter(key => file.meta[key] != null)
  118. .map(key => [`metadata[${key}]`, file.meta[key].toString()]),
  119. )
  120. const query = new URLSearchParams({ filename, type, ...metadata })
  121. return this.#client.get(`s3/params?${query}`)
  122. .then(assertServerError)
  123. }
  124. #handleUpload = (fileIDs) => {
  125. /**
  126. * keep track of `getUploadParameters()` responses
  127. * so we can cancel the calls individually using just a file ID
  128. *
  129. * @type {object.<string, Promise>}
  130. */
  131. const paramsPromises = Object.create(null)
  132. function onremove (file) {
  133. const { id } = file
  134. paramsPromises[id]?.abort()
  135. }
  136. this.uppy.on('file-removed', onremove)
  137. fileIDs.forEach((id) => {
  138. const file = this.uppy.getFile(id)
  139. this.uppy.emit('upload-started', file)
  140. })
  141. const getUploadParameters = this.#requests.wrapPromiseFunction((file) => {
  142. return this.opts.getUploadParameters(file)
  143. })
  144. const numberOfFiles = fileIDs.length
  145. return Promise.allSettled(fileIDs.map((id, index) => {
  146. paramsPromises[id] = getUploadParameters(this.uppy.getFile(id))
  147. return paramsPromises[id].then((params) => {
  148. delete paramsPromises[id]
  149. const file = this.uppy.getFile(id)
  150. validateParameters(file, params)
  151. const {
  152. method = 'post',
  153. url,
  154. fields,
  155. headers,
  156. } = params
  157. const xhrOpts = {
  158. method,
  159. formData: method.toLowerCase() === 'post',
  160. endpoint: url,
  161. metaFields: fields ? Object.keys(fields) : [],
  162. }
  163. if (headers) {
  164. xhrOpts.headers = headers
  165. }
  166. this.uppy.setFileState(file.id, {
  167. meta: { ...file.meta, ...fields },
  168. xhrUpload: xhrOpts,
  169. })
  170. return this.#uploader.uploadFile(file.id, index, numberOfFiles)
  171. }).catch((error) => {
  172. delete paramsPromises[id]
  173. const file = this.uppy.getFile(id)
  174. this.uppy.emit('upload-error', file, error)
  175. return Promise.reject(error)
  176. })
  177. })).finally(() => {
  178. // cleanup.
  179. this.uppy.off('file-removed', onremove)
  180. })
  181. }
  182. install () {
  183. const { uppy } = this
  184. uppy.addUploader(this.#handleUpload)
  185. // Get the response data from a successful XMLHttpRequest instance.
  186. // `content` is the S3 response as a string.
  187. // `xhr` is the XMLHttpRequest instance.
  188. function defaultGetResponseData (content, xhr) {
  189. const opts = this
  190. // If no response, we've hopefully done a PUT request to the file
  191. // in the bucket on its full URL.
  192. if (!isXml(content, xhr)) {
  193. if (opts.method.toUpperCase() === 'POST') {
  194. if (!warnedSuccessActionStatus) {
  195. uppy.log('[AwsS3] No response data found, make sure to set the success_action_status AWS SDK option to 201. See https://uppy.io/docs/aws-s3/#POST-Uploads', 'warning')
  196. warnedSuccessActionStatus = true
  197. }
  198. // The responseURL won't contain the object key. Give up.
  199. return { location: null }
  200. }
  201. // responseURL is not available in older browsers.
  202. if (!xhr.responseURL) {
  203. return { location: null }
  204. }
  205. // Trim the query string because it's going to be a bunch of presign
  206. // parameters for a PUT request—doing a GET request with those will
  207. // always result in an error
  208. return { location: xhr.responseURL.replace(/\?.*$/, '') }
  209. }
  210. return {
  211. // Some S3 alternatives do not reply with an absolute URL.
  212. // Eg DigitalOcean Spaces uses /$bucketName/xyz
  213. location: resolveUrl(xhr.responseURL, getXmlValue(content, 'Location')),
  214. bucket: getXmlValue(content, 'Bucket'),
  215. key: getXmlValue(content, 'Key'),
  216. etag: getXmlValue(content, 'ETag'),
  217. }
  218. }
  219. const xhrOptions = {
  220. fieldName: 'file',
  221. responseUrlFieldName: 'location',
  222. timeout: this.opts.timeout,
  223. // Share the rate limiting queue with XHRUpload.
  224. [internalRateLimitedQueue]: this.#requests,
  225. responseType: 'text',
  226. getResponseData: this.opts.getResponseData || defaultGetResponseData,
  227. getResponseError: defaultGetResponseError,
  228. }
  229. // TODO: remove i18n once we can depend on XHRUpload instead of MiniXHRUpload
  230. xhrOptions.i18n = this.i18n
  231. // Revert to `uppy.use(XHRUpload)` once the big comment block at the top of
  232. // this file is solved
  233. this.#uploader = new MiniXHRUpload(uppy, xhrOptions)
  234. }
  235. uninstall () {
  236. this.uppy.removeUploader(this.#handleUpload)
  237. }
  238. }