uploads: allow uploading multiple files from your computer at once.

Allow uploading multiple files from your computer at once.

The maximum limit is 100 files at once. There is still a 50MB size limit
that applies to the whole upload. This limit is at the Nginx level.

The upload widget no longer shows a thumbnail preview of the uploaded
file. This is because there isn't room for it in a multi-file upload,
and because the next page will show a preview anyway after the files are
uploaded.

Direct file uploads are processed synchronously, so they may be slow.

API change: the `POST /uploads` endpoint now expects the param to be
`upload[files][]`, not `upload[file]`.
This commit is contained in:
evazion
2022-02-18 23:08:17 -06:00
parent e37dd3a6d0
commit 202dfe5d87
10 changed files with 79 additions and 51 deletions

View File

@@ -2,7 +2,7 @@
# A component for uploading files to Danbooru. Used on the /uploads/new page.
class FileUploadComponent < ApplicationComponent
attr_reader :url, :referer_url, :drop_target, :max_file_size
attr_reader :url, :referer_url, :drop_target, :max_file_size, :max_files_per_upload
# @param url [String] Optional. The URL to upload. If present, the URL field
# will be prefilled in the widget and the upload will be immediately triggered.
@@ -11,11 +11,13 @@ class FileUploadComponent < ApplicationComponent
# events. If "body", then files can be dropped anywhere on the page, not
# just on the upload widget itself.
# @param max_file_size [Integer] The max size in bytes of an upload.
def initialize(url: nil, referer_url: nil, drop_target: nil, max_file_size: Danbooru.config.max_file_size)
# @param max_files_per_upload [Integer] The maximum number of files per upload.
def initialize(url: nil, referer_url: nil, drop_target: nil, max_file_size: Danbooru.config.max_file_size, max_files_per_upload: Upload::MAX_FILES_PER_UPLOAD)
@url = url
@referer_url = referer_url
@drop_target = drop_target
@max_file_size = max_file_size
@max_files_per_upload = max_files_per_upload
super
end
end

View File

@@ -1,8 +1,8 @@
<div class="file-upload-component relative card w-md max-w-full" data-drop-target="<%= j drop_target %>" data-max-file-size="<%= j max_file_size %>">
<div class="file-upload-component relative card w-md max-w-full" data-drop-target="<%= j drop_target %>" data-max-file-size="<%= j max_file_size %>" data-max-files-per-upload="<%= j max_files_per_upload %>">
<%= simple_form_for(Upload.new, url: uploads_path(format: :json), html: { class: "flex flex-col", autocomplete: "off" }, remote: true) do |f| %>
<%= f.input :file, as: :file, wrapper_html: { class: "hidden" } %>
<%= f.input :files, as: :file, wrapper_html: { class: "hidden" }, input_html: { "multiple": max_files_per_upload > 1 } %>
<div class="dropzone-container input flex flex-col text-center items-center justify-center rounded-t-lg cursor-pointer p-4">
<div class="dropzone-container input text-center rounded-t-lg cursor-pointer p-4 max-h-360px thin-scrollbar">
<div class="dropzone-hint py-4">
<div>Choose file or drag image here</div>
<div class="hint">Max size: <%= number_to_human_size(Danbooru.config.max_file_size) %>.</div>
@@ -19,21 +19,17 @@
<%= spinner_icon(class: "hidden animate-spin absolute inset-0 m-auto h-12 link-color") %>
<template class="dropzone-preview-template">
<div class="dz-preview dz-file-preview flex flex-col text-center space-y-4">
<img class="object-contain px-8 max-h-360px max-w-full" data-dz-thumbnail>
<div class="dz-details">
<div class="dz-filename break-all">
<span data-dz-name></span>
</div>
<div class="dz-size" data-dz-size></div>
<div class="dz-preview dz-file-preview flex flex-col text-center">
<div class="dz-details text-xxs">
<span class="dz-filename break-all" data-dz-name></span>,
<span class="dz-size" data-dz-size></span>
</div>
<div class="dz-progress absolute w-full h-1">
<div class="dz-upload h-1" data-dz-uploadprogress></div>
</div>
<div class="dz-error-message" data-dz-errormessage></div>
<div class="hidden dz-error-message" data-dz-errormessage></div>
</div>
</template>
</div>

View File

@@ -31,4 +31,8 @@
background-color: var(--uploads-dropzone-progress-bar-foreground-color);
}
}
&.dz-error .dz-error-message {
display: block;
}
}

