index.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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 breakig change, so it is
  19. * planned for Uppy v2.
  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. // If global `URL` constructor is available, use it
  28. const URL_ = typeof URL === 'function' ? URL : require('url-parse')
  29. const { Plugin } = require('@uppy/core')
  30. const Translator = require('@uppy/utils/lib/Translator')
  31. const RateLimitedQueue = require('@uppy/utils/lib/RateLimitedQueue')
  32. const settle = require('@uppy/utils/lib/settle')
  33. const hasProperty = require('@uppy/utils/lib/hasProperty')
  34. const { RequestClient } = require('@uppy/companion-client')
  35. const qsStringify = require('qs-stringify')
  36. const MiniXHRUpload = require('./MiniXHRUpload')
  37. function resolveUrl (origin, link) {
  38. return new URL_(link, origin).toString()
  39. }
  40. function isXml (content, xhr) {
  41. const rawContentType = (xhr.headers ? xhr.headers['content-type'] : xhr.getResponseHeader('Content-Type'))
  42. if (rawContentType === null) {
  43. return false
  44. }
  45. // Get rid of mime parameters like charset=utf-8
  46. const contentType = rawContentType.replace(/;.*$/, '').toLowerCase()
  47. if (typeof contentType === 'string') {
  48. if (contentType === 'application/xml' || contentType === 'text/xml') {
  49. return true
  50. }
  51. // GCS uses text/html for some reason
  52. // https://github.com/transloadit/uppy/issues/896
  53. if (contentType === 'text/html' && /^<\?xml /.test(content)) {
  54. return true
  55. }
  56. }
  57. return false
  58. }
  59. function getXmlValue (source, key) {
  60. const start = source.indexOf(`<${key}>`)
  61. const end = source.indexOf(`</${key}>`, start)
  62. return start !== -1 && end !== -1
  63. ? source.slice(start + key.length + 2, end)
  64. : ''
  65. }
  66. function assertServerError (res) {
  67. if (res && res.error) {
  68. const error = new Error(res.message)
  69. Object.assign(error, res.error)
  70. throw error
  71. }
  72. return res
  73. }
  74. // warning deduplication flag: see `getResponseData()` XHRUpload option definition
  75. let warnedSuccessActionStatus = false
  76. module.exports = class AwsS3 extends Plugin {
  77. static VERSION = require('../package.json').version
  78. constructor (uppy, opts) {
  79. super(uppy, opts)
  80. this.type = 'uploader'
  81. this.id = this.opts.id || 'AwsS3'
  82. this.title = 'AWS S3'
  83. this.defaultLocale = {
  84. strings: {
  85. preparingUpload: 'Preparing upload...'
  86. }
  87. }
  88. const defaultOptions = {
  89. timeout: 30 * 1000,
  90. limit: 0,
  91. metaFields: [], // have to opt in
  92. getUploadParameters: this.getUploadParameters.bind(this)
  93. }
  94. this.opts = { ...defaultOptions, ...opts }
  95. this.i18nInit()
  96. this.client = new RequestClient(uppy, opts)
  97. this.handleUpload = this.handleUpload.bind(this)
  98. this.requests = new RateLimitedQueue(this.opts.limit)
  99. }
  100. setOptions (newOpts) {
  101. super.setOptions(newOpts)
  102. this.i18nInit()
  103. }
  104. i18nInit () {
  105. this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale])
  106. this.i18n = this.translator.translate.bind(this.translator)
  107. this.setPluginState() // so that UI re-renders and we see the updated locale
  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 = encodeURIComponent(file.meta.name)
  114. const type = encodeURIComponent(file.meta.type)
  115. const metadata = {}
  116. this.opts.metaFields.forEach((key) => {
  117. if (file.meta[key] != null) {
  118. metadata[key] = file.meta[key].toString()
  119. }
  120. })
  121. const query = qsStringify({ filename, type, metadata })
  122. return this.client.get(`s3/params?${query}`)
  123. .then(assertServerError)
  124. }
  125. validateParameters (file, params) {
  126. const valid = typeof params === 'object' && params &&
  127. typeof params.url === 'string' &&
  128. (typeof params.fields === 'object' || params.fields == null) &&
  129. (params.method == null || /^(put|post)$/i.test(params.method))
  130. if (!valid) {
  131. const err = new TypeError(`AwsS3: got incorrect result from 'getUploadParameters()' for file '${file.name}', expected an object '{ url, method, fields, headers }'.\nSee https://uppy.io/docs/aws-s3/#getUploadParameters-file for more on the expected format.`)
  132. console.error(err)
  133. throw err
  134. }
  135. }
  136. handleUpload (fileIDs) {
  137. /**
  138. * keep track of `getUploadParameters()` responses
  139. * so we can cancel the calls individually using just a file ID
  140. * @type {Object.<string, Promise>}
  141. */
  142. const paramsPromises = Object.create(null)
  143. function onremove (file) {
  144. const { id } = file
  145. if (hasProperty(paramsPromises, id)) {
  146. paramsPromises[id].abort()
  147. }
  148. }
  149. this.uppy.on('file-removed', onremove)
  150. fileIDs.forEach((id) => {
  151. const file = this.uppy.getFile(id)
  152. this.uppy.emit('upload-started', file)
  153. })
  154. // Wrapping rate-limited opts.getUploadParameters in a Promise takes some boilerplate!
  155. const getUploadParameters = this.requests.wrapPromiseFunction((file) => {
  156. return this.opts.getUploadParameters(file)
  157. })
  158. const numberOfFiles = fileIDs.length
  159. return settle(fileIDs.map((id, index) => {
  160. const file = this.uppy.getFile(id)
  161. paramsPromises[id] = getUploadParameters(file)
  162. return paramsPromises[id].then((params) => {
  163. delete paramsPromises[id]
  164. this.validateParameters(file, params)
  165. const {
  166. method = 'post',
  167. url,
  168. fields,
  169. headers
  170. } = params
  171. const xhrOpts = {
  172. method,
  173. formData: method.toLowerCase() === 'post',
  174. endpoint: url,
  175. metaFields: fields ? Object.keys(fields) : []
  176. }
  177. if (headers) {
  178. xhrOpts.headers = headers
  179. }
  180. this.uppy.setFileState(file.id, {
  181. meta: { ...file.meta, ...fields },
  182. xhrUpload: xhrOpts
  183. })
  184. return this._uploader.uploadFile(file.id, index, numberOfFiles)
  185. }).catch((error) => {
  186. delete paramsPromises[id]
  187. this.uppy.emit('upload-error', file, error)
  188. })
  189. })).then((settled) => {
  190. // cleanup.
  191. this.uppy.off('file-removed', onremove)
  192. return settled
  193. })
  194. }
  195. install () {
  196. const uppy = this.uppy
  197. this.uppy.addUploader(this.handleUpload)
  198. // Get the response data from a successful XMLHttpRequest instance.
  199. // `content` is the S3 response as a string.
  200. // `xhr` is the XMLHttpRequest instance.
  201. function defaultGetResponseData (content, xhr) {
  202. const opts = this
  203. // If no response, we've hopefully done a PUT request to the file
  204. // in the bucket on its full URL.
  205. if (!isXml(content, xhr)) {
  206. if (opts.method.toUpperCase() === 'POST') {
  207. if (!warnedSuccessActionStatus) {
  208. 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')
  209. warnedSuccessActionStatus = true
  210. }
  211. // The responseURL won't contain the object key. Give up.
  212. return { location: null }
  213. }
  214. // responseURL is not available in older browsers.
  215. if (!xhr.responseURL) {
  216. return { location: null }
  217. }
  218. // Trim the query string because it's going to be a bunch of presign
  219. // parameters for a PUT request—doing a GET request with those will
  220. // always result in an error
  221. return { location: xhr.responseURL.replace(/\?.*$/, '') }
  222. }
  223. return {
  224. // Some S3 alternatives do not reply with an absolute URL.
  225. // Eg DigitalOcean Spaces uses /$bucketName/xyz
  226. location: resolveUrl(xhr.responseURL, getXmlValue(content, 'Location')),
  227. bucket: getXmlValue(content, 'Bucket'),
  228. key: getXmlValue(content, 'Key'),
  229. etag: getXmlValue(content, 'ETag')
  230. }
  231. }
  232. // Get the error data from a failed XMLHttpRequest instance.
  233. // `content` is the S3 response as a string.
  234. // `xhr` is the XMLHttpRequest instance.
  235. function defaultGetResponseError (content, xhr) {
  236. // If no response, we don't have a specific error message, use the default.
  237. if (!isXml(content, xhr)) {
  238. return
  239. }
  240. const error = getXmlValue(content, 'Message')
  241. return new Error(error)
  242. }
  243. const xhrOptions = {
  244. fieldName: 'file',
  245. responseUrlFieldName: 'location',
  246. timeout: this.opts.timeout,
  247. // Share the rate limiting queue with XHRUpload.
  248. __queue: this.requests,
  249. responseType: 'text',
  250. getResponseData: this.opts.getResponseData || defaultGetResponseData,
  251. getResponseError: defaultGetResponseError
  252. }
  253. // Revert to `this.uppy.use(XHRUpload)` once the big comment block at the top of
  254. // this file is solved
  255. this._uploader = new MiniXHRUpload(this.uppy, xhrOptions)
  256. this._uploader.i18n = this.i18n
  257. }
  258. uninstall () {
  259. this.uppy.removePreProcessor(this.handleUpload)
  260. }
  261. }