"use strict"; var _nonSecure = require("nanoid/non-secure"); var _companionClient = require("@uppy/companion-client"); var _RateLimitedQueue = require("@uppy/utils/lib/RateLimitedQueue"); const BasePlugin = require("@uppy/core/lib/BasePlugin"); const emitSocketProgress = require("@uppy/utils/lib/emitSocketProgress"); const getSocketHost = require("@uppy/utils/lib/getSocketHost"); const settle = require("@uppy/utils/lib/settle"); const EventTracker = require("@uppy/utils/lib/EventTracker"); const ProgressTimeout = require("@uppy/utils/lib/ProgressTimeout"); const NetworkError = require("@uppy/utils/lib/NetworkError"); const isNetworkError = require("@uppy/utils/lib/isNetworkError"); const packageJson = { "version": "2.1.3" }; const locale = require("./locale.js"); function buildResponseError(xhr, err) { let error = err; // No error message if (!error) error = new Error('Upload error'); // Got an error message string if (typeof error === 'string') error = new Error(error); // Got something else if (!(error instanceof Error)) { error = Object.assign(new Error('Upload error'), { data: error }); } if (isNetworkError(xhr)) { error = new NetworkError(error, xhr); return error; } error.request = xhr; return error; } /** * Set `data.type` in the blob to `file.meta.type`, * because we might have detected a more accurate file type in Uppy * https://stackoverflow.com/a/50875615 * * @param {object} file File object with `data`, `size` and `meta` properties * @returns {object} blob updated with the new `type` set from `file.meta.type` */ function setTypeInBlob(file) { const dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type); return dataWithUpdatedType; } class XHRUpload extends BasePlugin { // eslint-disable-next-line global-require constructor(uppy, opts) { super(uppy, opts); this.type = 'uploader'; this.id = this.opts.id || 'XHRUpload'; this.title = 'XHRUpload'; this.defaultLocale = locale; // Default options const defaultOptions = { formData: true, fieldName: opts.bundle ? 'files[]' : 'file', method: 'post', metaFields: null, responseUrlFieldName: 'url', bundle: false, headers: {}, timeout: 30 * 1000, limit: 5, withCredentials: false, responseType: '', /** * @param {string} responseText the response body string */ getResponseData(responseText) { let parsedResponse = {}; try { parsedResponse = JSON.parse(responseText); } catch (err) { uppy.log(err); } return parsedResponse; }, /** * * @param {string} _ the response body string * @param {XMLHttpRequest | respObj} response the response object (XHR or similar) */ getResponseError(_, response) { let error = new Error('Upload error'); if (isNetworkError(response)) { error = new NetworkError(error, response); } return error; }, /** * Check if the response from the upload endpoint indicates that the upload was successful. * * @param {number} status the response status code */ validateStatus(status) { return status >= 200 && status < 300; } }; this.opts = { ...defaultOptions, ...opts }; this.i18nInit(); this.handleUpload = this.handleUpload.bind(this); // Simultaneous upload limiting is shared across all uploads with this plugin. if (_RateLimitedQueue.internalRateLimitedQueue in this.opts) { this.requests = this.opts[_RateLimitedQueue.internalRateLimitedQueue]; } else { this.requests = new _RateLimitedQueue.RateLimitedQueue(this.opts.limit); } if (this.opts.bundle && !this.opts.formData) { throw new Error('`opts.formData` must be true when `opts.bundle` is enabled.'); } this.uploaderEvents = Object.create(null); } getOptions(file) { const overrides = this.uppy.getState().xhrUpload; const { headers } = this.opts; const opts = { ...this.opts, ...(overrides || {}), ...(file.xhrUpload || {}), headers: {} }; // Support for `headers` as a function, only in the XHRUpload settings. // Options set by other plugins in Uppy state or on the files themselves are still merged in afterward. // // ```js // headers: (file) => ({ expires: file.meta.expires }) // ``` if (typeof headers === 'function') { opts.headers = headers(file); } else { Object.assign(opts.headers, this.opts.headers); } if (overrides) { Object.assign(opts.headers, overrides.headers); } if (file.xhrUpload) { Object.assign(opts.headers, file.xhrUpload.headers); } return opts; } // eslint-disable-next-line class-methods-use-this addMetadata(formData, meta, opts) { const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields : Object.keys(meta); // Send along all fields by default. metaFields.forEach(item => { formData.append(item, meta[item]); }); } createFormDataUpload(file, opts) { const formPost = new FormData(); this.addMetadata(formPost, file.meta, opts); const dataWithUpdatedType = setTypeInBlob(file); if (file.name) { formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name); } else { formPost.append(opts.fieldName, dataWithUpdatedType); } return formPost; } createBundledUpload(files, opts) { const formPost = new FormData(); const { meta } = this.uppy.getState(); this.addMetadata(formPost, meta, opts); files.forEach(file => { const options = this.getOptions(file); const dataWithUpdatedType = setTypeInBlob(file); if (file.name) { formPost.append(options.fieldName, dataWithUpdatedType, file.name); } else { formPost.append(options.fieldName, dataWithUpdatedType); } }); return formPost; } upload(file, current, total) { const opts = this.getOptions(file); this.uppy.log(`uploading ${current} of ${total}`); return new Promise((resolve, reject) => { this.uppy.emit('upload-started', file); const data = opts.formData ? this.createFormDataUpload(file, opts) : file.data; const xhr = new XMLHttpRequest(); this.uploaderEvents[file.id] = new EventTracker(this.uppy); let queuedRequest; const timer = new ProgressTimeout(opts.timeout, () => { xhr.abort(); queuedRequest.done(); const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(opts.timeout / 1000) })); this.uppy.emit('upload-error', file, error); reject(error); }); const id = (0, _nonSecure.nanoid)(); xhr.upload.addEventListener('loadstart', () => { this.uppy.log(`[XHRUpload] ${id} started`); }); xhr.upload.addEventListener('progress', ev => { this.uppy.log(`[XHRUpload] ${id} progress: ${ev.loaded} / ${ev.total}`); // Begin checking for timeouts when progress starts, instead of loading, // to avoid timing out requests on browser concurrency queue timer.progress(); if (ev.lengthComputable) { this.uppy.emit('upload-progress', file, { uploader: this, bytesUploaded: ev.loaded, bytesTotal: ev.total }); } }); xhr.addEventListener('load', () => { this.uppy.log(`[XHRUpload] ${id} finished`); timer.done(); queuedRequest.done(); if (this.uploaderEvents[file.id]) { this.uploaderEvents[file.id].remove(); this.uploaderEvents[file.id] = null; } if (opts.validateStatus(xhr.status, xhr.responseText, xhr)) { const body = opts.getResponseData(xhr.responseText, xhr); const uploadURL = body[opts.responseUrlFieldName]; const uploadResp = { status: xhr.status, body, uploadURL }; this.uppy.emit('upload-success', file, uploadResp); if (uploadURL) { this.uppy.log(`Download ${file.name} from ${uploadURL}`); } return resolve(file); } const body = opts.getResponseData(xhr.responseText, xhr); const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr)); const response = { status: xhr.status, body }; this.uppy.emit('upload-error', file, error, response); return reject(error); }); xhr.addEventListener('error', () => { this.uppy.log(`[XHRUpload] ${id} errored`); timer.done(); queuedRequest.done(); if (this.uploaderEvents[file.id]) { this.uploaderEvents[file.id].remove(); this.uploaderEvents[file.id] = null; } const error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr)); this.uppy.emit('upload-error', file, error); return reject(error); }); xhr.open(opts.method.toUpperCase(), opts.endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType` // before `open()` is called. xhr.withCredentials = opts.withCredentials; if (opts.responseType !== '') { xhr.responseType = opts.responseType; } queuedRequest = this.requests.run(() => { this.uppy.emit('upload-started', file); // When using an authentication system like JWT, the bearer token goes as a header. This // header needs to be fresh each time the token is refreshed so computing and setting the // headers just before the upload starts enables this kind of authentication to work properly. // Otherwise, half-way through the list of uploads the token could be stale and the upload would fail. const currentOpts = this.getOptions(file); Object.keys(currentOpts.headers).forEach(header => { xhr.setRequestHeader(header, currentOpts.headers[header]); }); xhr.send(data); return () => { timer.done(); xhr.abort(); }; }); this.onFileRemove(file.id, () => { queuedRequest.abort(); reject(new Error('File removed')); }); this.onCancelAll(file.id, _ref => { let { reason } = _ref; if (reason === 'user') { queuedRequest.abort(); } reject(new Error('Upload cancelled')); }); }); } uploadRemote(file) { const opts = this.getOptions(file); return new Promise((resolve, reject) => { this.uppy.emit('upload-started', file); const fields = {}; const metaFields = Array.isArray(opts.metaFields) ? opts.metaFields // Send along all fields by default. : Object.keys(file.meta); metaFields.forEach(name => { fields[name] = file.meta[name]; }); const Client = file.remote.providerOptions.provider ? _companionClient.Provider : _companionClient.RequestClient; const client = new Client(this.uppy, file.remote.providerOptions); client.post(file.remote.url, { ...file.remote.body, endpoint: opts.endpoint, size: file.data.size, fieldname: opts.fieldName, metadata: fields, httpMethod: opts.method, useFormData: opts.formData, headers: opts.headers }).then(res => { const { token } = res; const host = getSocketHost(file.remote.companionUrl); const socket = new _companionClient.Socket({ target: `${host}/api/${token}`, autoOpen: false }); this.uploaderEvents[file.id] = new EventTracker(this.uppy); let queuedRequest; this.onFileRemove(file.id, () => { socket.send('cancel', {}); queuedRequest.abort(); resolve(`upload ${file.id} was removed`); }); this.onCancelAll(file.id, function (_temp) { let { reason } = _temp === void 0 ? {} : _temp; if (reason === 'user') { socket.send('cancel', {}); queuedRequest.abort(); } resolve(`upload ${file.id} was canceled`); }); this.onRetry(file.id, () => { socket.send('pause', {}); socket.send('resume', {}); }); this.onRetryAll(file.id, () => { socket.send('pause', {}); socket.send('resume', {}); }); socket.on('progress', progressData => emitSocketProgress(this, progressData, file)); socket.on('success', data => { const body = opts.getResponseData(data.response.responseText, data.response); const uploadURL = body[opts.responseUrlFieldName]; const uploadResp = { status: data.response.status, body, uploadURL }; this.uppy.emit('upload-success', file, uploadResp); queuedRequest.done(); if (this.uploaderEvents[file.id]) { this.uploaderEvents[file.id].remove(); this.uploaderEvents[file.id] = null; } return resolve(); }); socket.on('error', errData => { const resp = errData.response; const error = resp ? opts.getResponseError(resp.responseText, resp) : Object.assign(new Error(errData.error.message), { cause: errData.error }); this.uppy.emit('upload-error', file, error); queuedRequest.done(); if (this.uploaderEvents[file.id]) { this.uploaderEvents[file.id].remove(); this.uploaderEvents[file.id] = null; } reject(error); }); queuedRequest = this.requests.run(() => { socket.open(); if (file.isPaused) { socket.send('pause', {}); } return () => socket.close(); }); }).catch(err => { this.uppy.emit('upload-error', file, err); reject(err); }); }); } uploadBundle(files) { return new Promise((resolve, reject) => { const { endpoint } = this.opts; const { method } = this.opts; const optsFromState = this.uppy.getState().xhrUpload; const formData = this.createBundledUpload(files, { ...this.opts, ...(optsFromState || {}) }); const xhr = new XMLHttpRequest(); const emitError = error => { files.forEach(file => { this.uppy.emit('upload-error', file, error); }); }; const timer = new ProgressTimeout(this.opts.timeout, () => { xhr.abort(); const error = new Error(this.i18n('timedOut', { seconds: Math.ceil(this.opts.timeout / 1000) })); emitError(error); reject(error); }); xhr.upload.addEventListener('loadstart', () => { this.uppy.log('[XHRUpload] started uploading bundle'); timer.progress(); }); xhr.upload.addEventListener('progress', ev => { timer.progress(); if (!ev.lengthComputable) return; files.forEach(file => { this.uppy.emit('upload-progress', file, { uploader: this, bytesUploaded: ev.loaded / ev.total * file.size, bytesTotal: file.size }); }); }); xhr.addEventListener('load', ev => { timer.done(); if (this.opts.validateStatus(ev.target.status, xhr.responseText, xhr)) { const body = this.opts.getResponseData(xhr.responseText, xhr); const uploadResp = { status: ev.target.status, body }; files.forEach(file => { this.uppy.emit('upload-success', file, uploadResp); }); return resolve(); } const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error'); error.request = xhr; emitError(error); return reject(error); }); xhr.addEventListener('error', () => { timer.done(); const error = this.opts.getResponseError(xhr.responseText, xhr) || new Error('Upload error'); emitError(error); return reject(error); }); this.uppy.on('cancel-all', function (_temp2) { let { reason } = _temp2 === void 0 ? {} : _temp2; if (reason !== 'user') return; timer.done(); xhr.abort(); }); xhr.open(method.toUpperCase(), endpoint, true); // IE10 does not allow setting `withCredentials` and `responseType` // before `open()` is called. xhr.withCredentials = this.opts.withCredentials; if (this.opts.responseType !== '') { xhr.responseType = this.opts.responseType; } Object.keys(this.opts.headers).forEach(header => { xhr.setRequestHeader(header, this.opts.headers[header]); }); xhr.send(formData); files.forEach(file => { this.uppy.emit('upload-started', file); }); }); } uploadFiles(files) { const promises = files.map((file, i) => { const current = parseInt(i, 10) + 1; const total = files.length; if (file.error) { return Promise.reject(new Error(file.error)); } if (file.isRemote) { return this.uploadRemote(file, current, total); } return this.upload(file, current, total); }); return settle(promises); } onFileRemove(fileID, cb) { this.uploaderEvents[fileID].on('file-removed', file => { if (fileID === file.id) cb(file.id); }); } onRetry(fileID, cb) { this.uploaderEvents[fileID].on('upload-retry', targetFileID => { if (fileID === targetFileID) { cb(); } }); } onRetryAll(fileID, cb) { this.uploaderEvents[fileID].on('retry-all', () => { if (!this.uppy.getFile(fileID)) return; cb(); }); } onCancelAll(fileID, eventHandler) { var _this = this; this.uploaderEvents[fileID].on('cancel-all', function () { if (!_this.uppy.getFile(fileID)) return; eventHandler(...arguments); }); } handleUpload(fileIDs) { if (fileIDs.length === 0) { this.uppy.log('[XHRUpload] No files to upload!'); return Promise.resolve(); } // No limit configured by the user, and no RateLimitedQueue passed in by a "parent" plugin // (basically just AwsS3) using the internal symbol if (this.opts.limit === 0 && !this.opts[_RateLimitedQueue.internalRateLimitedQueue]) { this.uppy.log('[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0', 'warning'); } this.uppy.log('[XHRUpload] Uploading...'); const files = fileIDs.map(fileID => this.uppy.getFile(fileID)); if (this.opts.bundle) { // if bundle: true, we don’t support remote uploads const isSomeFileRemote = files.some(file => file.isRemote); if (isSomeFileRemote) { throw new Error('Can’t upload remote files when the `bundle: true` option is set'); } if (typeof this.opts.headers === 'function') { throw new TypeError('`headers` may not be a function when the `bundle: true` option is set'); } return this.uploadBundle(files); } return this.uploadFiles(files).then(() => null); } install() { if (this.opts.bundle) { const { capabilities } = this.uppy.getState(); this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: false } }); } this.uppy.addUploader(this.handleUpload); } uninstall() { if (this.opts.bundle) { const { capabilities } = this.uppy.getState(); this.uppy.setState({ capabilities: { ...capabilities, individualCancellation: true } }); } this.uppy.removeUploader(this.handleUpload); } } XHRUpload.VERSION = packageJson.version; module.exports = XHRUpload;