View File

@@ -33,16 +33,19 @@ export default class FileUploadComponent {
let dropzone = new Dropzone(this.$dropTarget.get(0), {
url: "/uploads.json",
paramName: "upload[file]",
paramName: "upload[files]",
clickable: this.$dropzone.get(0),
previewsContainer: this.$dropzone.get(0),
thumbnailHeight: null,
thumbnailWidth: null,
addRemoveLinks: false,
maxFiles: 1,
parallelUploads: this.maxFiles,
maxFiles: this.maxFiles,
maxFilesize: this.maxFileSize,
maxThumbnailFilesize: this.maxFileSize,
timeout: 0,
uploadMultiple: true,
createImageThumbnails: false,
acceptedFiles: "image/jpeg,image/png,image/gif,video/mp4,video/webm",
previewTemplate: this.$component.find(".dropzone-preview-template").html(),
});
@@ -54,13 +57,6 @@ export default class FileUploadComponent {
dropzone.on("addedfile", file => {
this.$dropzone.removeClass("error");
this.$dropzone.find(".dropzone-hint").hide();
// Remove all files except the file just added.
dropzone.files.forEach(f => {
if (f !== file) {
dropzone.removeFile(f);
}
});
});
dropzone.on("success", file => {
@@ -70,7 +66,9 @@ export default class FileUploadComponent {
});
dropzone.on("error", (file, msg) => {
this.$dropzone.addClass("error");
this.$dropzone.find(".dropzone-hint").show();
dropzone.removeFile(file);
Utility.error(msg);
});
return dropzone;
@@ -164,6 +162,10 @@ export default class FileUploadComponent {
return Number(this.$component.attr("data-max-file-size")) / (1024 * 1024);
}
get maxFiles() {
return Number(this.$component.attr("data-max-files-per-upload"));
}
// The element to listen for drag and drop events and paste events. By default,
// it's the `.file-upload-component` element. If `data-drop-target` is the `body`
// element, then you can drop images or paste URLs anywhere on the page.

View File

@@ -4,7 +4,9 @@ class Upload < ApplicationRecord
extend Memoist
class Error < StandardError; end
attr_accessor :file
MAX_FILES_PER_UPLOAD = 100
attr_accessor :files
belongs_to :uploader, class_name: "User"
has_many :upload_media_assets, dependent: :destroy
@@ -13,6 +15,7 @@ class Upload < ApplicationRecord
normalize :source, :normalize_source
validates :files, length: { maximum: MAX_FILES_PER_UPLOAD, message: "can't have more than #{MAX_FILES_PER_UPLOAD} files per upload" }
validates :source, format: { with: %r{\Ahttps?://}i, message: "is not a valid URL" }, if: -> { source.present? }
validates :referer_url, format: { with: %r{\Ahttps?://}i, message: "is not a valid URL" }, if: -> { referer_url.present? }
validate :validate_file_and_source, on: :create
@@ -55,9 +58,9 @@ class Upload < ApplicationRecord
concerning :ValidationMethods do
def validate_file_and_source
if file.present? && source.present?
if files.present? && source.present?
errors.add(:base, "Can't give both a file and a source")
elsif file.blank? && source.blank?
elsif files.blank? && source.blank?
errors.add(:base, "No file or source given")
end
end
@@ -86,8 +89,8 @@ class Upload < ApplicationRecord
end
def async_process_upload!
if file.present?
ProcessUploadJob.perform_now(self)
if files.present?
process_upload!
elsif source.present?
ProcessUploadJob.perform_later(self)
else
@@ -98,12 +101,10 @@ class Upload < ApplicationRecord
def process_upload!
update!(status: "processing")
if file.present?
media_file = MediaFile.open(file.tempfile)
media_asset = MediaAsset.upload!(media_file)
upload_media_asset = UploadMediaAsset.new(media_asset: media_asset, source_url: "file://#{file.original_filename}", status: "active")
update!(upload_media_assets: [upload_media_asset], status: "completed", media_asset_count: 1)
if files.present?
upload_media_assets = files.map do |_index, file|
UploadMediaAsset.new(file: file.tempfile, source_url: "file://#{file.original_filename}")
end
elsif source.present?
page_url = source_strategy.page_url
image_urls = source_strategy.image_urls
@@ -115,11 +116,11 @@ class Upload < ApplicationRecord
upload_media_assets = image_urls.map do |image_url|
UploadMediaAsset.new(source_url: image_url, page_url: page_url, media_asset: nil)
end
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
else
raise Error, "No file or source given" # Should never happen
end
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
rescue Exception => e
update!(status: "error", error: e.message)
end

View File

@@ -3,6 +3,8 @@
class UploadMediaAsset < ApplicationRecord
extend Memoist
attr_accessor :file
belongs_to :upload
belongs_to :media_asset, optional: true
has_one :post, through: :media_asset
@@ -62,16 +64,22 @@ class UploadMediaAsset < ApplicationRecord
end
def async_process_upload!
return if file_upload?
ProcessUploadMediaAssetJob.perform_later(self)
if file.present?
process_upload!
else
ProcessUploadMediaAssetJob.perform_later(self)
end
end
def process_upload!
return if file_upload?
update!(status: :processing)
strategy = Sources::Strategies.find(source_url)
media_file = strategy.download_file!(source_url)
if file.present?
media_file = MediaFile.open(file)
else
media_file = source_strategy.download_file!(source_url)
end
MediaAsset.upload!(media_file) do |media_asset|
update!(media_asset: media_asset)
end

View File

@@ -18,6 +18,6 @@ class UploadPolicy < ApplicationPolicy
end
def permitted_attributes
%i[file source referer_url]
[:source, :referer_url, files: {}]
end
end

View File

@@ -17,7 +17,7 @@ FactoryBot.define do
status { "completed" }
source { nil }
media_asset_count { 1 }
file { Rack::Test::UploadedFile.new("#{Rails.root}/test/files/test.jpg") }
files { { "0" => Rack::Test::UploadedFile.new("#{Rails.root}/test/files/test.jpg") } }
upload_media_assets do
[build(:upload_media_asset, media_asset: build(:media_asset, file: "test/files/test.jpg"), source_url: "file://test.jpg", status: "active")]

View File

@@ -162,7 +162,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
assert_no_difference("Upload.count") do
file = File.open("test/files/test.jpg")
source = "https://files.catbox.moe/om3tcw.webm"
post_auth uploads_path(format: :json), @user, params: { upload: { file: file, source: source }}
post_auth uploads_path(format: :json), @user, params: { upload: { files: { "0" => file }, source: source }}
end
assert_response 422
@@ -171,7 +171,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
should "fail if given an unsupported filetype" do
file = Rack::Test::UploadedFile.new("test/files/ugoira.json")
post_auth uploads_path(format: :json), @user, params: { upload: { file: file }}
post_auth uploads_path(format: :json), @user, params: { upload: { files: { "0" => file } }}
assert_response 201
assert_match("File is not an image or video", Upload.last.error)
@@ -247,12 +247,9 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
context "when re-uploading a media asset stuck in the 'processing' state" do
should "mark the asset as failed" do
asset = create(:media_asset, file: File.open("test/files/test.jpg"), status: "processing")
file = Rack::Test::UploadedFile.new("test/files/test.jpg")
create_upload!("test/files/test.jpg", user: @user)
post_auth uploads_path, @user, params: { upload: { file: file }}
upload = Upload.last
assert_redirected_to upload
assert_match("Upload failed, try again", upload.reload.error)
assert_equal("failed", asset.reload.status)
end
@@ -287,6 +284,24 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
# should_upload_successfully("test/files/compressed.swf")
end
context "uploading multiple files from your computer" do
should "work" do
files = {
"0" => Rack::Test::UploadedFile.new("test/files/test.jpg"),
"1" => Rack::Test::UploadedFile.new("test/files/test.png"),
"2" => Rack::Test::UploadedFile.new("test/files/test.gif"),
}
post_auth uploads_path(format: :json), @user, params: { upload: { files: files }}
upload = Upload.last
assert_response 201
assert_equal("", upload.error.to_s)
assert_equal("completed", upload.status)
assert_equal(3, upload.media_asset_count)
end
end
context "uploading a file from a source" do
should_upload_successfully("https://www.artstation.com/artwork/04XA4")
should_upload_successfully("https://dantewontdie.artstation.com/projects/YZK5q")

View File

@@ -7,7 +7,7 @@ module UploadTestHelper
source = { source: source_or_file_path }
else
file = Rack::Test::UploadedFile.new(Rails.root.join(source_or_file_path))
source = { file: file }
source = { files: { "0" => file } }
end
perform_enqueued_jobs do