diff --git a/app/assets/javascripts/discourse-shims.js b/app/assets/javascripts/discourse-shims.js index 71ce8478e37..3f038c12d85 100644 --- a/app/assets/javascripts/discourse-shims.js +++ b/app/assets/javascripts/discourse-shims.js @@ -32,7 +32,7 @@ define("@popperjs/core", ["exports"], function (__exports__) { define("@uppy/core", ["exports"], function (__exports__) { __exports__.default = window.Uppy.Core; - __exports__.Plugin = window.Uppy.Plugin; + __exports__.BasePlugin = window.Uppy.BasePlugin; }); define("@uppy/aws-s3", ["exports"], function (__exports__) { diff --git a/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js b/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js index 4cd0cea0476..3e74b36252f 100644 --- a/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js +++ b/app/assets/javascripts/discourse/app/lib/uppy-checksum-plugin.js @@ -1,8 +1,8 @@ -import { Plugin } from "@uppy/core"; +import { BasePlugin } from "@uppy/core"; import { warn } from "@ember/debug"; import { Promise } from "rsvp"; -export default class UppyChecksum extends Plugin { +export default class UppyChecksum extends BasePlugin { constructor(uppy, opts) { super(uppy, opts); this.id = opts.id || "uppy-checksum"; diff --git a/app/assets/javascripts/discourse/app/lib/uppy-media-optimization-plugin.js b/app/assets/javascripts/discourse/app/lib/uppy-media-optimization-plugin.js index 3ba4cc8fca0..dcf3c3121ba 100644 --- a/app/assets/javascripts/discourse/app/lib/uppy-media-optimization-plugin.js +++ b/app/assets/javascripts/discourse/app/lib/uppy-media-optimization-plugin.js @@ -1,8 +1,8 @@ -import { Plugin } from "@uppy/core"; +import { BasePlugin } from "@uppy/core"; import { warn } from "@ember/debug"; import { Promise } from "rsvp"; -export default class UppyMediaOptimization extends Plugin { +export default class UppyMediaOptimization extends BasePlugin { constructor(uppy, opts) { super(uppy, opts); this.id = opts.id || "uppy-media-optimization"; @@ -30,7 +30,10 @@ export default class UppyMediaOptimization extends Plugin { id: "discourse.uppy-media-optimization", }); } else { - this.uppy.setFileState(fileId, { data: optimizedFile }); + this.uppy.setFileState(fileId, { + data: optimizedFile, + size: optimizedFile.size, + }); } this.uppy.emit("preprocess-complete", this.pluginClass, file); }) diff --git a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js index d7b88d7de43..97cd755e4a3 100644 --- a/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js +++ b/app/assets/javascripts/discourse/app/mixins/composer-upload-uppy.js @@ -1,10 +1,12 @@ import Mixin from "@ember/object/mixin"; +import { ajax } from "discourse/lib/ajax"; import { deepMerge } from "discourse-common/lib/object"; import UppyChecksum from "discourse/lib/uppy-checksum-plugin"; import UppyMediaOptimization from "discourse/lib/uppy-media-optimization-plugin"; import Uppy from "@uppy/core"; import DropTarget from "@uppy/drop-target"; import XHRUpload from "@uppy/xhr-upload"; +import AwsS3Multipart from "@uppy/aws-s3-multipart"; import { warn } from "@ember/debug"; import I18n from "I18n"; import getURL from "discourse-common/lib/get-url"; @@ -70,6 +72,7 @@ export default Mixin.create({ _bindUploadTarget() { this.placeholders = {}; + this._inProgressUploads = 0; this._preProcessorStatus = {}; this.fileInputEl = document.getElementById("file-uploader"); const isPrivateMessage = this.get("composer.privateMessage"); @@ -140,9 +143,12 @@ export default Mixin.create({ // name for the preprocess-X events. this._trackPreProcessorStatus(UppyChecksum); - // TODO (martin) support for direct S3 uploads will come later, for now - // we just want the regular /uploads.json endpoint to work well - this._useXHRUploads(); + // hidden setting like enable_experimental_image_uploader + if (this.siteSettings.enable_direct_s3_uploads) { + this._useS3MultipartUploads(); + } else { + this._useXHRUploads(); + } // TODO (martin) develop upload handler guidance and an API to use; will // likely be using uppy plugins for this @@ -171,6 +177,7 @@ export default Mixin.create({ }); files.forEach((file) => { + this._inProgressUploads++; const placeholder = this._uploadPlaceholder(file); this.placeholders[file.id] = { uploadPlaceholder: placeholder, @@ -199,14 +206,7 @@ export default Mixin.create({ this.appEvents.trigger("composer:upload-success", file.name, upload); }); - this._uppyInstance.on("upload-error", (file, error, response) => { - this._resetUpload(file, { removePlaceholder: true }); - - if (!this.userCancelled) { - displayErrorForUpload(response, this.siteSettings, file.name); - this.appEvents.trigger("composer:upload-error", file); - } - }); + this._uppyInstance.on("upload-error", this._handleUploadError.bind(this)); this._uppyInstance.on("complete", () => { this.appEvents.trigger("composer:all-uploads-complete"); @@ -235,6 +235,20 @@ export default Mixin.create({ this._setupPreprocessing(); }, + _handleUploadError(file, error, response) { + this._inProgressUploads--; + this._resetUpload(file, { removePlaceholder: true }); + + if (!this.userCancelled) { + displayErrorForUpload(response || error, this.siteSettings, file.name); + this.appEvents.trigger("composer:upload-error", file); + } + + if (this._inProgressUploads === 0) { + this._reset(); + } + }, + _setupPreprocessing() { Object.keys(this.uploadProcessorActions).forEach((action) => { switch (action) { @@ -343,6 +357,99 @@ export default Mixin.create({ }); }, + _useS3MultipartUploads() { + const self = this; + + this._uppyInstance.use(AwsS3Multipart, { + // controls how many simultaneous _chunks_ are uploaded, not files, + // which in turn controls the minimum number of chunks presigned + // in each batch (limit / 2) + // + // the default, and minimum, chunk size is 5mb. we can control the + // chunk size via getChunkSize(file), so we may want to increase + // the chunk size for larger files + limit: 10, + + createMultipartUpload(file) { + return ajax("/uploads/create-multipart.json", { + type: "POST", + data: { + file_name: file.name, + file_size: file.size, + upload_type: file.meta.upload_type, + }, + // uppy is inconsistent, an error here fires the upload-error event + }).then((data) => { + file.meta.unique_identifier = data.unique_identifier; + return { + uploadId: data.external_upload_identifier, + key: data.key, + }; + }); + }, + + prepareUploadParts(file, partData) { + return ( + ajax("/uploads/batch-presign-multipart-parts.json", { + type: "POST", + data: { + part_numbers: partData.partNumbers, + unique_identifier: file.meta.unique_identifier, + }, + }) + .then((data) => { + return { presignedUrls: data.presigned_urls }; + }) + // uppy is inconsistent, an error here does not fire the upload-error event + .catch((err) => { + self._handleUploadError(file, err); + }) + ); + }, + + completeMultipartUpload(file, data) { + const parts = data.parts.map((part) => { + return { part_number: part.PartNumber, etag: part.ETag }; + }); + return ajax("/uploads/complete-multipart.json", { + type: "POST", + contentType: "application/json", + data: JSON.stringify({ + parts, + unique_identifier: file.meta.unique_identifier, + }), + // uppy is inconsistent, an error here fires the upload-error event + }).then((responseData) => { + return responseData; + }); + }, + + abortMultipartUpload(file, { key, uploadId }) { + // if the user cancels the upload before the key and uploadId + // are stored from the createMultipartUpload response then they + // will not be set, and we don't have to abort the upload because + // it will not exist yet + if (!key || !uploadId) { + return; + } + + return ajax("/uploads/abort-multipart.json", { + type: "POST", + data: { + external_upload_identifier: uploadId, + }, + // uppy is inconsistent, an error here does not fire the upload-error event + }).catch((err) => { + self._handleUploadError(file, err); + }); + }, + + // we will need a listParts function at some point when we want to + // resume multipart uploads; this is used by uppy to figure out + // what parts are uploaded and which still need to be + }); + }, + _reset() { this._uppyInstance?.reset(); this.setProperties({ diff --git a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js index d4d0d4770d9..101fce1ccc9 100644 --- a/app/assets/javascripts/discourse/app/mixins/uppy-upload.js +++ b/app/assets/javascripts/discourse/app/mixins/uppy-upload.js @@ -175,7 +175,11 @@ export default Mixin.create({ this.set("usingS3Uploads", true); this._uppyInstance.use(AwsS3, { getUploadParameters: (file) => { - const data = { file_name: file.name, type: this.type }; + const data = { + file_name: file.name, + file_size: file.size, + type: this.type, + }; // the sha1 checksum is set by the UppyChecksum plugin, except // for in cases where the browser does not support the required diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb index 2048a9fc169..0a5c0bb0560 100644 --- a/app/controllers/uploads_controller.rb +++ b/app/controllers/uploads_controller.rb @@ -9,14 +9,30 @@ class UploadsController < ApplicationController protect_from_forgery except: :show before_action :is_asset_path, :apply_cdn_headers, only: [:show, :show_short, :show_secure] - before_action :external_store_check, only: [:show_secure, :generate_presigned_put, :complete_external_upload] + before_action :external_store_check, only: [ + :show_secure, + :generate_presigned_put, + :complete_external_upload, + :create_multipart, + :batch_presign_multipart_parts, + :abort_multipart, + :complete_multipart + ] + before_action :direct_s3_uploads_check, only: [ + :generate_presigned_put, + :complete_external_upload, + :create_multipart, + :batch_presign_multipart_parts, + :abort_multipart, + :complete_multipart + ] + before_action :can_upload_external?, only: [:create_multipart, :generate_presigned_put] SECURE_REDIRECT_GRACE_SECONDS = 5 - PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE = 5 - - def external_store_check - return render_404 if !Discourse.store.external? - end + PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE = 10 + CREATE_MULTIPART_RATE_LIMIT_PER_MINUTE = 10 + COMPLETE_MULTIPART_RATE_LIMIT_PER_MINUTE = 10 + BATCH_PRESIGN_RATE_LIMIT_PER_MINUTE = 10 def create # capture current user for block later on @@ -193,15 +209,21 @@ class UploadsController < ApplicationController end def generate_presigned_put - return render_404 if !SiteSetting.enable_direct_s3_uploads - RateLimiter.new( current_user, "generate-presigned-put-upload-stub", PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE, 1.minute ).performed! file_name = params.require(:file_name) + file_size = params.require(:file_size).to_i type = params.require(:type) + if file_size_too_big?(file_name, file_size) + return render_json_error( + I18n.t("upload.attachments.too_large", max_size_kb: SiteSetting.max_attachment_size_kb), + status: 422 + ) + end + # don't want people posting arbitrary S3 metadata so we just take the # one we need. all of these will be converted to x-amz-meta- metadata # fields in S3 so it's best to use dashes in the names for consistency @@ -225,33 +247,37 @@ class UploadsController < ApplicationController key: key, created_by: current_user, original_filename: file_name, - upload_type: type + upload_type: type, + filesize: file_size ) render json: { url: url, key: key, unique_identifier: upload_stub.unique_identifier } end def complete_external_upload - return render_404 if !SiteSetting.enable_direct_s3_uploads - unique_identifier = params.require(:unique_identifier) external_upload_stub = ExternalUploadStub.find_by( unique_identifier: unique_identifier, created_by: current_user ) return render_404 if external_upload_stub.blank? - raise Discourse::InvalidAccess if external_upload_stub.created_by_id != current_user.id - external_upload_manager = ExternalUploadManager.new(external_upload_stub) + complete_external_upload_via_manager(external_upload_stub) + end + def complete_external_upload_via_manager(external_upload_stub) + external_upload_manager = ExternalUploadManager.new(external_upload_stub) hijack do begin upload = external_upload_manager.promote_to_upload! if upload.errors.empty? - external_upload_manager.destroy! + external_upload_stub.destroy! render json: UploadsController.serialize_upload(upload), status: 200 else render_json_error(upload.errors.to_hash.values.flatten, status: 422) end + rescue ExternalUploadManager::SizeMismatchError => err + debug_upload_error(err, "upload.size_mismatch_failure", additional_detail: err.message) + render_json_error(I18n.t("upload.failed"), status: 422) rescue ExternalUploadManager::ChecksumMismatchError => err debug_upload_error(err, "upload.checksum_mismatch_failure") render_json_error(I18n.t("upload.failed"), status: 422) @@ -270,6 +296,179 @@ class UploadsController < ApplicationController end end + def create_multipart + RateLimiter.new( + current_user, "create-multipart-upload", CREATE_MULTIPART_RATE_LIMIT_PER_MINUTE, 1.minute + ).performed! + + file_name = params.require(:file_name) + file_size = params.require(:file_size).to_i + upload_type = params.require(:upload_type) + content_type = MiniMime.lookup_by_filename(file_name)&.content_type + + if file_size_too_big?(file_name, file_size) + return render_json_error( + I18n.t("upload.attachments.too_large", max_size_kb: SiteSetting.max_attachment_size_kb), + status: 422 + ) + end + + begin + multipart_upload = Discourse.store.create_multipart( + file_name, content_type + ) + rescue Aws::S3::Errors::ServiceError => err + debug_upload_error(err, "upload.create_mutlipart_failure") + return render_json_error(I18n.t("upload.failed"), status: 422) + end + + upload_stub = ExternalUploadStub.create!( + key: multipart_upload[:key], + created_by: current_user, + original_filename: file_name, + upload_type: upload_type, + external_upload_identifier: multipart_upload[:upload_id], + multipart: true, + filesize: file_size + ) + + render json: { + external_upload_identifier: upload_stub.external_upload_identifier, + key: upload_stub.key, + unique_identifier: upload_stub.unique_identifier + } + end + + def batch_presign_multipart_parts + part_numbers = params.require(:part_numbers) + unique_identifier = params.require(:unique_identifier) + + RateLimiter.new( + current_user, "batch-presign", BATCH_PRESIGN_RATE_LIMIT_PER_MINUTE, 1.minute + ).performed! + + part_numbers = part_numbers.map do |part_number| + validate_part_number(part_number) + end + + external_upload_stub = ExternalUploadStub.find_by( + unique_identifier: unique_identifier, created_by: current_user + ) + return render_404 if external_upload_stub.blank? + + if !multipart_upload_exists?(external_upload_stub) + return render_404 + end + + presigned_urls = {} + part_numbers.each do |part_number| + presigned_urls[part_number] = Discourse.store.presign_multipart_part( + upload_id: external_upload_stub.external_upload_identifier, + key: external_upload_stub.key, + part_number: part_number + ) + end + + render json: { presigned_urls: presigned_urls } + end + + def validate_part_number(part_number) + part_number = part_number.to_i + if !part_number.between?(1, 10000) + raise Discourse::InvalidParameters.new( + "Each part number should be between 1 and 10000" + ) + end + part_number + end + + def multipart_upload_exists?(external_upload_stub) + begin + Discourse.store.list_multipart_parts( + upload_id: external_upload_stub.external_upload_identifier, key: external_upload_stub.key + ) + rescue Aws::S3::Errors::NoSuchUpload => err + debug_upload_error(err, "upload.external_upload_not_found", { additional_detail: "path: #{external_upload_stub.key}" }) + return false + end + true + end + + def abort_multipart + external_upload_identifier = params.require(:external_upload_identifier) + external_upload_stub = ExternalUploadStub.find_by( + external_upload_identifier: external_upload_identifier + ) + + # The stub could have already been deleted by an earlier error via + # ExternalUploadManager, so we consider this a great success if the + # stub is already gone. + return render json: success_json if external_upload_stub.blank? + + return render_404 if external_upload_stub.created_by_id != current_user.id + + begin + Discourse.store.abort_multipart( + upload_id: external_upload_stub.external_upload_identifier, + key: external_upload_stub.key + ) + rescue Aws::S3::Errors::ServiceError => err + debug_upload_error(err, "upload.abort_mutlipart_failure", additional_detail: "external upload stub id: #{external_upload_stub.id}") + return render_json_error(I18n.t("upload.failed"), status: 422) + end + + external_upload_stub.destroy! + + render json: success_json + end + + def complete_multipart + unique_identifier = params.require(:unique_identifier) + parts = params.require(:parts) + + RateLimiter.new( + current_user, "complete-multipart-upload", COMPLETE_MULTIPART_RATE_LIMIT_PER_MINUTE, 1.minute + ).performed! + + external_upload_stub = ExternalUploadStub.find_by( + unique_identifier: unique_identifier, created_by: current_user + ) + return render_404 if external_upload_stub.blank? + + if !multipart_upload_exists?(external_upload_stub) + return render_404 + end + + parts = parts.map do |part| + part_number = part[:part_number] + etag = part[:etag] + part_number = validate_part_number(part_number) + + if etag.blank? + raise Discourse::InvalidParameters.new("All parts must have an etag and a valid part number") + end + + # this is done so it's an array of hashes rather than an array of + # ActionController::Parameters + { part_number: part_number, etag: etag } + end.sort_by do |part| + part[:part_number] + end + + begin + complete_response = Discourse.store.complete_multipart( + upload_id: external_upload_stub.external_upload_identifier, + key: external_upload_stub.key, + parts: parts + ) + rescue Aws::S3::Errors::ServiceError => err + debug_upload_error(err, "upload.complete_mutlipart_failure", additional_detail: "external upload stub id: #{external_upload_stub.id}") + return render_json_error(I18n.t("upload.failed"), status: 422) + end + + complete_external_upload_via_manager(external_upload_stub) + end + protected def force_download? @@ -339,6 +538,25 @@ class UploadsController < ApplicationController private + def external_store_check + return render_404 if !Discourse.store.external? + end + + def direct_s3_uploads_check + return render_404 if !SiteSetting.enable_direct_s3_uploads + end + + def can_upload_external? + raise Discourse::InvalidAccess if !guardian.can_upload_external? + end + + # We can pre-emptively check size for attachments, but not for images + # as they may be further reduced in size by UploadCreator (at this point + # they may have already been reduced in size by preprocessors) + def file_size_too_big?(file_name, file_size) + !FileHelper.is_supported_image?(file_name) && file_size >= SiteSetting.max_attachment_size_kb.kilobytes + end + def send_file_local_upload(upload) opts = { filename: upload.original_filename, @@ -357,8 +575,8 @@ class UploadsController < ApplicationController send_file(file_path, opts) end - def debug_upload_error(translation_key, err) + def debug_upload_error(err, translation_key, translation_params = {}) return if !SiteSetting.enable_upload_debug_mode - Discourse.warn_exception(err, message: I18n.t(translation_key)) + Discourse.warn_exception(err, message: I18n.t(translation_key, translation_params)) end end diff --git a/app/models/external_upload_stub.rb b/app/models/external_upload_stub.rb index 82a20ff2064..6f50a042ecf 100644 --- a/app/models/external_upload_stub.rb +++ b/app/models/external_upload_stub.rb @@ -5,9 +5,14 @@ require "digest/sha1" class ExternalUploadStub < ActiveRecord::Base CREATED_EXPIRY_HOURS = 1 UPLOADED_EXPIRY_HOURS = 24 + FAILED_EXPIRY_HOURS = 48 belongs_to :created_by, class_name: 'User' + validates :filesize, numericality: { + allow_nil: false, only_integer: true, greater_than_or_equal_to: 1 + } + scope :expired_created, -> { where( "status = ? AND created_at <= ?", @@ -33,7 +38,6 @@ class ExternalUploadStub < ActiveRecord::Base @statuses ||= Enum.new( created: 1, uploaded: 2, - failed: 3 ) end @@ -50,19 +54,23 @@ end # # Table name: external_upload_stubs # -# id :bigint not null, primary key -# key :string not null -# original_filename :string not null -# status :integer default(1), not null -# unique_identifier :uuid not null -# created_by_id :integer not null -# upload_type :string not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint not null, primary key +# key :string not null +# original_filename :string not null +# status :integer default(1), not null +# unique_identifier :uuid not null +# created_by_id :integer not null +# upload_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# multipart :boolean default(FALSE), not null +# external_upload_identifier :string +# filesize :bigint not null # # Indexes # -# index_external_upload_stubs_on_created_by_id (created_by_id) -# index_external_upload_stubs_on_key (key) UNIQUE -# index_external_upload_stubs_on_status (status) +# index_external_upload_stubs_on_created_by_id (created_by_id) +# index_external_upload_stubs_on_external_upload_identifier (external_upload_identifier) +# index_external_upload_stubs_on_key (key) UNIQUE +# index_external_upload_stubs_on_status (status) # diff --git a/app/services/external_upload_manager.rb b/app/services/external_upload_manager.rb index aacfb99ecad..aa0e7040b2f 100644 --- a/app/services/external_upload_manager.rb +++ b/app/services/external_upload_manager.rb @@ -2,13 +2,24 @@ class ExternalUploadManager DOWNLOAD_LIMIT = 100.megabytes + SIZE_MISMATCH_BAN_MINUTES = 5 + BAN_USER_REDIS_PREFIX = "ban_user_from_external_uploads_" class ChecksumMismatchError < StandardError; end class DownloadFailedError < StandardError; end class CannotPromoteError < StandardError; end + class SizeMismatchError < StandardError; end attr_reader :external_upload_stub + def self.ban_user_from_external_uploads!(user:, ban_minutes: 5) + Discourse.redis.setex("#{BAN_USER_REDIS_PREFIX}#{user.id}", ban_minutes.minutes.to_i, "1") + end + + def self.user_banned?(user) + Discourse.redis.get("#{BAN_USER_REDIS_PREFIX}#{user.id}") == "1" + end + def initialize(external_upload_stub) @external_upload_stub = external_upload_stub end @@ -31,6 +42,19 @@ class ExternalUploadManager # variable as well to check. tempfile = nil should_download = external_size < DOWNLOAD_LIMIT + + # We require that the file size is specified ahead of time, and compare + # it here to make sure that people are not uploading excessively large + # files to the external provider. If this happens, the user will be banned + # from uploading to the external provider for N minutes. + if external_size != external_upload_stub.filesize + ExternalUploadManager.ban_user_from_external_uploads!( + user: external_upload_stub.created_by, + ban_minutes: SIZE_MISMATCH_BAN_MINUTES + ) + raise SizeMismatchError.new("expected: #{external_upload_stub.filesize}, actual: #{external_size}") + end + if should_download tempfile = download(external_upload_stub.key, external_upload_stub.upload_type) @@ -60,16 +84,17 @@ class ExternalUploadManager external_upload_stub.created_by_id ) rescue - external_upload_stub.update!(status: ExternalUploadStub.statuses[:failed]) + # We don't need to do anything special to abort multipart uploads here, + # because at this point (calling promote_to_upload!), the multipart + # upload would already be complete. + Discourse.store.delete_file(external_upload_stub.key) + external_upload_stub.destroy! + raise ensure tempfile&.close! end - def destroy! - external_upload_stub.destroy! - end - private def download(key, type) diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index d7150cf44c2..c19136c70cf 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -4009,6 +4009,11 @@ en: png_to_jpg_conversion_failure_message: "An error happened when converting from PNG to JPG." optimize_failure_message: "An error occurred while optimizing the uploaded image." download_failure: "Downloading the file from the external provider failed." + size_mismatch_failure: "The size of the file uploaded to S3 did not match the external upload stub's intended size. %{additional_detail}" + create_mutlipart_failure: "Failed to create multipart upload in the external store." + abort_mutlipart_failure: "Failed to abort multipart upload in the external store." + complete_mutlipart_failure: "Failed to complete multipart upload in the external store." + external_upload_not_found: "The upload was not found in the external store. %{additional_detail}" checksum_mismatch_failure: "The checksum of the file you uploaded does not match. The file contents may have changed on upload. Please try again." cannot_promote_failure: "The upload cannot be completed, it may have already completed or previously failed." attachments: diff --git a/config/routes.rb b/config/routes.rb index 28cb935b343..c7737d9fce0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -541,8 +541,15 @@ Discourse::Application.routes.draw do post "uploads" => "uploads#create" post "uploads/lookup-urls" => "uploads#lookup_urls" - post "uploads/generate-presigned-put" => "uploads#generate_presigned_put" - post "uploads/complete-external-upload" => "uploads#complete_external_upload" + # direct to s3 uploads + post "uploads/generate-presigned-put" => "uploads#generate_presigned_put", format: :json + post "uploads/complete-external-upload" => "uploads#complete_external_upload", format: :json + + # multipart uploads + post "uploads/create-multipart" => "uploads#create_multipart", format: :json + post "uploads/complete-multipart" => "uploads#complete_multipart", format: :json + post "uploads/abort-multipart" => "uploads#abort_multipart", format: :json + post "uploads/batch-presign-multipart-parts" => "uploads#batch_presign_multipart_parts", format: :json # used to download original images get "uploads/:site/:sha(.:extension)" => "uploads#show", constraints: { site: /\w+/, sha: /\h{40}/, extension: /[a-z0-9\._]+/i } diff --git a/db/migrate/20210812033033_add_multipart_and_size_columns_to_external_upload_stubs.rb b/db/migrate/20210812033033_add_multipart_and_size_columns_to_external_upload_stubs.rb new file mode 100644 index 00000000000..97fdb885ec1 --- /dev/null +++ b/db/migrate/20210812033033_add_multipart_and_size_columns_to_external_upload_stubs.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddMultipartAndSizeColumnsToExternalUploadStubs < ActiveRecord::Migration[6.1] + def up + add_column :external_upload_stubs, :multipart, :boolean, default: false, null: false + add_column :external_upload_stubs, :external_upload_identifier, :string, null: true + add_column :external_upload_stubs, :filesize, :bigint + + add_index :external_upload_stubs, :external_upload_identifier + + # this feature is not actively used yet so this will be safe, also the rows in this + # table are regularly deleted + DB.exec("UPDATE external_upload_stubs SET filesize = 0 WHERE filesize IS NULL") + + change_column_null :external_upload_stubs, :filesize, false + end + + def down + remove_column :external_upload_stubs, :multipart + remove_column :external_upload_stubs, :external_upload_identifier + remove_column :external_upload_stubs, :filesize + end +end diff --git a/lib/file_store/s3_store.rb b/lib/file_store/s3_store.rb index 2c85c77eba2..66642ec91d8 100644 --- a/lib/file_store/s3_store.rb +++ b/lib/file_store/s3_store.rb @@ -97,12 +97,13 @@ module FileStore # if this fails, it will throw an exception if opts[:move_existing] && opts[:existing_external_upload_key] + original_path = opts[:existing_external_upload_key] path, etag = s3_helper.copy( - opts[:existing_external_upload_key], + original_path, path, options: options ) - s3_helper.delete_object(opts[:existing_external_upload_key]) + delete_file(original_path) else path, etag = s3_helper.upload(file, path, options) end @@ -111,6 +112,12 @@ module FileStore [File.join(absolute_base_url, path), etag] end + def delete_file(path) + # delete the object outright without moving to tombstone, + # not recommended for most use cases + s3_helper.delete_object(path) + end + def remove_file(url, path) return unless has_been_uploaded?(url) # copy the removed file to tombstone @@ -217,7 +224,15 @@ module FileStore def signed_url_for_temporary_upload(file_name, expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, metadata: {}) key = temporary_upload_path(file_name) - presigned_put_url(key, expires_in: expires_in, metadata: metadata) + presigned_url( + key, + method: :put_object, + expires_in: expires_in, + opts: { + metadata: metadata, + acl: "private" + } + ) end def temporary_upload_path(file_name) @@ -297,17 +312,72 @@ module FileStore FileUtils.mv(old_upload_path, public_upload_path) if old_upload_path end - private - - def presigned_put_url(key, expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, metadata: {}) - signer = Aws::S3::Presigner.new(client: s3_helper.s3_client) - signer.presigned_url( - :put_object, + def abort_multipart(key:, upload_id:) + s3_helper.s3_client.abort_multipart_upload( bucket: s3_bucket_name, key: key, + upload_id: upload_id + ) + end + + def create_multipart(file_name, content_type) + key = temporary_upload_path(file_name) + response = s3_helper.s3_client.create_multipart_upload( acl: "private", - expires_in: expires_in, - metadata: metadata + bucket: s3_bucket_name, + key: key, + content_type: content_type + ) + { upload_id: response.upload_id, key: key } + end + + def presign_multipart_part(upload_id:, key:, part_number:) + presigned_url( + key, + method: :upload_part, + expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, + opts: { + part_number: part_number, + upload_id: upload_id + } + ) + end + + def list_multipart_parts(upload_id:, key:) + s3_helper.s3_client.list_parts( + bucket: s3_bucket_name, + key: key, + upload_id: upload_id + ) + end + + def complete_multipart(upload_id:, key:, parts:) + s3_helper.s3_client.complete_multipart_upload( + bucket: s3_bucket_name, + key: key, + upload_id: upload_id, + multipart_upload: { + parts: parts + } + ) + end + + private + + def presigned_url( + key, + method:, + expires_in: S3Helper::UPLOAD_URL_EXPIRES_AFTER_SECONDS, + opts: {} + ) + signer = Aws::S3::Presigner.new(client: s3_helper.s3_client) + signer.presigned_url( + method, + { + bucket: s3_bucket_name, + key: key, + expires_in: expires_in, + }.merge(opts) ) end diff --git a/lib/guardian/user_guardian.rb b/lib/guardian/user_guardian.rb index cd9dda0921e..ef13588cef2 100644 --- a/lib/guardian/user_guardian.rb +++ b/lib/guardian/user_guardian.rb @@ -176,6 +176,10 @@ module UserGuardian (is_me?(user) && user.has_trust_level?(SiteSetting.min_trust_level_to_allow_user_card_background.to_i)) || is_staff? end + def can_upload_external? + !ExternalUploadManager.user_banned?(user) + end + def can_delete_sso_record?(user) SiteSetting.enable_discourse_connect && user && is_admin? end diff --git a/lib/upload_creator.rb b/lib/upload_creator.rb index d29a3c5d2b5..affbe78775a 100644 --- a/lib/upload_creator.rb +++ b/lib/upload_creator.rb @@ -32,6 +32,9 @@ class UploadCreator @opts = opts @filesize = @opts[:filesize] if @opts[:external_upload_too_big] @opts[:validate] = opts[:skip_validations].present? ? !ActiveRecord::Type::Boolean.new.cast(opts[:skip_validations]) : true + + # TODO (martin) Validate @opts[:type] to make sure only blessed types are passed + # in, since the clientside can pass any type it wants. end def create_for(user_id) @@ -50,6 +53,11 @@ class UploadCreator # so we have not downloaded it to a tempfile. no modifications can be made to the # file in this case because it does not exist; we simply move it to its new location # in S3 + # + # TODO (martin) I've added a bunch of external_upload_too_big checks littered + # throughout the UploadCreator code. It would be better to have two seperate + # classes with shared methods, rather than doing all these checks all over the + # place. Needs a refactor. external_upload_too_big = @opts[:external_upload_too_big] sha1_before_changes = Upload.generate_digest(@file) if @file diff --git a/spec/components/guardian/user_guardian_spec.rb b/spec/components/guardian/user_guardian_spec.rb index 63f8b3a5e77..f07a4062b30 100644 --- a/spec/components/guardian/user_guardian_spec.rb +++ b/spec/components/guardian/user_guardian_spec.rb @@ -492,4 +492,17 @@ describe UserGuardian do end end end + + describe "#can_upload_external?" do + after { Discourse.redis.flushdb } + + it "is true by default" do + expect(Guardian.new(user).can_upload_external?).to eq(true) + end + + it "is false if the user has been banned from external uploads for a time period" do + ExternalUploadManager.ban_user_from_external_uploads!(user: user) + expect(Guardian.new(user).can_upload_external?).to eq(false) + end + end end diff --git a/spec/fabricators/external_upload_stub_fabricator.rb b/spec/fabricators/external_upload_stub_fabricator.rb index 57d26d10a3b..ce7b1ec6163 100644 --- a/spec/fabricators/external_upload_stub_fabricator.rb +++ b/spec/fabricators/external_upload_stub_fabricator.rb @@ -5,15 +5,18 @@ Fabricator(:external_upload_stub) do original_filename "test.txt" key { Discourse.store.temporary_upload_path("test.txt") } upload_type "card_background" + filesize 1024 status 1 end Fabricator(:image_external_upload_stub, from: :external_upload_stub) do original_filename "logo.png" + filesize 1024 key { Discourse.store.temporary_upload_path("logo.png") } end Fabricator(:attachment_external_upload_stub, from: :external_upload_stub) do original_filename "file.pdf" + filesize 1024 key { Discourse.store.temporary_upload_path("file.pdf") } end diff --git a/spec/requests/uploads_controller_spec.rb b/spec/requests/uploads_controller_spec.rb index 737c992d522..c50711a952a 100644 --- a/spec/requests/uploads_controller_spec.rb +++ b/spec/requests/uploads_controller_spec.rb @@ -721,7 +721,9 @@ describe UploadsController do end it "generates a presigned URL and creates an external upload stub" do - post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background" } + post "/uploads/generate-presigned-put.json", params: { + file_name: "test.png", type: "card_background", file_size: 1024 + } expect(response.status).to eq(200) result = response.parsed_body @@ -730,7 +732,8 @@ describe UploadsController do unique_identifier: result["unique_identifier"], original_filename: "test.png", created_by: user, - upload_type: "card_background" + upload_type: "card_background", + filesize: 1024 ) expect(external_upload_stub.exists?).to eq(true) expect(result["key"]).to include(FileStore::S3Store::TEMPORARY_UPLOAD_PREFIX) @@ -742,6 +745,7 @@ describe UploadsController do post "/uploads/generate-presigned-put.json", { params: { file_name: "test.png", + file_size: 1024, type: "card_background", metadata: { "sha1-checksum" => "testing", @@ -761,8 +765,8 @@ describe UploadsController do RateLimiter.clear_all! stub_const(UploadsController, "PRESIGNED_PUT_RATE_LIMIT_PER_MINUTE", 1) do - post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background" } - post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background" } + post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background", file_size: 1024 } + post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background", file_size: 1024 } end expect(response.status).to eq(429) end @@ -774,7 +778,566 @@ describe UploadsController do end it "returns 404" do - post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background" } + post "/uploads/generate-presigned-put.json", params: { file_name: "test.png", type: "card_background", file_size: 1024 } + expect(response.status).to eq(404) + end + end + end + + describe "#create_multipart" do + context "when the store is external" do + let(:mock_multipart_upload_id) { "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" } + + before do + sign_in(user) + SiteSetting.enable_direct_s3_uploads = true + setup_s3 + FileStore::S3Store.any_instance.stubs(:temporary_upload_path).returns( + "uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png" + ) + end + + it "errors if the correct params are not provided" do + post "/uploads/create-multipart.json", params: { file_name: "test.png" } + expect(response.status).to eq(400) + post "/uploads/create-multipart.json", params: { upload_type: "composer" } + expect(response.status).to eq(400) + post "/uploads/create-multipart.json", params: { content_type: "image/jpeg" } + expect(response.status).to eq(400) + end + + it "returns 422 when the create request errors" do + FileStore::S3Store.any_instance.stubs(:create_multipart).raises(Aws::S3::Errors::ServiceError.new({}, "test")) + post "/uploads/create-multipart.json", { + params: { + file_name: "test.png", + file_size: 1024, + upload_type: "composer", + content_type: "image/png" + } + } + expect(response.status).to eq(422) + end + + it "returns 422 when the file is an attachment and it's too big" do + SiteSetting.max_attachment_size_kb = 1000 + post "/uploads/create-multipart.json", { + params: { + file_name: "test.zip", + file_size: 9999999, + upload_type: "composer", + content_type: "application/zip" + } + } + expect(response.status).to eq(422) + expect(response.body).to include(I18n.t("upload.attachments.too_large", max_size_kb: SiteSetting.max_attachment_size_kb)) + end + + def stub_create_multipart_request + create_multipart_result = <<~BODY + \n + + s3-upload-bucket + uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png + #{mock_multipart_upload_id} + + BODY + stub_request( + :post, + "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/uploads/default/test_0/temp/28fccf8259bbe75b873a2bd2564b778c/test.png?uploads" + ).to_return({ status: 200, body: create_multipart_result }) + end + + it "creates a multipart upload and creates an external upload stub that is marked as multipart" do + stub_create_multipart_request + post "/uploads/create-multipart.json", { + params: { + file_name: "test.png", + file_size: 1024, + upload_type: "composer", + content_type: "image/png" + } + } + + expect(response.status).to eq(200) + result = response.parsed_body + + external_upload_stub = ExternalUploadStub.where( + unique_identifier: result["unique_identifier"], + original_filename: "test.png", + created_by: user, + upload_type: "composer", + key: result["key"], + external_upload_identifier: mock_multipart_upload_id, + multipart: true, + filesize: 1024 + ) + expect(external_upload_stub.exists?).to eq(true) + expect(result["key"]).to include(FileStore::S3Store::TEMPORARY_UPLOAD_PREFIX) + expect(result["external_upload_identifier"]).to eq(mock_multipart_upload_id) + expect(result["key"]).to eq(external_upload_stub.last.key) + end + + it "rate limits" do + RateLimiter.enable + RateLimiter.clear_all! + + stub_create_multipart_request + stub_const(UploadsController, "CREATE_MULTIPART_RATE_LIMIT_PER_MINUTE", 1) do + post "/uploads/create-multipart.json", params: { + file_name: "test.png", + upload_type: "composer", + content_type: "image/png", + file_size: 1024 + } + expect(response.status).to eq(200) + + post "/uploads/create-multipart.json", params: { + file_name: "test.png", + upload_type: "composer", + content_type: "image/png", + file_size: 1024 + } + expect(response.status).to eq(429) + end + end + end + + context "when the store is not external" do + before do + sign_in(user) + end + + it "returns 404" do + post "/uploads/create-multipart.json", params: { + file_name: "test.png", + upload_type: "composer", + content_type: "image/png", + file_size: 1024 + } + expect(response.status).to eq(404) + end + end + end + + describe "#batch_presign_multipart_parts" do + fab!(:mock_multipart_upload_id) { "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" } + fab!(:external_upload_stub) do + Fabricate(:image_external_upload_stub, created_by: user, multipart: true, external_upload_identifier: mock_multipart_upload_id) + end + + context "when the store is external" do + before do + sign_in(user) + SiteSetting.enable_direct_s3_uploads = true + setup_s3 + end + + def stub_list_multipart_request + list_multipart_result = <<~BODY + \n + + s3-upload-bucket + #{external_upload_stub.key} + #{mock_multipart_upload_id} + 0 + 0 + 1 + false + + test + #{Time.zone.now} + 1 + #{5.megabytes} + + + test-upload-user + arn:aws:iam::123:user/test-upload-user + + + + 12345 + + STANDARD + + BODY + stub_request(:get, "https://s3-upload-bucket.s3.us-west-1.amazonaws.com/#{external_upload_stub.key}?uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) + end + + it "errors if the correct params are not provided" do + post "/uploads/batch-presign-multipart-parts.json", params: {} + expect(response.status).to eq(400) + end + + it "errors if the part_numbers do not contain numbers between 1 and 10000" do + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [-1, 0, 1, 2, 3, 4] + } + expect(response.status).to eq(400) + expect(response.body).to include("You supplied invalid parameters to the request: Each part number should be between 1 and 10000") + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [3, 4, "blah"] + } + expect(response.status).to eq(400) + expect(response.body).to include("You supplied invalid parameters to the request: Each part number should be between 1 and 10000") + end + + it "returns 404 when the upload stub does not exist" do + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: "unknown", + part_numbers: [1, 2, 3] + } + expect(response.status).to eq(404) + end + + it "returns 404 when the upload stub does not belong to the user" do + external_upload_stub.update!(created_by: Fabricate(:user)) + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [1, 2, 3] + } + expect(response.status).to eq(404) + end + + it "returns 404 when the multipart upload does not exist" do + FileStore::S3Store.any_instance.stubs(:list_multipart_parts).raises(Aws::S3::Errors::NoSuchUpload.new("test", "test")) + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [1, 2, 3] + } + expect(response.status).to eq(404) + end + + it "returns an object with the presigned URLs with the part numbers as keys" do + stub_list_multipart_request + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [2, 3, 4] + } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result["presigned_urls"].keys).to eq(["2", "3", "4"]) + expect(result["presigned_urls"]["2"]).to include("?partNumber=2&uploadId=#{mock_multipart_upload_id}") + expect(result["presigned_urls"]["3"]).to include("?partNumber=3&uploadId=#{mock_multipart_upload_id}") + expect(result["presigned_urls"]["4"]).to include("?partNumber=4&uploadId=#{mock_multipart_upload_id}") + end + + it "rate limits" do + RateLimiter.enable + RateLimiter.clear_all! + + stub_const(UploadsController, "BATCH_PRESIGN_RATE_LIMIT_PER_MINUTE", 1) do + stub_list_multipart_request + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [1, 2, 3] + } + + expect(response.status).to eq(200) + + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [1, 2, 3] + } + + expect(response.status).to eq(429) + end + end + end + + context "when the store is not external" do + before do + sign_in(user) + end + + it "returns 404" do + post "/uploads/batch-presign-multipart-parts.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + part_numbers: [1, 2, 3] + } + expect(response.status).to eq(404) + end + end + end + + describe "#complete_multipart" do + let(:upload_base_url) { "https://#{SiteSetting.s3_upload_bucket}.s3.#{SiteSetting.s3_region}.amazonaws.com" } + let(:mock_multipart_upload_id) { "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" } + let!(:external_upload_stub) do + Fabricate(:image_external_upload_stub, created_by: user, multipart: true, external_upload_identifier: mock_multipart_upload_id) + end + + context "when the store is external" do + before do + sign_in(user) + SiteSetting.enable_direct_s3_uploads = true + setup_s3 + end + + def stub_list_multipart_request + list_multipart_result = <<~BODY + \n + + s3-upload-bucket + #{external_upload_stub.key} + #{mock_multipart_upload_id} + 0 + 0 + 1 + false + + test + #{Time.zone.now} + 1 + #{5.megabytes} + + + test-upload-user + arn:aws:iam::123:user/test-upload-user + + + + 12345 + + STANDARD + + BODY + stub_request(:get, "#{upload_base_url}/#{external_upload_stub.key}?uploadId=#{mock_multipart_upload_id}").to_return({ status: 200, body: list_multipart_result }) + end + + it "errors if the correct params are not provided" do + post "/uploads/complete-multipart.json", params: {} + expect(response.status).to eq(400) + end + + it "errors if the part_numbers do not contain numbers between 1 and 10000" do + stub_list_multipart_request + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: -1, etag: "test1" }] + } + expect(response.status).to eq(400) + expect(response.body).to include("You supplied invalid parameters to the request: Each part number should be between 1 and 10000") + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 20001, etag: "test1" }] + } + expect(response.status).to eq(400) + expect(response.body).to include("You supplied invalid parameters to the request: Each part number should be between 1 and 10000") + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: "blah", etag: "test1" }] + } + expect(response.status).to eq(400) + expect(response.body).to include("You supplied invalid parameters to the request: Each part number should be between 1 and 10000") + end + + it "errors if any of the parts objects have missing values" do + stub_list_multipart_request + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 1 }] + } + expect(response.status).to eq(400) + expect(response.body).to include("All parts must have an etag") + end + + it "returns 404 when the upload stub does not exist" do + post "/uploads/complete-multipart.json", params: { + unique_identifier: "unknown", + parts: [{ part_number: 1, etag: "test1" }] + } + expect(response.status).to eq(404) + end + + it "returns 422 when the complete request errors" do + FileStore::S3Store.any_instance.stubs(:complete_multipart).raises(Aws::S3::Errors::ServiceError.new({}, "test")) + stub_list_multipart_request + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 1, etag: "test1" }] + } + expect(response.status).to eq(422) + end + + it "returns 404 when the upload stub does not belong to the user" do + external_upload_stub.update!(created_by: Fabricate(:user)) + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 1, etag: "test1" }] + } + expect(response.status).to eq(404) + end + + it "returns 404 when the multipart upload does not exist" do + FileStore::S3Store.any_instance.stubs(:list_multipart_parts).raises(Aws::S3::Errors::NoSuchUpload.new("test", "test")) + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 1, etag: "test1" }] + } + expect(response.status).to eq(404) + end + + it "completes the multipart upload, creates the Upload record, and returns a serialized Upload record" do + temp_location = "#{upload_base_url}/#{external_upload_stub.key}" + stub_list_multipart_request + stub_request( + :post, + "#{temp_location}?uploadId=#{external_upload_stub.external_upload_identifier}" + ).with( + body: "\n \n test1\n 1\n \n \n test2\n 2\n \n\n" + ).to_return(status: 200, body: <<~XML) + + + #{temp_location} + s3-upload-bucket + #{external_upload_stub.key} + testfinal + + XML + + # all the functionality for ExternalUploadManager is already tested along + # with stubs to S3 in its own test, we can just stub the response here + upload = Fabricate(:upload) + ExternalUploadManager.any_instance.stubs(:promote_to_upload!).returns(upload) + + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.unique_identifier, + parts: [{ part_number: 1, etag: "test1" }, { part_number: 2, etag: "test2" }] + } + + expect(response.status).to eq(200) + result = response.parsed_body + expect(result[:upload]).to eq(JSON.parse(UploadSerializer.new(upload).to_json)[:upload]) + end + + it "rate limits" do + RateLimiter.enable + RateLimiter.clear_all! + + stub_const(UploadsController, "COMPLETE_MULTIPART_RATE_LIMIT_PER_MINUTE", 1) do + post "/uploads/complete-multipart.json", params: { + unique_identifier: "blah", + parts: [{ part_number: 1, etag: "test1" }, { part_number: 2, etag: "test2" }] + } + post "/uploads/complete-multipart.json", params: { + unique_identifier: "blah", + parts: [{ part_number: 1, etag: "test1" }, { part_number: 2, etag: "test2" }] + } + end + expect(response.status).to eq(429) + end + end + + context "when the store is not external" do + before do + sign_in(user) + end + + it "returns 404" do + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.external_upload_identifier, + parts: [ + { + part_number: 1, + etag: "test1" + }, + { + part_number: 2, + etag: "test2" + } + ] + } + expect(response.status).to eq(404) + end + end + end + + describe "#abort_multipart" do + let(:upload_base_url) { "https://#{SiteSetting.s3_upload_bucket}.s3.#{SiteSetting.s3_region}.amazonaws.com" } + let(:mock_multipart_upload_id) { "ibZBv_75gd9r8lH_gqXatLdxMVpAlj6CFTR.OwyF3953YdwbcQnMA2BLGn8Lx12fQNICtMw5KyteFeHw.Sjng--" } + let!(:external_upload_stub) do + Fabricate(:image_external_upload_stub, created_by: user, multipart: true, external_upload_identifier: mock_multipart_upload_id) + end + + context "when the store is external" do + before do + sign_in(user) + SiteSetting.enable_direct_s3_uploads = true + setup_s3 + end + + def stub_abort_request + temp_location = "#{upload_base_url}/#{external_upload_stub.key}" + stub_request( + :delete, + "#{temp_location}?uploadId=#{external_upload_stub.external_upload_identifier}" + ).to_return(status: 200, body: "") + end + + it "errors if the correct params are not provided" do + post "/uploads/abort-multipart.json", params: {} + expect(response.status).to eq(400) + end + + it "returns 200 when the stub does not exist, assumes it has already been deleted" do + FileStore::S3Store.any_instance.expects(:abort_multipart).never + post "/uploads/abort-multipart.json", params: { + external_upload_identifier: "unknown", + } + expect(response.status).to eq(200) + end + + it "returns 404 when the upload stub does not belong to the user" do + external_upload_stub.update!(created_by: Fabricate(:user)) + post "/uploads/abort-multipart.json", params: { + external_upload_identifier: external_upload_stub.external_upload_identifier + } + expect(response.status).to eq(404) + end + + it "aborts the multipart upload and deletes the stub" do + stub_abort_request + + post "/uploads/abort-multipart.json", params: { + external_upload_identifier: external_upload_stub.external_upload_identifier + } + + expect(response.status).to eq(200) + expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) + end + + it "returns 422 when the abort request errors" do + FileStore::S3Store.any_instance.stubs(:abort_multipart).raises(Aws::S3::Errors::ServiceError.new({}, "test")) + post "/uploads/abort-multipart.json", params: { + external_upload_identifier: external_upload_stub.external_upload_identifier + } + expect(response.status).to eq(422) + end + end + + context "when the store is not external" do + before do + sign_in(user) + end + + it "returns 404" do + post "/uploads/complete-multipart.json", params: { + unique_identifier: external_upload_stub.external_upload_identifier, + parts: [ + { + part_number: 1, + etag: "test1" + }, + { + part_number: 2, + etag: "test2" + } + ] + } expect(response.status).to eq(404) end end @@ -786,7 +1349,7 @@ describe UploadsController do end context "when the store is external" do - fab!(:external_upload_stub) { Fabricate(:external_upload_stub, created_by: user) } + fab!(:external_upload_stub) { Fabricate(:image_external_upload_stub, created_by: user) } let(:upload) { Fabricate(:upload) } before do @@ -813,6 +1376,13 @@ describe UploadsController do expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) end + it "handles SizeMismatchError" do + ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::SizeMismatchError.new("expected: 10, actual: 1000")) + post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } + expect(response.status).to eq(422) + expect(response.parsed_body["errors"].first).to eq(I18n.t("upload.failed")) + end + it "handles CannotPromoteError" do ExternalUploadManager.any_instance.stubs(:promote_to_upload!).raises(ExternalUploadManager::CannotPromoteError) post "/uploads/complete-external-upload.json", params: { unique_identifier: external_upload_stub.unique_identifier } diff --git a/spec/services/external_upload_manager_spec.rb b/spec/services/external_upload_manager_spec.rb index 59a7ad92818..aafad046341 100644 --- a/spec/services/external_upload_manager_spec.rb +++ b/spec/services/external_upload_manager_spec.rb @@ -31,6 +31,15 @@ RSpec.describe ExternalUploadManager do stub_delete_object end + describe "#ban_user_from_external_uploads!" do + after { Discourse.redis.flushdb } + + it "bans the user from external uploads using a redis key" do + ExternalUploadManager.ban_user_from_external_uploads!(user: user) + expect(ExternalUploadManager.user_banned?(user)).to eq(true) + end + end + describe "#can_promote?" do it "returns false if the external stub status is not created" do external_upload_stub.update!(status: ExternalUploadStub.statuses[:uploaded]) @@ -40,7 +49,7 @@ RSpec.describe ExternalUploadManager do describe "#promote_to_upload!" do context "when stubbed upload is < DOWNLOAD_LIMIT (small enough to download + generate sha)" do - let!(:external_upload_stub) { Fabricate(:image_external_upload_stub, created_by: user) } + let!(:external_upload_stub) { Fabricate(:image_external_upload_stub, created_by: user, filesize: object_size) } let(:object_size) { 1.megabyte } let(:object_file) { logo_file } @@ -114,18 +123,36 @@ RSpec.describe ExternalUploadManager do context "when the downloaded file sha1 does not match the client sha1" do let(:client_sha1) { "blahblah" } - it "raises an error and marks upload as failed" do + it "raises an error, deletes the stub" do expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::ChecksumMismatchError) - expect(external_upload_stub.reload.status).to eq(ExternalUploadStub.statuses[:failed]) + expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) end end end + + context "when the downloaded file size does not match the expected file size for the upload stub" do + before do + external_upload_stub.update!(filesize: 10) + end + + after { Discourse.redis.flushdb } + + it "raises an error, deletes the file immediately, and prevents the user from uploading external files for a few minutes" do + expect { subject.promote_to_upload! }.to raise_error(ExternalUploadManager::SizeMismatchError) + expect(ExternalUploadStub.exists?(id: external_upload_stub.id)).to eq(false) + expect(Discourse.redis.get("#{ExternalUploadManager::BAN_USER_REDIS_PREFIX}#{external_upload_stub.created_by_id}")).to eq("1") + expect(WebMock).to have_requested( + :delete, + "#{upload_base_url}/#{external_upload_stub.key}" + ) + end + end end context "when stubbed upload is > DOWNLOAD_LIMIT (too big to download, generate a fake sha)" do let(:object_size) { 200.megabytes } let(:object_file) { pdf_file } - let!(:external_upload_stub) { Fabricate(:attachment_external_upload_stub, created_by: user) } + let!(:external_upload_stub) { Fabricate(:attachment_external_upload_stub, created_by: user, filesize: object_size) } before do UploadCreator.any_instance.stubs(:generate_fake_sha1_hash).returns("testbc60eb18e8f974cbfae8bb0f069c3a311024") diff --git a/vendor/assets/javascripts/uppy.js b/vendor/assets/javascripts/uppy.js index 36cf75c3923..ef9e4517322 100644 --- a/vendor/assets/javascripts/uppy.js +++ b/vendor/assets/javascripts/uppy.js @@ -1,4 +1,18 @@ (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i= need) { - break; - } - } - - candidates.forEach(function (index) { - _this3._uploadPartRetryable(index).then(function () { - // Continue uploading parts - _this3._uploadParts(); - }, function (err) { - _this3._onError(err); - }); - }); - }; - - _proto._retryable = function _retryable(_ref) { - var _this4 = this; - - var before = _ref.before, - attempt = _ref.attempt, - after = _ref.after; - var retryDelays = this.options.retryDelays; - var signal = this.abortController.signal; - if (before) before(); - - function shouldRetry(err) { - if (err.source && typeof err.source.status === 'number') { - var status = err.source.status; // 0 probably indicates network failure - - return status === 0 || status === 409 || status === 423 || status >= 500 && status < 600; - } - - return false; - } - - var doAttempt = function doAttempt(retryAttempt) { - return attempt().catch(function (err) { - if (_this4._aborted()) throw createAbortError(); - - if (shouldRetry(err) && retryAttempt < retryDelays.length) { - return delay(retryDelays[retryAttempt], { - signal: signal - }).then(function () { - return doAttempt(retryAttempt + 1); - }); - } - - throw err; - }); - }; - - return doAttempt(0).then(function (result) { - if (after) after(); - return result; - }, function (err) { - if (after) after(); - throw err; - }); - }; - - _proto._uploadPartRetryable = function _uploadPartRetryable(index) { - var _this5 = this; - - return this._retryable({ - before: function before() { - _this5.partsInProgress += 1; - }, - attempt: function attempt() { - return _this5._uploadPart(index); - }, - after: function after() { - _this5.partsInProgress -= 1; - } - }); - }; - - _proto._uploadPart = function _uploadPart(index) { - var _this6 = this; - - var body = this.chunks[index]; - this.chunkState[index].busy = true; - return Promise.resolve().then(function () { - return _this6.options.prepareUploadPart({ - key: _this6.key, - uploadId: _this6.uploadId, - body: body, - number: index + 1 - }); - }).then(function (result) { - var valid = typeof result === 'object' && result && typeof result.url === 'string'; - - if (!valid) { - throw new TypeError('AwsS3/Multipart: Got incorrect result from `prepareUploadPart()`, expected an object `{ url }`.'); - } - - return result; - }).then(function (_ref2) { - var url = _ref2.url, - headers = _ref2.headers; - - if (_this6._aborted()) { - _this6.chunkState[index].busy = false; - throw createAbortError(); - } - - return _this6._uploadPartBytes(index, url, headers); - }); - }; - - _proto._onPartProgress = function _onPartProgress(index, sent, total) { - this.chunkState[index].uploaded = ensureInt(sent); - var totalUploaded = this.chunkState.reduce(function (n, c) { - return n + c.uploaded; - }, 0); - this.options.onProgress(totalUploaded, this.file.size); - }; - - _proto._onPartComplete = function _onPartComplete(index, etag) { - this.chunkState[index].etag = etag; - this.chunkState[index].done = true; - var part = { - PartNumber: index + 1, - ETag: etag - }; - this.parts.push(part); - this.options.onPartComplete(part); - }; - - _proto._uploadPartBytes = function _uploadPartBytes(index, url, headers) { - var _this7 = this; - - var body = this.chunks[index]; - var signal = this.abortController.signal; - var defer; - var promise = new Promise(function (resolve, reject) { - defer = { - resolve: resolve, - reject: reject - }; - }); - var xhr = new XMLHttpRequest(); - xhr.open('PUT', url, true); - - if (headers) { - Object.keys(headers).map(function (key) { - xhr.setRequestHeader(key, headers[key]); - }); - } - - xhr.responseType = 'text'; - - function cleanup() { - signal.removeEventListener('abort', onabort); - } - - function onabort() { - xhr.abort(); - } - - signal.addEventListener('abort', onabort); - xhr.upload.addEventListener('progress', function (ev) { - if (!ev.lengthComputable) return; - - _this7._onPartProgress(index, ev.loaded, ev.total); - }); - xhr.addEventListener('abort', function (ev) { - cleanup(); - _this7.chunkState[index].busy = false; - defer.reject(createAbortError()); - }); - xhr.addEventListener('load', function (ev) { - cleanup(); - _this7.chunkState[index].busy = false; - - if (ev.target.status < 200 || ev.target.status >= 300) { - var error = new Error('Non 2xx'); - error.source = ev.target; - defer.reject(error); - return; - } - - _this7._onPartProgress(index, body.size, body.size); // NOTE This must be allowed by CORS. - - - var etag = ev.target.getResponseHeader('ETag'); - - if (etag === null) { - defer.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.')); - return; - } - - _this7._onPartComplete(index, etag); - - defer.resolve(); - }); - xhr.addEventListener('error', function (ev) { - cleanup(); - _this7.chunkState[index].busy = false; - var error = new Error('Unknown error'); - error.source = ev.target; - defer.reject(error); - }); - xhr.send(body); - return promise; - }; - - _proto._completeUpload = function _completeUpload() { - var _this8 = this; - - // Parts may not have completed uploading in sorted order, if limit > 1. - this.parts.sort(function (a, b) { - return a.PartNumber - b.PartNumber; - }); - return Promise.resolve().then(function () { - return _this8.options.completeMultipartUpload({ - key: _this8.key, - uploadId: _this8.uploadId, - parts: _this8.parts - }); - }).then(function (result) { - _this8.options.onSuccess(result); - }, function (err) { - _this8._onError(err); - }); - }; - - _proto._abortUpload = function _abortUpload() { - var _this9 = this; - - this.abortController.abort(); - this.createdPromise.then(function () { - _this9.options.abortMultipartUpload({ - key: _this9.key, - uploadId: _this9.uploadId - }); - }, function () {// if the creation failed we do not need to abort - }); - }; - - _proto._onError = function _onError(err) { - if (err && err.name === 'AbortError') { - return; - } - - this.options.onError(err); - }; - - _proto.start = function start() { - this.isPaused = false; - - if (this.uploadId) { - this._resumeUpload(); - } else { - this._createUpload(); - } - }; - - _proto.pause = function pause() { - this.abortController.abort(); // Swap it out for a new controller, because this instance may be resumed later. - - this.abortController = new AbortController(); - this.isPaused = true; - }; - - _proto.abort = function abort(opts) { - if (opts === void 0) { - opts = {}; - } - - var really = opts.really || false; - if (!really) return this.pause(); - - this._abortUpload(); - }; - - return MultipartUploader; -}(); - -module.exports = MultipartUploader; -},{"@uppy/utils/lib/AbortController":21,"@uppy/utils/lib/delay":27}],3:[function(require,module,exports){ -var _class, _temp; - -function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } - -function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); } - -function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } - -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -var _require = require('@uppy/core'), - Plugin = _require.Plugin; - -var _require2 = require('@uppy/companion-client'), - Socket = _require2.Socket, - Provider = _require2.Provider, - RequestClient = _require2.RequestClient; - -var EventTracker = require('@uppy/utils/lib/EventTracker'); - -var emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress'); - -var getSocketHost = require('@uppy/utils/lib/getSocketHost'); - -var RateLimitedQueue = require('@uppy/utils/lib/RateLimitedQueue'); - -var Uploader = require('./MultipartUploader'); - -function assertServerError(res) { - if (res && res.error) { - var error = new Error(res.message); - - _extends(error, res.error); - - throw error; - } - - return res; -} - -module.exports = (_temp = _class = /*#__PURE__*/function (_Plugin) { - _inheritsLoose(AwsS3Multipart, _Plugin); - - function AwsS3Multipart(uppy, opts) { - var _this; - - _this = _Plugin.call(this, uppy, opts) || this; - _this.type = 'uploader'; - _this.id = _this.opts.id || 'AwsS3Multipart'; - _this.title = 'AWS S3 Multipart'; - _this.client = new RequestClient(uppy, opts); - var defaultOptions = { - timeout: 30 * 1000, - limit: 0, - retryDelays: [0, 1000, 3000, 5000], - createMultipartUpload: _this.createMultipartUpload.bind(_assertThisInitialized(_this)), - listParts: _this.listParts.bind(_assertThisInitialized(_this)), - prepareUploadPart: _this.prepareUploadPart.bind(_assertThisInitialized(_this)), - abortMultipartUpload: _this.abortMultipartUpload.bind(_assertThisInitialized(_this)), - completeMultipartUpload: _this.completeMultipartUpload.bind(_assertThisInitialized(_this)) - }; - _this.opts = _extends({}, defaultOptions, opts); - _this.upload = _this.upload.bind(_assertThisInitialized(_this)); - _this.requests = new RateLimitedQueue(_this.opts.limit); - _this.uploaders = Object.create(null); - _this.uploaderEvents = Object.create(null); - _this.uploaderSockets = Object.create(null); - return _this; - } - /** - * Clean up all references for a file's upload: the MultipartUploader instance, - * any events related to the file, and the Companion WebSocket connection. - * - * Set `opts.abort` to tell S3 that the multipart upload is cancelled and must be removed. - * This should be done when the user cancels the upload, not when the upload is completed or errored. - */ - - - var _proto = AwsS3Multipart.prototype; - - _proto.resetUploaderReferences = function resetUploaderReferences(fileID, opts) { - if (opts === void 0) { - opts = {}; - } - - if (this.uploaders[fileID]) { - this.uploaders[fileID].abort({ - really: opts.abort || false - }); - this.uploaders[fileID] = null; - } - - if (this.uploaderEvents[fileID]) { - this.uploaderEvents[fileID].remove(); - this.uploaderEvents[fileID] = null; - } - - if (this.uploaderSockets[fileID]) { - this.uploaderSockets[fileID].close(); - this.uploaderSockets[fileID] = null; - } - }; - - _proto.assertHost = function assertHost(method) { - if (!this.opts.companionUrl) { - throw new Error("Expected a `companionUrl` option containing a Companion address, or if you are not using Companion, a custom `" + method + "` implementation."); - } - }; - - _proto.createMultipartUpload = function createMultipartUpload(file) { - this.assertHost('createMultipartUpload'); - var metadata = {}; - Object.keys(file.meta).map(function (key) { - if (file.meta[key] != null) { - metadata[key] = file.meta[key].toString(); - } - }); - return this.client.post('s3/multipart', { - filename: file.name, - type: file.type, - metadata: metadata - }).then(assertServerError); - }; - - _proto.listParts = function listParts(file, _ref) { - var key = _ref.key, - uploadId = _ref.uploadId; - this.assertHost('listParts'); - var filename = encodeURIComponent(key); - return this.client.get("s3/multipart/" + uploadId + "?key=" + filename).then(assertServerError); - }; - - _proto.prepareUploadPart = function prepareUploadPart(file, _ref2) { - var key = _ref2.key, - uploadId = _ref2.uploadId, - number = _ref2.number; - this.assertHost('prepareUploadPart'); - var filename = encodeURIComponent(key); - return this.client.get("s3/multipart/" + uploadId + "/" + number + "?key=" + filename).then(assertServerError); - }; - - _proto.completeMultipartUpload = function completeMultipartUpload(file, _ref3) { - var key = _ref3.key, - uploadId = _ref3.uploadId, - parts = _ref3.parts; - this.assertHost('completeMultipartUpload'); - var filename = encodeURIComponent(key); - var uploadIdEnc = encodeURIComponent(uploadId); - return this.client.post("s3/multipart/" + uploadIdEnc + "/complete?key=" + filename, { - parts: parts - }).then(assertServerError); - }; - - _proto.abortMultipartUpload = function abortMultipartUpload(file, _ref4) { - var key = _ref4.key, - uploadId = _ref4.uploadId; - this.assertHost('abortMultipartUpload'); - var filename = encodeURIComponent(key); - var uploadIdEnc = encodeURIComponent(uploadId); - return this.client.delete("s3/multipart/" + uploadIdEnc + "?key=" + filename).then(assertServerError); - }; - - _proto.uploadFile = function uploadFile(file) { - var _this2 = this; - - return new Promise(function (resolve, reject) { - var onStart = function onStart(data) { - var cFile = _this2.uppy.getFile(file.id); - - _this2.uppy.setFileState(file.id, { - s3Multipart: _extends({}, cFile.s3Multipart, { - key: data.key, - uploadId: data.uploadId - }) - }); - }; - - var onProgress = function onProgress(bytesUploaded, bytesTotal) { - _this2.uppy.emit('upload-progress', file, { - uploader: _this2, - bytesUploaded: bytesUploaded, - bytesTotal: bytesTotal - }); - }; - - var onError = function onError(err) { - _this2.uppy.log(err); - - _this2.uppy.emit('upload-error', file, err); - - queuedRequest.done(); - - _this2.resetUploaderReferences(file.id); - - reject(err); - }; - - var onSuccess = function onSuccess(result) { - var uploadResp = { - body: _extends({}, result), - uploadURL: result.location - }; - queuedRequest.done(); - - _this2.resetUploaderReferences(file.id); - - var cFile = _this2.uppy.getFile(file.id); - - _this2.uppy.emit('upload-success', cFile || file, uploadResp); - - if (result.location) { - _this2.uppy.log("Download " + upload.file.name + " from " + result.location); - } - - resolve(upload); - }; - - var onPartComplete = function onPartComplete(part) { - var cFile = _this2.uppy.getFile(file.id); - - if (!cFile) { - return; - } - - _this2.uppy.emit('s3-multipart:part-uploaded', cFile, part); - }; - - var upload = new Uploader(file.data, _extends({ - // .bind to pass the file object to each handler. - createMultipartUpload: _this2.opts.createMultipartUpload.bind(_this2, file), - listParts: _this2.opts.listParts.bind(_this2, file), - prepareUploadPart: _this2.opts.prepareUploadPart.bind(_this2, file), - completeMultipartUpload: _this2.opts.completeMultipartUpload.bind(_this2, file), - abortMultipartUpload: _this2.opts.abortMultipartUpload.bind(_this2, file), - getChunkSize: _this2.opts.getChunkSize ? _this2.opts.getChunkSize.bind(_this2) : null, - onStart: onStart, - onProgress: onProgress, - onError: onError, - onSuccess: onSuccess, - onPartComplete: onPartComplete, - limit: _this2.opts.limit || 5, - retryDelays: _this2.opts.retryDelays || [] - }, file.s3Multipart)); - _this2.uploaders[file.id] = upload; - _this2.uploaderEvents[file.id] = new EventTracker(_this2.uppy); - - var queuedRequest = _this2.requests.run(function () { - if (!file.isPaused) { - upload.start(); - } // Don't do anything here, the caller will take care of cancelling the upload itself - // using resetUploaderReferences(). This is because resetUploaderReferences() has to be - // called when this request is still in the queue, and has not been started yet, too. At - // that point this cancellation function is not going to be called. - - - return function () {}; - }); - - _this2.onFileRemove(file.id, function (removed) { - queuedRequest.abort(); - - _this2.resetUploaderReferences(file.id, { - abort: true - }); - - resolve("upload " + removed.id + " was removed"); - }); - - _this2.onCancelAll(file.id, function () { - queuedRequest.abort(); - - _this2.resetUploaderReferences(file.id, { - abort: true - }); - - resolve("upload " + file.id + " was canceled"); - }); - - _this2.onFilePause(file.id, function (isPaused) { - if (isPaused) { - // Remove this file from the queue so another file can start in its place. - queuedRequest.abort(); - upload.pause(); - } else { - // Resuming an upload should be queued, else you could pause and then resume a queued upload to make it skip the queue. - queuedRequest.abort(); - queuedRequest = _this2.requests.run(function () { - upload.start(); - return function () {}; - }); - } - }); - - _this2.onPauseAll(file.id, function () { - queuedRequest.abort(); - upload.pause(); - }); - - _this2.onResumeAll(file.id, function () { - queuedRequest.abort(); - - if (file.error) { - upload.abort(); - } - - queuedRequest = _this2.requests.run(function () { - upload.start(); - return function () {}; - }); - }); // Don't double-emit upload-started for Golden Retriever-restored files that were already started - - - if (!file.progress.uploadStarted || !file.isRestored) { - _this2.uppy.emit('upload-started', file); - } - }); - }; - - _proto.uploadRemote = function uploadRemote(file) { - var _this3 = this; - - this.resetUploaderReferences(file.id); // Don't double-emit upload-started for Golden Retriever-restored files that were already started - - if (!file.progress.uploadStarted || !file.isRestored) { - this.uppy.emit('upload-started', file); - } - - if (file.serverToken) { - return this.connectToServerSocket(file); - } - - return new Promise(function (resolve, reject) { - var Client = file.remote.providerOptions.provider ? Provider : RequestClient; - var client = new Client(_this3.uppy, file.remote.providerOptions); - client.post(file.remote.url, _extends({}, file.remote.body, { - protocol: 's3-multipart', - size: file.data.size, - metadata: file.meta - })).then(function (res) { - _this3.uppy.setFileState(file.id, { - serverToken: res.token - }); - - file = _this3.uppy.getFile(file.id); - return file; - }).then(function (file) { - return _this3.connectToServerSocket(file); - }).then(function () { - resolve(); - }).catch(function (err) { - _this3.uppy.emit('upload-error', file, err); - - reject(err); - }); - }); - }; - - _proto.connectToServerSocket = function connectToServerSocket(file) { - var _this4 = this; - - return new Promise(function (resolve, reject) { - var token = file.serverToken; - var host = getSocketHost(file.remote.companionUrl); - var socket = new Socket({ - target: host + "/api/" + token, - autoOpen: false - }); - _this4.uploaderSockets[file.id] = socket; - _this4.uploaderEvents[file.id] = new EventTracker(_this4.uppy); - - _this4.onFileRemove(file.id, function (removed) { - queuedRequest.abort(); - socket.send('pause', {}); - - _this4.resetUploaderReferences(file.id, { - abort: true - }); - - resolve("upload " + file.id + " was removed"); - }); - - _this4.onFilePause(file.id, function (isPaused) { - if (isPaused) { - // Remove this file from the queue so another file can start in its place. - queuedRequest.abort(); - socket.send('pause', {}); - } else { - // Resuming an upload should be queued, else you could pause and then resume a queued upload to make it skip the queue. - queuedRequest.abort(); - queuedRequest = _this4.requests.run(function () { - socket.send('resume', {}); - return function () {}; - }); - } - }); - - _this4.onPauseAll(file.id, function () { - queuedRequest.abort(); - socket.send('pause', {}); - }); - - _this4.onCancelAll(file.id, function () { - queuedRequest.abort(); - socket.send('pause', {}); - - _this4.resetUploaderReferences(file.id); - - resolve("upload " + file.id + " was canceled"); - }); - - _this4.onResumeAll(file.id, function () { - queuedRequest.abort(); - - if (file.error) { - socket.send('pause', {}); - } - - queuedRequest = _this4.requests.run(function () { - socket.send('resume', {}); - }); - }); - - _this4.onRetry(file.id, function () { - // Only do the retry if the upload is actually in progress; - // else we could try to send these messages when the upload is still queued. - // We may need a better check for this since the socket may also be closed - // for other reasons, like network failures. - if (socket.isOpen) { - socket.send('pause', {}); - socket.send('resume', {}); - } - }); - - _this4.onRetryAll(file.id, function () { - if (socket.isOpen) { - socket.send('pause', {}); - socket.send('resume', {}); - } - }); - - socket.on('progress', function (progressData) { - return emitSocketProgress(_this4, progressData, file); - }); - socket.on('error', function (errData) { - _this4.uppy.emit('upload-error', file, new Error(errData.error)); - - _this4.resetUploaderReferences(file.id); - - queuedRequest.done(); - reject(new Error(errData.error)); - }); - socket.on('success', function (data) { - var uploadResp = { - uploadURL: data.url - }; - - _this4.uppy.emit('upload-success', file, uploadResp); - - _this4.resetUploaderReferences(file.id); - - queuedRequest.done(); - resolve(); - }); - - var queuedRequest = _this4.requests.run(function () { - socket.open(); - - if (file.isPaused) { - socket.send('pause', {}); - } - - return function () {}; - }); - }); - }; - - _proto.upload = function upload(fileIDs) { - var _this5 = this; - - if (fileIDs.length === 0) return Promise.resolve(); - var promises = fileIDs.map(function (id) { - var file = _this5.uppy.getFile(id); - - if (file.isRemote) { - return _this5.uploadRemote(file); - } - - return _this5.uploadFile(file); - }); - return Promise.all(promises); - }; - - _proto.onFileRemove = function onFileRemove(fileID, cb) { - this.uploaderEvents[fileID].on('file-removed', function (file) { - if (fileID === file.id) cb(file.id); - }); - }; - - _proto.onFilePause = function onFilePause(fileID, cb) { - this.uploaderEvents[fileID].on('upload-pause', function (targetFileID, isPaused) { - if (fileID === targetFileID) { - // const isPaused = this.uppy.pauseResume(fileID) - cb(isPaused); - } - }); - }; - - _proto.onRetry = function onRetry(fileID, cb) { - this.uploaderEvents[fileID].on('upload-retry', function (targetFileID) { - if (fileID === targetFileID) { - cb(); - } - }); - }; - - _proto.onRetryAll = function onRetryAll(fileID, cb) { - var _this6 = this; - - this.uploaderEvents[fileID].on('retry-all', function (filesToRetry) { - if (!_this6.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto.onPauseAll = function onPauseAll(fileID, cb) { - var _this7 = this; - - this.uploaderEvents[fileID].on('pause-all', function () { - if (!_this7.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto.onCancelAll = function onCancelAll(fileID, cb) { - var _this8 = this; - - this.uploaderEvents[fileID].on('cancel-all', function () { - if (!_this8.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto.onResumeAll = function onResumeAll(fileID, cb) { - var _this9 = this; - - this.uploaderEvents[fileID].on('resume-all', function () { - if (!_this9.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto.install = function install() { - var _this$uppy$getState = this.uppy.getState(), - capabilities = _this$uppy$getState.capabilities; - - this.uppy.setState({ - capabilities: _extends({}, capabilities, { - resumableUploads: true - }) - }); - this.uppy.addUploader(this.upload); - }; - - _proto.uninstall = function uninstall() { - var _this$uppy$getState2 = this.uppy.getState(), - capabilities = _this$uppy$getState2.capabilities; - - this.uppy.setState({ - capabilities: _extends({}, capabilities, { - resumableUploads: false - }) - }); - this.uppy.removeUploader(this.upload); - }; - - return AwsS3Multipart; -}(Plugin), _class.VERSION = "1.8.18", _temp); -},{"./MultipartUploader":2,"@uppy/companion-client":12,"@uppy/core":15,"@uppy/utils/lib/EventTracker":22,"@uppy/utils/lib/RateLimitedQueue":25,"@uppy/utils/lib/emitSocketProgress":28,"@uppy/utils/lib/getSocketHost":40}],4:[function(require,module,exports){ -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -var cuid = require('cuid'); - -var _require = require('@uppy/companion-client'), - Provider = _require.Provider, - RequestClient = _require.RequestClient, - Socket = _require.Socket; - -var emitSocketProgress = require('@uppy/utils/lib/emitSocketProgress'); - -var getSocketHost = require('@uppy/utils/lib/getSocketHost'); - -var EventTracker = require('@uppy/utils/lib/EventTracker'); - -var ProgressTimeout = require('@uppy/utils/lib/ProgressTimeout'); - -var NetworkError = require('@uppy/utils/lib/NetworkError'); - -var isNetworkError = require('@uppy/utils/lib/isNetworkError'); // See XHRUpload - - -function buildResponseError(xhr, error) { - // 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 = _extends(new Error('Upload error'), { - data: error - }); - } - - if (isNetworkError(xhr)) { - error = new NetworkError(error, xhr); - return error; - } - - error.request = xhr; - return error; -} // See XHRUpload - - -function setTypeInBlob(file) { - var dataWithUpdatedType = file.data.slice(0, file.data.size, file.meta.type); - return dataWithUpdatedType; -} - -module.exports = /*#__PURE__*/function () { - function MiniXHRUpload(uppy, opts) { - this.uppy = uppy; - this.opts = _extends({ - validateStatus: function validateStatus(status, responseText, response) { - return status >= 200 && status < 300; - } - }, opts); - this.requests = opts.__queue; - this.uploaderEvents = Object.create(null); - this.i18n = opts.i18n; - } - - var _proto = MiniXHRUpload.prototype; - - _proto._getOptions = function _getOptions(file) { - var uppy = this.uppy; - var overrides = uppy.getState().xhrUpload; - - var opts = _extends({}, this.opts, overrides || {}, file.xhrUpload || {}, { - headers: {} - }); - - _extends(opts.headers, this.opts.headers); - - if (overrides) { - _extends(opts.headers, overrides.headers); - } - - if (file.xhrUpload) { - _extends(opts.headers, file.xhrUpload.headers); - } - - return opts; - }; - - _proto.uploadFile = function uploadFile(id, current, total) { - var file = this.uppy.getFile(id); - - if (file.error) { - throw new Error(file.error); - } else if (file.isRemote) { - return this._uploadRemoteFile(file, current, total); - } - - return this._uploadLocalFile(file, current, total); - }; - - _proto._addMetadata = function _addMetadata(formData, meta, opts) { - var metaFields = Array.isArray(opts.metaFields) ? opts.metaFields // Send along all fields by default. - : Object.keys(meta); - metaFields.forEach(function (item) { - formData.append(item, meta[item]); - }); - }; - - _proto._createFormDataUpload = function _createFormDataUpload(file, opts) { - var formPost = new FormData(); - - this._addMetadata(formPost, file.meta, opts); - - var dataWithUpdatedType = setTypeInBlob(file); - - if (file.name) { - formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name); - } else { - formPost.append(opts.fieldName, dataWithUpdatedType); - } - - return formPost; - }; - - _proto._createBareUpload = function _createBareUpload(file, opts) { - return file.data; - }; - - _proto._onFileRemoved = function _onFileRemoved(fileID, cb) { - this.uploaderEvents[fileID].on('file-removed', function (file) { - if (fileID === file.id) cb(file.id); - }); - }; - - _proto._onRetry = function _onRetry(fileID, cb) { - this.uploaderEvents[fileID].on('upload-retry', function (targetFileID) { - if (fileID === targetFileID) { - cb(); - } - }); - }; - - _proto._onRetryAll = function _onRetryAll(fileID, cb) { - var _this = this; - - this.uploaderEvents[fileID].on('retry-all', function (filesToRetry) { - if (!_this.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto._onCancelAll = function _onCancelAll(fileID, cb) { - var _this2 = this; - - this.uploaderEvents[fileID].on('cancel-all', function () { - if (!_this2.uppy.getFile(fileID)) return; - cb(); - }); - }; - - _proto._uploadLocalFile = function _uploadLocalFile(file, current, total) { - var _this3 = this; - - var opts = this._getOptions(file); - - this.uppy.log("uploading " + current + " of " + total); - return new Promise(function (resolve, reject) { - // This is done in index.js in the S3 plugin. - // this.uppy.emit('upload-started', file) - var data = opts.formData ? _this3._createFormDataUpload(file, opts) : _this3._createBareUpload(file, opts); - var xhr = new XMLHttpRequest(); - _this3.uploaderEvents[file.id] = new EventTracker(_this3.uppy); - var timer = new ProgressTimeout(opts.timeout, function () { - xhr.abort(); - queuedRequest.done(); - var error = new Error(_this3.i18n('timedOut', { - seconds: Math.ceil(opts.timeout / 1000) - })); - - _this3.uppy.emit('upload-error', file, error); - - reject(error); - }); - var id = cuid(); - xhr.upload.addEventListener('loadstart', function (ev) { - _this3.uppy.log("[AwsS3/XHRUpload] " + id + " started"); - }); - xhr.upload.addEventListener('progress', function (ev) { - _this3.uppy.log("[AwsS3/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) { - _this3.uppy.emit('upload-progress', file, { - uploader: _this3, - bytesUploaded: ev.loaded, - bytesTotal: ev.total - }); - } - }); - xhr.addEventListener('load', function (ev) { - _this3.uppy.log("[AwsS3/XHRUpload] " + id + " finished"); - - timer.done(); - queuedRequest.done(); - - if (_this3.uploaderEvents[file.id]) { - _this3.uploaderEvents[file.id].remove(); - - _this3.uploaderEvents[file.id] = null; - } - - if (opts.validateStatus(ev.target.status, xhr.responseText, xhr)) { - var _body = opts.getResponseData(xhr.responseText, xhr); - - var uploadURL = _body[opts.responseUrlFieldName]; - var uploadResp = { - status: ev.target.status, - body: _body, - uploadURL: uploadURL - }; - - _this3.uppy.emit('upload-success', file, uploadResp); - - if (uploadURL) { - _this3.uppy.log("Download " + file.name + " from " + uploadURL); - } - - return resolve(file); - } - - var body = opts.getResponseData(xhr.responseText, xhr); - var error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr)); - var response = { - status: ev.target.status, - body: body - }; - - _this3.uppy.emit('upload-error', file, error, response); - - return reject(error); - }); - xhr.addEventListener('error', function (ev) { - _this3.uppy.log("[AwsS3/XHRUpload] " + id + " errored"); - - timer.done(); - queuedRequest.done(); - - if (_this3.uploaderEvents[file.id]) { - _this3.uploaderEvents[file.id].remove(); - - _this3.uploaderEvents[file.id] = null; - } - - var error = buildResponseError(xhr, opts.getResponseError(xhr.responseText, xhr)); - - _this3.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; - } - - Object.keys(opts.headers).forEach(function (header) { - xhr.setRequestHeader(header, opts.headers[header]); - }); - - var queuedRequest = _this3.requests.run(function () { - xhr.send(data); - return function () { - timer.done(); - xhr.abort(); - }; - }, { - priority: 1 - }); - - _this3._onFileRemoved(file.id, function () { - queuedRequest.abort(); - reject(new Error('File removed')); - }); - - _this3._onCancelAll(file.id, function () { - queuedRequest.abort(); - reject(new Error('Upload cancelled')); - }); - }); - }; - - _proto._uploadRemoteFile = function _uploadRemoteFile(file, current, total) { - var _this4 = this; - - var opts = this._getOptions(file); - - return new Promise(function (resolve, reject) { - // This is done in index.js in the S3 plugin. - // this.uppy.emit('upload-started', file) - var fields = {}; - var metaFields = Array.isArray(opts.metaFields) ? opts.metaFields // Send along all fields by default. - : Object.keys(file.meta); - metaFields.forEach(function (name) { - fields[name] = file.meta[name]; - }); - var Client = file.remote.providerOptions.provider ? Provider : RequestClient; - var client = new Client(_this4.uppy, file.remote.providerOptions); - client.post(file.remote.url, _extends({}, 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(function (res) { - var token = res.token; - var host = getSocketHost(file.remote.companionUrl); - var socket = new Socket({ - target: host + "/api/" + token, - autoOpen: false - }); - _this4.uploaderEvents[file.id] = new EventTracker(_this4.uppy); - - _this4._onFileRemoved(file.id, function () { - socket.send('pause', {}); - queuedRequest.abort(); - resolve("upload " + file.id + " was removed"); - }); - - _this4._onCancelAll(file.id, function () { - socket.send('pause', {}); - queuedRequest.abort(); - resolve("upload " + file.id + " was canceled"); - }); - - _this4._onRetry(file.id, function () { - socket.send('pause', {}); - socket.send('resume', {}); - }); - - _this4._onRetryAll(file.id, function () { - socket.send('pause', {}); - socket.send('resume', {}); - }); - - socket.on('progress', function (progressData) { - return emitSocketProgress(_this4, progressData, file); - }); - socket.on('success', function (data) { - var body = opts.getResponseData(data.response.responseText, data.response); - var uploadURL = body[opts.responseUrlFieldName]; - var uploadResp = { - status: data.response.status, - body: body, - uploadURL: uploadURL - }; - - _this4.uppy.emit('upload-success', file, uploadResp); - - queuedRequest.done(); - - if (_this4.uploaderEvents[file.id]) { - _this4.uploaderEvents[file.id].remove(); - - _this4.uploaderEvents[file.id] = null; - } - - return resolve(); - }); - socket.on('error', function (errData) { - var resp = errData.response; - var error = resp ? opts.getResponseError(resp.responseText, resp) : _extends(new Error(errData.error.message), { - cause: errData.error - }); - - _this4.uppy.emit('upload-error', file, error); - - queuedRequest.done(); - - if (_this4.uploaderEvents[file.id]) { - _this4.uploaderEvents[file.id].remove(); - - _this4.uploaderEvents[file.id] = null; - } - - reject(error); - }); - - var queuedRequest = _this4.requests.run(function () { - socket.open(); - - if (file.isPaused) { - socket.send('pause', {}); - } - - return function () { - return socket.close(); - }; - }); - }).catch(function (err) { - _this4.uppy.emit('upload-error', file, err); - - reject(err); - }); - }); - }; - - return MiniXHRUpload; -}(); -},{"@uppy/companion-client":12,"@uppy/utils/lib/EventTracker":22,"@uppy/utils/lib/NetworkError":23,"@uppy/utils/lib/ProgressTimeout":24,"@uppy/utils/lib/emitSocketProgress":28,"@uppy/utils/lib/getSocketHost":40,"@uppy/utils/lib/isNetworkError":44,"cuid":50}],5:[function(require,module,exports){ -var _class, _temp; - -function _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } - -function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); } - -function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } - -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -/** - * This plugin is currently a A Big Hack™! The core reason for that is how this plugin - * interacts with Uppy's current pipeline design. The pipeline can handle files in steps, - * including preprocessing, uploading, and postprocessing steps. This plugin initially - * was designed to do its work in a preprocessing step, and let XHRUpload deal with the - * actual file upload as an uploading step. However, Uppy runs steps on all files at once, - * sequentially: first, all files go through a preprocessing step, then, once they are all - * done, they go through the uploading step. - * - * For S3, this causes severely broken behaviour when users upload many files. The - * preprocessing step will request S3 upload URLs that are valid for a short time only, - * but it has to do this for _all_ files, which can take a long time if there are hundreds - * or even thousands of files. By the time the uploader step starts, the first URLs may - * already have expired. If not, the uploading might take such a long time that later URLs - * will expire before some files can be uploaded. - * - * The long-term solution to this problem is to change the upload pipeline so that files - * can be sent to the next step individually. That requires a breaking change, so it is - * planned for some future Uppy version. - * - * In the mean time, this plugin is stuck with a hackier approach: the necessary parts - * of the XHRUpload implementation were copied into this plugin, as the MiniXHRUpload - * class, and this plugin calls into it immediately once it receives an upload URL. - * This isn't as nicely modular as we'd like and requires us to maintain two copies of - * the XHRUpload code, but at least it's not horrifically broken :) - */ -// If global `URL` constructor is available, use it -var URL_ = typeof URL === 'function' ? URL : require('url-parse'); - -var _require = require('@uppy/core'), - Plugin = _require.Plugin; - -var Translator = require('@uppy/utils/lib/Translator'); - -var RateLimitedQueue = require('@uppy/utils/lib/RateLimitedQueue'); - -var settle = require('@uppy/utils/lib/settle'); - -var hasProperty = require('@uppy/utils/lib/hasProperty'); - -var _require2 = require('@uppy/companion-client'), - RequestClient = _require2.RequestClient; - -var qsStringify = require('qs-stringify'); - -var MiniXHRUpload = require('./MiniXHRUpload'); - -var isXml = require('./isXml'); - -function resolveUrl(origin, link) { - return origin ? new URL_(link, origin).toString() : new URL_(link).toString(); -} -/** - * Get the contents of a named tag in an XML source string. - * - * @param {string} source - The XML source string. - * @param {string} tagName - The name of the tag. - * @returns {string} The contents of the tag, or the empty string if the tag does not exist. - */ - - -function getXmlValue(source, tagName) { - var start = source.indexOf("<" + tagName + ">"); - var end = source.indexOf("", start); - return start !== -1 && end !== -1 ? source.slice(start + tagName.length + 2, end) : ''; -} - -function assertServerError(res) { - if (res && res.error) { - var error = new Error(res.message); - - _extends(error, res.error); - - throw error; - } - - return res; -} // warning deduplication flag: see `getResponseData()` XHRUpload option definition - - -var warnedSuccessActionStatus = false; -module.exports = (_temp = _class = /*#__PURE__*/function (_Plugin) { - _inheritsLoose(AwsS3, _Plugin); - - function AwsS3(uppy, opts) { - var _this; - - _this = _Plugin.call(this, uppy, opts) || this; - _this.type = 'uploader'; - _this.id = _this.opts.id || 'AwsS3'; - _this.title = 'AWS S3'; - _this.defaultLocale = { - strings: { - timedOut: 'Upload stalled for %{seconds} seconds, aborting.' - } - }; - var defaultOptions = { - timeout: 30 * 1000, - limit: 0, - metaFields: [], - // have to opt in - getUploadParameters: _this.getUploadParameters.bind(_assertThisInitialized(_this)) - }; - _this.opts = _extends({}, defaultOptions, opts); - - _this.i18nInit(); - - _this.client = new RequestClient(uppy, opts); - _this.handleUpload = _this.handleUpload.bind(_assertThisInitialized(_this)); - _this.requests = new RateLimitedQueue(_this.opts.limit); - return _this; - } - - var _proto = AwsS3.prototype; - - _proto.setOptions = function setOptions(newOpts) { - _Plugin.prototype.setOptions.call(this, newOpts); - - this.i18nInit(); - }; - - _proto.i18nInit = function i18nInit() { - this.translator = new Translator([this.defaultLocale, this.uppy.locale, this.opts.locale]); - this.i18n = this.translator.translate.bind(this.translator); - this.setPluginState(); // so that UI re-renders and we see the updated locale - }; - - _proto.getUploadParameters = function getUploadParameters(file) { - if (!this.opts.companionUrl) { - throw new Error('Expected a `companionUrl` option containing a Companion address.'); - } - - var filename = file.meta.name; - var type = file.meta.type; - var metadata = {}; - this.opts.metaFields.forEach(function (key) { - if (file.meta[key] != null) { - metadata[key] = file.meta[key].toString(); - } - }); - var query = qsStringify({ - filename: filename, - type: type, - metadata: metadata - }); - return this.client.get("s3/params?" + query).then(assertServerError); - }; - - _proto.validateParameters = function validateParameters(file, params) { - var valid = typeof params === 'object' && params && typeof params.url === 'string' && (typeof params.fields === 'object' || params.fields == null); - - if (!valid) { - var 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."); - console.error(err); - throw err; - } - - var methodIsValid = params.method == null || /^(put|post)$/i.test(params.method); - - if (!methodIsValid) { - var _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."); - - console.error(_err); - throw _err; - } - }; - - _proto.handleUpload = function handleUpload(fileIDs) { - var _this2 = this; - - /** - * keep track of `getUploadParameters()` responses - * so we can cancel the calls individually using just a file ID - * - * @type {object.} - */ - var paramsPromises = Object.create(null); - - function onremove(file) { - var id = file.id; - - if (hasProperty(paramsPromises, id)) { - paramsPromises[id].abort(); - } - } - - this.uppy.on('file-removed', onremove); - fileIDs.forEach(function (id) { - var file = _this2.uppy.getFile(id); - - _this2.uppy.emit('upload-started', file); - }); - var getUploadParameters = this.requests.wrapPromiseFunction(function (file) { - return _this2.opts.getUploadParameters(file); - }); - var numberOfFiles = fileIDs.length; - return settle(fileIDs.map(function (id, index) { - paramsPromises[id] = getUploadParameters(_this2.uppy.getFile(id)); - return paramsPromises[id].then(function (params) { - delete paramsPromises[id]; - - var file = _this2.uppy.getFile(id); - - _this2.validateParameters(file, params); - - var _params$method = params.method, - method = _params$method === void 0 ? 'post' : _params$method, - url = params.url, - fields = params.fields, - headers = params.headers; - var xhrOpts = { - method: method, - formData: method.toLowerCase() === 'post', - endpoint: url, - metaFields: fields ? Object.keys(fields) : [] - }; - - if (headers) { - xhrOpts.headers = headers; - } - - _this2.uppy.setFileState(file.id, { - meta: _extends({}, file.meta, fields), - xhrUpload: xhrOpts - }); - - return _this2._uploader.uploadFile(file.id, index, numberOfFiles); - }).catch(function (error) { - delete paramsPromises[id]; - - var file = _this2.uppy.getFile(id); - - _this2.uppy.emit('upload-error', file, error); - }); - })).then(function (settled) { - // cleanup. - _this2.uppy.off('file-removed', onremove); - - return settled; - }); - }; - - _proto.install = function install() { - var uppy = this.uppy; - this.uppy.addUploader(this.handleUpload); // Get the response data from a successful XMLHttpRequest instance. - // `content` is the S3 response as a string. - // `xhr` is the XMLHttpRequest instance. - - function defaultGetResponseData(content, xhr) { - var opts = this; // If no response, we've hopefully done a PUT request to the file - // in the bucket on its full URL. - - if (!isXml(content, xhr)) { - if (opts.method.toUpperCase() === 'POST') { - if (!warnedSuccessActionStatus) { - 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'); - warnedSuccessActionStatus = true; - } // The responseURL won't contain the object key. Give up. - - - return { - location: null - }; - } // responseURL is not available in older browsers. - - - if (!xhr.responseURL) { - return { - location: null - }; - } // Trim the query string because it's going to be a bunch of presign - // parameters for a PUT request—doing a GET request with those will - // always result in an error - - - return { - location: xhr.responseURL.replace(/\?.*$/, '') - }; - } - - return { - // Some S3 alternatives do not reply with an absolute URL. - // Eg DigitalOcean Spaces uses /$bucketName/xyz - location: resolveUrl(xhr.responseURL, getXmlValue(content, 'Location')), - bucket: getXmlValue(content, 'Bucket'), - key: getXmlValue(content, 'Key'), - etag: getXmlValue(content, 'ETag') - }; - } // Get the error data from a failed XMLHttpRequest instance. - // `content` is the S3 response as a string. - // `xhr` is the XMLHttpRequest instance. - - - function defaultGetResponseError(content, xhr) { - // If no response, we don't have a specific error message, use the default. - if (!isXml(content, xhr)) { - return; - } - - var error = getXmlValue(content, 'Message'); - return new Error(error); - } - - var xhrOptions = { - fieldName: 'file', - responseUrlFieldName: 'location', - timeout: this.opts.timeout, - // Share the rate limiting queue with XHRUpload. - __queue: this.requests, - responseType: 'text', - getResponseData: this.opts.getResponseData || defaultGetResponseData, - getResponseError: defaultGetResponseError - }; // Only for MiniXHRUpload, remove once we can depend on XHRUpload directly again - - xhrOptions.i18n = this.i18n; // Revert to `this.uppy.use(XHRUpload)` once the big comment block at the top of - // this file is solved - - this._uploader = new MiniXHRUpload(this.uppy, xhrOptions); - }; - - _proto.uninstall = function uninstall() { - this.uppy.removeUploader(this.handleUpload); - }; - - return AwsS3; -}(Plugin), _class.VERSION = "1.7.12", _temp); -},{"./MiniXHRUpload":4,"./isXml":6,"@uppy/companion-client":12,"@uppy/core":15,"@uppy/utils/lib/RateLimitedQueue":25,"@uppy/utils/lib/Translator":26,"@uppy/utils/lib/hasProperty":42,"@uppy/utils/lib/settle":46,"qs-stringify":58,"url-parse":61}],6:[function(require,module,exports){ -/** - * Remove parameters like `charset=utf-8` from the end of a mime type string. - * - * @param {string} mimeType - The mime type string that may have optional parameters. - * @returns {string} The "base" mime type, i.e. only 'category/type'. - */ -function removeMimeParameters(mimeType) { - return mimeType.replace(/;.*$/, ''); -} -/** - * Check if a response contains XML based on the response object and its text content. - * - * @param {string} content - The text body of the response. - * @param {object|XMLHttpRequest} xhr - The XHR object or response object from Companion. - * @returns {bool} Whether the content is (probably) XML. - */ - - -function isXml(content, xhr) { - var rawContentType = xhr.headers ? xhr.headers['content-type'] : xhr.getResponseHeader('Content-Type'); - - if (typeof rawContentType === 'string') { - var contentType = removeMimeParameters(rawContentType).toLowerCase(); - - if (contentType === 'application/xml' || contentType === 'text/xml') { - return true; - } // GCS uses text/html for some reason - // https://github.com/transloadit/uppy/issues/896 - - - if (contentType === 'text/html' && /^<\?xml /.test(content)) { - return true; - } - } - - return false; -} - -module.exports = isXml; -},{}],7:[function(require,module,exports){ -'use strict'; - -function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); } - -function _wrapNativeSuper(Class) { var _cache = typeof Map === "function" ? new Map() : undefined; _wrapNativeSuper = function _wrapNativeSuper(Class) { if (Class === null || !_isNativeFunction(Class)) return Class; if (typeof Class !== "function") { throw new TypeError("Super expression must either be null or a function"); } if (typeof _cache !== "undefined") { if (_cache.has(Class)) return _cache.get(Class); _cache.set(Class, Wrapper); } function Wrapper() { return _construct(Class, arguments, _getPrototypeOf(this).constructor); } Wrapper.prototype = Object.create(Class.prototype, { constructor: { value: Wrapper, enumerable: false, writable: true, configurable: true } }); return _setPrototypeOf(Wrapper, Class); }; return _wrapNativeSuper(Class); } - -function _construct(Parent, args, Class) { if (_isNativeReflectConstruct()) { _construct = Reflect.construct; } else { _construct = function _construct(Parent, args, Class) { var a = [null]; a.push.apply(a, args); var Constructor = Function.bind.apply(Parent, a); var instance = new Constructor(); if (Class) _setPrototypeOf(instance, Class.prototype); return instance; }; } return _construct.apply(null, arguments); } - -function _isNativeReflectConstruct() { if (typeof Reflect === "undefined" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === "function") return true; try { Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); return true; } catch (e) { return false; } } - -function _isNativeFunction(fn) { return Function.toString.call(fn).indexOf("[native code]") !== -1; } - -function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } - -function _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); } - -var AuthError = /*#__PURE__*/function (_Error) { - _inheritsLoose(AuthError, _Error); - - function AuthError() { - var _this; - - _this = _Error.call(this, 'Authorization required') || this; - _this.name = 'AuthError'; - _this.isAuthError = true; - return _this; - } - - return AuthError; -}( /*#__PURE__*/_wrapNativeSuper(Error)); - -module.exports = AuthError; -},{}],8:[function(require,module,exports){ -'use strict'; - -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); } - -function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } - -var qsStringify = require('qs-stringify'); - -var URL = require('url-parse'); - -var RequestClient = require('./RequestClient'); - -var tokenStorage = require('./tokenStorage'); - -var _getName = function _getName(id) { - return id.split('-').map(function (s) { - return s.charAt(0).toUpperCase() + s.slice(1); - }).join(' '); -}; - -module.exports = /*#__PURE__*/function (_RequestClient) { - _inheritsLoose(Provider, _RequestClient); - - function Provider(uppy, opts) { - var _this; - - _this = _RequestClient.call(this, uppy, opts) || this; - _this.provider = opts.provider; - _this.id = _this.provider; - _this.name = _this.opts.name || _getName(_this.id); - _this.pluginId = _this.opts.pluginId; - _this.tokenKey = "companion-" + _this.pluginId + "-auth-token"; - _this.companionKeysParams = _this.opts.companionKeysParams; - _this.preAuthToken = null; - return _this; - } - - var _proto = Provider.prototype; - - _proto.headers = function headers() { - var _this2 = this; - - return Promise.all([_RequestClient.prototype.headers.call(this), this.getAuthToken()]).then(function (_ref) { - var headers = _ref[0], - token = _ref[1]; - var authHeaders = {}; - - if (token) { - authHeaders['uppy-auth-token'] = token; - } - - if (_this2.companionKeysParams) { - authHeaders['uppy-credentials-params'] = btoa(JSON.stringify({ - params: _this2.companionKeysParams - })); - } - - return _extends({}, headers, authHeaders); - }); - }; - - _proto.onReceiveResponse = function onReceiveResponse(response) { - response = _RequestClient.prototype.onReceiveResponse.call(this, response); - var plugin = this.uppy.getPlugin(this.pluginId); - var oldAuthenticated = plugin.getPluginState().authenticated; - var authenticated = oldAuthenticated ? response.status !== 401 : response.status < 400; - plugin.setPluginState({ - authenticated: authenticated - }); - return response; - } // @todo(i.olarewaju) consider whether or not this method should be exposed - ; - - _proto.setAuthToken = function setAuthToken(token) { - return this.uppy.getPlugin(this.pluginId).storage.setItem(this.tokenKey, token); - }; - - _proto.getAuthToken = function getAuthToken() { - return this.uppy.getPlugin(this.pluginId).storage.getItem(this.tokenKey); - }; - - _proto.authUrl = function authUrl(queries) { - if (queries === void 0) { - queries = {}; - } - - if (this.preAuthToken) { - queries.uppyPreAuthToken = this.preAuthToken; - } - - var strigifiedQueries = qsStringify(queries); - strigifiedQueries = strigifiedQueries ? "?" + strigifiedQueries : strigifiedQueries; - return this.hostname + "/" + this.id + "/connect" + strigifiedQueries; - }; - - _proto.fileUrl = function fileUrl(id) { - return this.hostname + "/" + this.id + "/get/" + id; - }; - - _proto.fetchPreAuthToken = function fetchPreAuthToken() { - var _this3 = this; - - if (!this.companionKeysParams) { - return Promise.resolve(); - } - - return this.post(this.id + "/preauth/", { - params: this.companionKeysParams - }).then(function (res) { - _this3.preAuthToken = res.token; - }).catch(function (err) { - _this3.uppy.log("[CompanionClient] unable to fetch preAuthToken " + err, 'warning'); - }); - }; - - _proto.list = function list(directory) { - return this.get(this.id + "/list/" + (directory || '')); - }; - - _proto.logout = function logout() { - var _this4 = this; - - return this.get(this.id + "/logout").then(function (response) { - return Promise.all([response, _this4.uppy.getPlugin(_this4.pluginId).storage.removeItem(_this4.tokenKey)]); - }).then(function (_ref2) { - var response = _ref2[0]; - return response; - }); - }; - - Provider.initPlugin = function initPlugin(plugin, opts, defaultOpts) { - plugin.type = 'acquirer'; - plugin.files = []; - - if (defaultOpts) { - plugin.opts = _extends({}, defaultOpts, opts); - } - - if (opts.serverUrl || opts.serverPattern) { - throw new Error('`serverUrl` and `serverPattern` have been renamed to `companionUrl` and `companionAllowedHosts` respectively in the 0.30.5 release. Please consult the docs (for example, https://uppy.io/docs/instagram/ for the Instagram plugin) and use the updated options.`'); - } - - if (opts.companionAllowedHosts) { - var pattern = opts.companionAllowedHosts; // validate companionAllowedHosts param - - if (typeof pattern !== 'string' && !Array.isArray(pattern) && !(pattern instanceof RegExp)) { - throw new TypeError(plugin.id + ": the option \"companionAllowedHosts\" must be one of string, Array, RegExp"); - } - - plugin.opts.companionAllowedHosts = pattern; - } else { - // does not start with https:// - if (/^(?!https?:\/\/).*$/i.test(opts.companionUrl)) { - plugin.opts.companionAllowedHosts = "https://" + opts.companionUrl.replace(/^\/\//, ''); - } else { - plugin.opts.companionAllowedHosts = new URL(opts.companionUrl).origin; - } - } - - plugin.storage = plugin.opts.storage || tokenStorage; - }; - - return Provider; -}(RequestClient); -},{"./RequestClient":9,"./tokenStorage":13,"qs-stringify":58,"url-parse":61}],9:[function(require,module,exports){ -'use strict'; - -var _class, _temp; - -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } - -function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } - -var AuthError = require('./AuthError'); - -var fetchWithNetworkError = require('@uppy/utils/lib/fetchWithNetworkError'); // Remove the trailing slash so we can always safely append /xyz. - - -function stripSlash(url) { - return url.replace(/\/$/, ''); -} - -module.exports = (_temp = _class = /*#__PURE__*/function () { - function RequestClient(uppy, opts) { - this.uppy = uppy; - this.opts = opts; - this.onReceiveResponse = this.onReceiveResponse.bind(this); - this.allowedHeaders = ['accept', 'content-type', 'uppy-auth-token']; - this.preflightDone = false; - } - - var _proto = RequestClient.prototype; - - _proto.headers = function headers() { - var userHeaders = this.opts.companionHeaders || this.opts.serverHeaders || {}; - return Promise.resolve(_extends({}, this.defaultHeaders, userHeaders)); - }; - - _proto._getPostResponseFunc = function _getPostResponseFunc(skip) { - var _this = this; - - return function (response) { - if (!skip) { - return _this.onReceiveResponse(response); - } - - return response; - }; - }; - - _proto.onReceiveResponse = function onReceiveResponse(response) { - var state = this.uppy.getState(); - var companion = state.companion || {}; - var host = this.opts.companionUrl; - var headers = response.headers; // Store the self-identified domain name for the Companion instance we just hit. - - if (headers.has('i-am') && headers.get('i-am') !== companion[host]) { - var _extends2; - - this.uppy.setState({ - companion: _extends({}, companion, (_extends2 = {}, _extends2[host] = headers.get('i-am'), _extends2)) - }); - } - - return response; - }; - - _proto._getUrl = function _getUrl(url) { - if (/^(https?:|)\/\//.test(url)) { - return url; - } - - return this.hostname + "/" + url; - }; - - _proto._json = function _json(res) { - if (res.status === 401) { - throw new AuthError(); - } - - if (res.status < 200 || res.status > 300) { - var errMsg = "Failed request with status: " + res.status + ". " + res.statusText; - return res.json().then(function (errData) { - errMsg = errData.message ? errMsg + " message: " + errData.message : errMsg; - errMsg = errData.requestId ? errMsg + " request-Id: " + errData.requestId : errMsg; - throw new Error(errMsg); - }).catch(function () { - throw new Error(errMsg); - }); - } - - return res.json(); - }; - - _proto.preflight = function preflight(path) { - var _this2 = this; - - if (this.preflightDone) { - return Promise.resolve(this.allowedHeaders.slice()); - } - - return fetch(this._getUrl(path), { - method: 'OPTIONS' - }).then(function (response) { - if (response.headers.has('access-control-allow-headers')) { - _this2.allowedHeaders = response.headers.get('access-control-allow-headers').split(',').map(function (headerName) { - return headerName.trim().toLowerCase(); - }); - } - - _this2.preflightDone = true; - return _this2.allowedHeaders.slice(); - }).catch(function (err) { - _this2.uppy.log("[CompanionClient] unable to make preflight request " + err, 'warning'); - - _this2.preflightDone = true; - return _this2.allowedHeaders.slice(); - }); - }; - - _proto.preflightAndHeaders = function preflightAndHeaders(path) { - var _this3 = this; - - return Promise.all([this.preflight(path), this.headers()]).then(function (_ref) { - var allowedHeaders = _ref[0], - headers = _ref[1]; - // filter to keep only allowed Headers - Object.keys(headers).forEach(function (header) { - if (allowedHeaders.indexOf(header.toLowerCase()) === -1) { - _this3.uppy.log("[CompanionClient] excluding unallowed header " + header); - - delete headers[header]; - } - }); - return headers; - }); - }; - - _proto.get = function get(path, skipPostResponse) { - var _this4 = this; - - return this.preflightAndHeaders(path).then(function (headers) { - return fetchWithNetworkError(_this4._getUrl(path), { - method: 'get', - headers: headers, - credentials: _this4.opts.companionCookiesRule || 'same-origin' - }); - }).then(this._getPostResponseFunc(skipPostResponse)).then(function (res) { - return _this4._json(res); - }).catch(function (err) { - if (!err.isAuthError) { - err.message = "Could not get " + _this4._getUrl(path) + ". " + err.message; - } - - return Promise.reject(err); - }); - }; - - _proto.post = function post(path, data, skipPostResponse) { - var _this5 = this; - - return this.preflightAndHeaders(path).then(function (headers) { - return fetchWithNetworkError(_this5._getUrl(path), { - method: 'post', - headers: headers, - credentials: _this5.opts.companionCookiesRule || 'same-origin', - body: JSON.stringify(data) - }); - }).then(this._getPostResponseFunc(skipPostResponse)).then(function (res) { - return _this5._json(res); - }).catch(function (err) { - if (!err.isAuthError) { - err.message = "Could not post " + _this5._getUrl(path) + ". " + err.message; - } - - return Promise.reject(err); - }); - }; - - _proto.delete = function _delete(path, data, skipPostResponse) { - var _this6 = this; - - return this.preflightAndHeaders(path).then(function (headers) { - return fetchWithNetworkError(_this6.hostname + "/" + path, { - method: 'delete', - headers: headers, - credentials: _this6.opts.companionCookiesRule || 'same-origin', - body: data ? JSON.stringify(data) : null - }); - }).then(this._getPostResponseFunc(skipPostResponse)).then(function (res) { - return _this6._json(res); - }).catch(function (err) { - if (!err.isAuthError) { - err.message = "Could not delete " + _this6._getUrl(path) + ". " + err.message; - } - - return Promise.reject(err); - }); - }; - - _createClass(RequestClient, [{ - key: "hostname", - get: function get() { - var _this$uppy$getState = this.uppy.getState(), - companion = _this$uppy$getState.companion; - - var host = this.opts.companionUrl; - return stripSlash(companion && companion[host] ? companion[host] : host); - } - }, { - key: "defaultHeaders", - get: function get() { - return { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'Uppy-Versions': "@uppy/companion-client=" + RequestClient.VERSION - }; - } - }]); - - return RequestClient; -}(), _class.VERSION = "1.10.2", _temp); -},{"./AuthError":7,"@uppy/utils/lib/fetchWithNetworkError":29}],10:[function(require,module,exports){ -'use strict'; - -function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; _setPrototypeOf(subClass, superClass); } - -function _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); } - -var RequestClient = require('./RequestClient'); - -var _getName = function _getName(id) { - return id.split('-').map(function (s) { - return s.charAt(0).toUpperCase() + s.slice(1); - }).join(' '); -}; - -module.exports = /*#__PURE__*/function (_RequestClient) { - _inheritsLoose(SearchProvider, _RequestClient); - - function SearchProvider(uppy, opts) { - var _this; - - _this = _RequestClient.call(this, uppy, opts) || this; - _this.provider = opts.provider; - _this.id = _this.provider; - _this.name = _this.opts.name || _getName(_this.id); - _this.pluginId = _this.opts.pluginId; - return _this; - } - - var _proto = SearchProvider.prototype; - - _proto.fileUrl = function fileUrl(id) { - return this.hostname + "/search/" + this.id + "/get/" + id; - }; - - _proto.search = function search(text, queries) { - queries = queries ? "&" + queries : ''; - return this.get("search/" + this.id + "/list?q=" + encodeURIComponent(text) + queries); - }; - - return SearchProvider; -}(RequestClient); -},{"./RequestClient":9}],11:[function(require,module,exports){ -var ee = require('namespace-emitter'); - -module.exports = /*#__PURE__*/function () { - function UppySocket(opts) { - this.opts = opts; - this._queued = []; - this.isOpen = false; - this.emitter = ee(); - this._handleMessage = this._handleMessage.bind(this); - this.close = this.close.bind(this); - this.emit = this.emit.bind(this); - this.on = this.on.bind(this); - this.once = this.once.bind(this); - this.send = this.send.bind(this); - - if (!opts || opts.autoOpen !== false) { - this.open(); - } - } - - var _proto = UppySocket.prototype; - - _proto.open = function open() { - var _this = this; - - this.socket = new WebSocket(this.opts.target); - - this.socket.onopen = function (e) { - _this.isOpen = true; - - while (_this._queued.length > 0 && _this.isOpen) { - var first = _this._queued[0]; - - _this.send(first.action, first.payload); - - _this._queued = _this._queued.slice(1); - } - }; - - this.socket.onclose = function (e) { - _this.isOpen = false; - }; - - this.socket.onmessage = this._handleMessage; - }; - - _proto.close = function close() { - if (this.socket) { - this.socket.close(); - } - }; - - _proto.send = function send(action, payload) { - // attach uuid - if (!this.isOpen) { - this._queued.push({ - action: action, - payload: payload - }); - - return; - } - - this.socket.send(JSON.stringify({ - action: action, - payload: payload - })); - }; - - _proto.on = function on(action, handler) { - this.emitter.on(action, handler); - }; - - _proto.emit = function emit(action, payload) { - this.emitter.emit(action, payload); - }; - - _proto.once = function once(action, handler) { - this.emitter.once(action, handler); - }; - - _proto._handleMessage = function _handleMessage(e) { - try { - var message = JSON.parse(e.data); - this.emit(message.action, message.payload); - } catch (err) { - console.log(err); - } - }; - - return UppySocket; -}(); -},{"namespace-emitter":56}],12:[function(require,module,exports){ -'use strict'; -/** - * Manages communications with Companion - */ - -var RequestClient = require('./RequestClient'); - -var Provider = require('./Provider'); - -var SearchProvider = require('./SearchProvider'); - -var Socket = require('./Socket'); - -module.exports = { - RequestClient: RequestClient, - Provider: Provider, - SearchProvider: SearchProvider, - Socket: Socket -}; -},{"./Provider":8,"./RequestClient":9,"./SearchProvider":10,"./Socket":11}],13:[function(require,module,exports){ -'use strict'; -/** - * This module serves as an Async wrapper for LocalStorage - */ - -module.exports.setItem = function (key, value) { - return new Promise(function (resolve) { - localStorage.setItem(key, value); - resolve(); - }); -}; - -module.exports.getItem = function (key) { - return Promise.resolve(localStorage.getItem(key)); -}; - -module.exports.removeItem = function (key) { - return new Promise(function (resolve) { - localStorage.removeItem(key); - resolve(); - }); -}; -},{}],14:[function(require,module,exports){ -function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } - -var preact = require('preact'); - -var findDOMElement = require('@uppy/utils/lib/findDOMElement'); -/** - * Defer a frequent call to the microtask queue. - */ - - -function debounce(fn) { - var calling = null; - var latestArgs = null; - return function () { - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - - latestArgs = args; - - if (!calling) { - calling = Promise.resolve().then(function () { - calling = null; // At this point `args` may be different from the most - // recent state, if multiple calls happened since this task - // was queued. So we use the `latestArgs`, which definitely - // is the most recent call. - - return fn.apply(void 0, latestArgs); - }); - } - - return calling; - }; -} -/** - * Boilerplate that all Plugins share - and should not be used - * directly. It also shows which methods final plugins should implement/override, - * this deciding on structure. - * - * @param {object} main Uppy core object - * @param {object} object with plugin options - * @returns {Array|string} files or success/fail message - */ - - -module.exports = /*#__PURE__*/function () { - function Plugin(uppy, opts) { - this.uppy = uppy; - this.opts = opts || {}; - this.update = this.update.bind(this); - this.mount = this.mount.bind(this); - this.install = this.install.bind(this); - this.uninstall = this.uninstall.bind(this); - } - - var _proto = Plugin.prototype; - - _proto.getPluginState = function getPluginState() { - var _this$uppy$getState = this.uppy.getState(), - plugins = _this$uppy$getState.plugins; - - return plugins[this.id] || {}; - }; - - _proto.setPluginState = function setPluginState(update) { - var _extends2; - - var _this$uppy$getState2 = this.uppy.getState(), - plugins = _this$uppy$getState2.plugins; - - this.uppy.setState({ - plugins: _extends({}, plugins, (_extends2 = {}, _extends2[this.id] = _extends({}, plugins[this.id], update), _extends2)) - }); - }; - - _proto.setOptions = function setOptions(newOpts) { - this.opts = _extends({}, this.opts, newOpts); - this.setPluginState(); // so that UI re-renders with new options - }; - - _proto.update = function update(state) { - if (typeof this.el === 'undefined') { - return; - } - - if (this._updateUI) { - this._updateUI(state); - } - } // Called after every state update, after everything's mounted. Debounced. - ; - - _proto.afterUpdate = function afterUpdate() {} - /** - * Called when plugin is mounted, whether in DOM or into another plugin. - * Needed because sometimes plugins are mounted separately/after `install`, - * so this.el and this.parent might not be available in `install`. - * This is the case with @uppy/react plugins, for example. - */ - ; - - _proto.onMount = function onMount() {} - /** - * Check if supplied `target` is a DOM element or an `object`. - * If it’s an object — target is a plugin, and we search `plugins` - * for a plugin with same name and return its target. - * - * @param {string|object} target - * - */ - ; - - _proto.mount = function mount(target, plugin) { - var _this = this; - - var callerPluginName = plugin.id; - var targetElement = findDOMElement(target); - - if (targetElement) { - this.isTargetDOMEl = true; // API for plugins that require a synchronous rerender. - - this.rerender = function (state) { - // plugin could be removed, but this.rerender is debounced below, - // so it could still be called even after uppy.removePlugin or uppy.close - // hence the check - if (!_this.uppy.getPlugin(_this.id)) return; - _this.el = preact.render(_this.render(state), targetElement, _this.el); - - _this.afterUpdate(); - }; - - this._updateUI = debounce(this.rerender); - this.uppy.log("Installing " + callerPluginName + " to a DOM element '" + target + "'"); // clear everything inside the target container - - if (this.opts.replaceTargetContent) { - targetElement.innerHTML = ''; - } - - this.el = preact.render(this.render(this.uppy.getState()), targetElement); - this.onMount(); - return this.el; - } - - var targetPlugin; - - if (typeof target === 'object' && target instanceof Plugin) { - // Targeting a plugin *instance* - targetPlugin = target; - } else if (typeof target === 'function') { - // Targeting a plugin type - var Target = target; // Find the target plugin instance. - - this.uppy.iteratePlugins(function (plugin) { - if (plugin instanceof Target) { - targetPlugin = plugin; - return false; - } - }); - } - - if (targetPlugin) { - this.uppy.log("Installing " + callerPluginName + " to " + targetPlugin.id); - this.parent = targetPlugin; - this.el = targetPlugin.addTarget(plugin); - this.onMount(); - return this.el; - } - - this.uppy.log("Not installing " + callerPluginName); - var message = "Invalid target option given to " + callerPluginName + "."; - - if (typeof target === 'function') { - message += ' The given target is not a Plugin class. ' + 'Please check that you\'re not specifying a React Component instead of a plugin. ' + 'If you are using @uppy/* packages directly, make sure you have only 1 version of @uppy/core installed: ' + 'run `npm ls @uppy/core` on the command line and verify that all the versions match and are deduped correctly.'; - } else { - message += 'If you meant to target an HTML element, please make sure that the element exists. ' + 'Check that the