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:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# A component for uploading files to Danbooru. Used on the /uploads/new page.
|
# A component for uploading files to Danbooru. Used on the /uploads/new page.
|
||||||
class FileUploadComponent < ApplicationComponent
|
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
|
# @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.
|
# 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
|
# events. If "body", then files can be dropped anywhere on the page, not
|
||||||
# just on the upload widget itself.
|
# just on the upload widget itself.
|
||||||
# @param max_file_size [Integer] The max size in bytes of an upload.
|
# @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
|
@url = url
|
||||||
@referer_url = referer_url
|
@referer_url = referer_url
|
||||||
@drop_target = drop_target
|
@drop_target = drop_target
|
||||||
@max_file_size = max_file_size
|
@max_file_size = max_file_size
|
||||||
|
@max_files_per_upload = max_files_per_upload
|
||||||
super
|
super
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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| %>
|
<%= 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 class="dropzone-hint py-4">
|
||||||
<div>Choose file or drag image here</div>
|
<div>Choose file or drag image here</div>
|
||||||
<div class="hint">Max size: <%= number_to_human_size(Danbooru.config.max_file_size) %>.</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") %>
|
<%= spinner_icon(class: "hidden animate-spin absolute inset-0 m-auto h-12 link-color") %>
|
||||||
|
|
||||||
<template class="dropzone-preview-template">
|
<template class="dropzone-preview-template">
|
||||||
<div class="dz-preview dz-file-preview flex flex-col text-center space-y-4">
|
<div class="dz-preview dz-file-preview flex flex-col text-center">
|
||||||
<img class="object-contain px-8 max-h-360px max-w-full" data-dz-thumbnail>
|
<div class="dz-details text-xxs">
|
||||||
|
<span class="dz-filename break-all" data-dz-name></span>,
|
||||||
<div class="dz-details">
|
<span class="dz-size" data-dz-size></span>
|
||||||
<div class="dz-filename break-all">
|
|
||||||
<span data-dz-name></span>
|
|
||||||
</div>
|
|
||||||
<div class="dz-size" data-dz-size></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dz-progress absolute w-full h-1">
|
<div class="dz-progress absolute w-full h-1">
|
||||||
<div class="dz-upload h-1" data-dz-uploadprogress></div>
|
<div class="dz-upload h-1" data-dz-uploadprogress></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dz-error-message" data-dz-errormessage></div>
|
<div class="hidden dz-error-message" data-dz-errormessage></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,4 +31,8 @@
|
|||||||
background-color: var(--uploads-dropzone-progress-bar-foreground-color);
|
background-color: var(--uploads-dropzone-progress-bar-foreground-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.dz-error .dz-error-message {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,16 +33,19 @@ export default class FileUploadComponent {
|
|||||||
|
|
||||||
let dropzone = new Dropzone(this.$dropTarget.get(0), {
|
let dropzone = new Dropzone(this.$dropTarget.get(0), {
|
||||||
url: "/uploads.json",
|
url: "/uploads.json",
|
||||||
paramName: "upload[file]",
|
paramName: "upload[files]",
|
||||||
clickable: this.$dropzone.get(0),
|
clickable: this.$dropzone.get(0),
|
||||||
previewsContainer: this.$dropzone.get(0),
|
previewsContainer: this.$dropzone.get(0),
|
||||||
thumbnailHeight: null,
|
thumbnailHeight: null,
|
||||||
thumbnailWidth: null,
|
thumbnailWidth: null,
|
||||||
addRemoveLinks: false,
|
addRemoveLinks: false,
|
||||||
maxFiles: 1,
|
parallelUploads: this.maxFiles,
|
||||||
|
maxFiles: this.maxFiles,
|
||||||
maxFilesize: this.maxFileSize,
|
maxFilesize: this.maxFileSize,
|
||||||
maxThumbnailFilesize: this.maxFileSize,
|
maxThumbnailFilesize: this.maxFileSize,
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
|
uploadMultiple: true,
|
||||||
|
createImageThumbnails: false,
|
||||||
acceptedFiles: "image/jpeg,image/png,image/gif,video/mp4,video/webm",
|
acceptedFiles: "image/jpeg,image/png,image/gif,video/mp4,video/webm",
|
||||||
previewTemplate: this.$component.find(".dropzone-preview-template").html(),
|
previewTemplate: this.$component.find(".dropzone-preview-template").html(),
|
||||||
});
|
});
|
||||||
@@ -54,13 +57,6 @@ export default class FileUploadComponent {
|
|||||||
dropzone.on("addedfile", file => {
|
dropzone.on("addedfile", file => {
|
||||||
this.$dropzone.removeClass("error");
|
this.$dropzone.removeClass("error");
|
||||||
this.$dropzone.find(".dropzone-hint").hide();
|
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 => {
|
dropzone.on("success", file => {
|
||||||
@@ -70,7 +66,9 @@ export default class FileUploadComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
dropzone.on("error", (file, msg) => {
|
dropzone.on("error", (file, msg) => {
|
||||||
this.$dropzone.addClass("error");
|
this.$dropzone.find(".dropzone-hint").show();
|
||||||
|
dropzone.removeFile(file);
|
||||||
|
Utility.error(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
return dropzone;
|
return dropzone;
|
||||||
@@ -164,6 +162,10 @@ export default class FileUploadComponent {
|
|||||||
return Number(this.$component.attr("data-max-file-size")) / (1024 * 1024);
|
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,
|
// 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`
|
// 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.
|
// element, then you can drop images or paste URLs anywhere on the page.
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ class Upload < ApplicationRecord
|
|||||||
extend Memoist
|
extend Memoist
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
attr_accessor :file
|
MAX_FILES_PER_UPLOAD = 100
|
||||||
|
|
||||||
|
attr_accessor :files
|
||||||
|
|
||||||
belongs_to :uploader, class_name: "User"
|
belongs_to :uploader, class_name: "User"
|
||||||
has_many :upload_media_assets, dependent: :destroy
|
has_many :upload_media_assets, dependent: :destroy
|
||||||
@@ -13,6 +15,7 @@ class Upload < ApplicationRecord
|
|||||||
|
|
||||||
normalize :source, :normalize_source
|
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 :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? }
|
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
|
validate :validate_file_and_source, on: :create
|
||||||
@@ -55,9 +58,9 @@ class Upload < ApplicationRecord
|
|||||||
|
|
||||||
concerning :ValidationMethods do
|
concerning :ValidationMethods do
|
||||||
def validate_file_and_source
|
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")
|
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")
|
errors.add(:base, "No file or source given")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -86,8 +89,8 @@ class Upload < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def async_process_upload!
|
def async_process_upload!
|
||||||
if file.present?
|
if files.present?
|
||||||
ProcessUploadJob.perform_now(self)
|
process_upload!
|
||||||
elsif source.present?
|
elsif source.present?
|
||||||
ProcessUploadJob.perform_later(self)
|
ProcessUploadJob.perform_later(self)
|
||||||
else
|
else
|
||||||
@@ -98,12 +101,10 @@ class Upload < ApplicationRecord
|
|||||||
def process_upload!
|
def process_upload!
|
||||||
update!(status: "processing")
|
update!(status: "processing")
|
||||||
|
|
||||||
if file.present?
|
if files.present?
|
||||||
media_file = MediaFile.open(file.tempfile)
|
upload_media_assets = files.map do |_index, file|
|
||||||
media_asset = MediaAsset.upload!(media_file)
|
UploadMediaAsset.new(file: file.tempfile, source_url: "file://#{file.original_filename}")
|
||||||
upload_media_asset = UploadMediaAsset.new(media_asset: media_asset, source_url: "file://#{file.original_filename}", status: "active")
|
end
|
||||||
|
|
||||||
update!(upload_media_assets: [upload_media_asset], status: "completed", media_asset_count: 1)
|
|
||||||
elsif source.present?
|
elsif source.present?
|
||||||
page_url = source_strategy.page_url
|
page_url = source_strategy.page_url
|
||||||
image_urls = source_strategy.image_urls
|
image_urls = source_strategy.image_urls
|
||||||
@@ -115,11 +116,11 @@ class Upload < ApplicationRecord
|
|||||||
upload_media_assets = image_urls.map do |image_url|
|
upload_media_assets = image_urls.map do |image_url|
|
||||||
UploadMediaAsset.new(source_url: image_url, page_url: page_url, media_asset: nil)
|
UploadMediaAsset.new(source_url: image_url, page_url: page_url, media_asset: nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
|
|
||||||
else
|
else
|
||||||
raise Error, "No file or source given" # Should never happen
|
raise Error, "No file or source given" # Should never happen
|
||||||
end
|
end
|
||||||
|
|
||||||
|
update!(upload_media_assets: upload_media_assets, media_asset_count: upload_media_assets.size)
|
||||||
rescue Exception => e
|
rescue Exception => e
|
||||||
update!(status: "error", error: e.message)
|
update!(status: "error", error: e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
class UploadMediaAsset < ApplicationRecord
|
class UploadMediaAsset < ApplicationRecord
|
||||||
extend Memoist
|
extend Memoist
|
||||||
|
|
||||||
|
attr_accessor :file
|
||||||
|
|
||||||
belongs_to :upload
|
belongs_to :upload
|
||||||
belongs_to :media_asset, optional: true
|
belongs_to :media_asset, optional: true
|
||||||
has_one :post, through: :media_asset
|
has_one :post, through: :media_asset
|
||||||
@@ -62,16 +64,22 @@ class UploadMediaAsset < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def async_process_upload!
|
def async_process_upload!
|
||||||
return if file_upload?
|
if file.present?
|
||||||
ProcessUploadMediaAssetJob.perform_later(self)
|
process_upload!
|
||||||
|
else
|
||||||
|
ProcessUploadMediaAssetJob.perform_later(self)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_upload!
|
def process_upload!
|
||||||
return if file_upload?
|
|
||||||
update!(status: :processing)
|
update!(status: :processing)
|
||||||
|
|
||||||
strategy = Sources::Strategies.find(source_url)
|
if file.present?
|
||||||
media_file = strategy.download_file!(source_url)
|
media_file = MediaFile.open(file)
|
||||||
|
else
|
||||||
|
media_file = source_strategy.download_file!(source_url)
|
||||||
|
end
|
||||||
|
|
||||||
MediaAsset.upload!(media_file) do |media_asset|
|
MediaAsset.upload!(media_file) do |media_asset|
|
||||||
update!(media_asset: media_asset)
|
update!(media_asset: media_asset)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ class UploadPolicy < ApplicationPolicy
|
|||||||
end
|
end
|
||||||
|
|
||||||
def permitted_attributes
|
def permitted_attributes
|
||||||
%i[file source referer_url]
|
[:source, :referer_url, files: {}]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ FactoryBot.define do
|
|||||||
status { "completed" }
|
status { "completed" }
|
||||||
source { nil }
|
source { nil }
|
||||||
media_asset_count { 1 }
|
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
|
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")]
|
[build(:upload_media_asset, media_asset: build(:media_asset, file: "test/files/test.jpg"), source_url: "file://test.jpg", status: "active")]
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
assert_no_difference("Upload.count") do
|
assert_no_difference("Upload.count") do
|
||||||
file = File.open("test/files/test.jpg")
|
file = File.open("test/files/test.jpg")
|
||||||
source = "https://files.catbox.moe/om3tcw.webm"
|
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
|
end
|
||||||
|
|
||||||
assert_response 422
|
assert_response 422
|
||||||
@@ -171,7 +171,7 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
|
|
||||||
should "fail if given an unsupported filetype" do
|
should "fail if given an unsupported filetype" do
|
||||||
file = Rack::Test::UploadedFile.new("test/files/ugoira.json")
|
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_response 201
|
||||||
assert_match("File is not an image or video", Upload.last.error)
|
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
|
context "when re-uploading a media asset stuck in the 'processing' state" do
|
||||||
should "mark the asset as failed" do
|
should "mark the asset as failed" do
|
||||||
asset = create(:media_asset, file: File.open("test/files/test.jpg"), status: "processing")
|
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
|
upload = Upload.last
|
||||||
|
|
||||||
assert_redirected_to upload
|
|
||||||
assert_match("Upload failed, try again", upload.reload.error)
|
assert_match("Upload failed, try again", upload.reload.error)
|
||||||
assert_equal("failed", asset.reload.status)
|
assert_equal("failed", asset.reload.status)
|
||||||
end
|
end
|
||||||
@@ -287,6 +284,24 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
|
|||||||
# should_upload_successfully("test/files/compressed.swf")
|
# should_upload_successfully("test/files/compressed.swf")
|
||||||
end
|
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
|
context "uploading a file from a source" do
|
||||||
should_upload_successfully("https://www.artstation.com/artwork/04XA4")
|
should_upload_successfully("https://www.artstation.com/artwork/04XA4")
|
||||||
should_upload_successfully("https://dantewontdie.artstation.com/projects/YZK5q")
|
should_upload_successfully("https://dantewontdie.artstation.com/projects/YZK5q")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module UploadTestHelper
|
|||||||
source = { source: source_or_file_path }
|
source = { source: source_or_file_path }
|
||||||
else
|
else
|
||||||
file = Rack::Test::UploadedFile.new(Rails.root.join(source_or_file_path))
|
file = Rack::Test::UploadedFile.new(Rails.root.join(source_or_file_path))
|
||||||
source = { file: file }
|
source = { files: { "0" => file } }
|
||||||
end
|
end
|
||||||
|
|
||||||
perform_enqueued_jobs do
|
perform_enqueued_jobs do
|
||||||
|
|||||||
Reference in New Issue
Block a user