Commit 0e6c3587 authored by Albert Yi's avatar Albert Yi

add drag and drop file uploads w/async processing

[skip ci]
parent e808a4f7
......@@ -33,7 +33,7 @@
Danbooru.Upload.initialize_submit = function() {
$("#form").submit(function(e) {
var error_messages = [];
if (($("#upload_file").val() === "") && ($("#upload_source").val() === "")) {
if (($("#upload_file").val() === "") && ($("#upload_source").val() === "") && $("#upload_md5_confirmation").val() === "") {
error_messages.push("Must choose file or specify source");
}
if (!$("#upload_rating_s").prop("checked") && !$("#upload_rating_q").prop("checked") && !$("#upload_rating_e").prop("checked") &&
......
......@@ -34,6 +34,37 @@ div#c-uploads {
div.field_with_errors {
display: inline;
}
#filedropzone {
border: 4px dashed #DDD;
padding: 10px;
max-width: 700px;
min-height: 50px;
&.error {
background-color: #f2dede;
}
&.success {
background-color: #dff0d8;
}
}
.dz-preview {
margin-bottom: 1em;
}
.dz-progress {
height: 20px;
width: 300px;
border: 1px solid #CCC;
.dz-upload {
background-color: #F5F5FF;
display: block;
height: 20px;
}
}
}
div#a-index {
......
class UploadsController < ApplicationController
before_action :member_only, except: [:index, :show]
respond_to :html, :xml, :json, :js
skip_before_action :verify_authenticity_token, only: [:preprocess]
def new
@upload_notice_wiki = WikiPage.titled(Danbooru.config.upload_notice_wiki_page).first
@upload, @post, @source, @normalized_url, @remote_size = UploadService::ControllerHelper.prepare(params[:url], params[:ref])
@upload, @post, @source, @normalized_url, @remote_size = UploadService::ControllerHelper.prepare(
url: params[:url], ref: params[:ref]
)
respond_with(@upload)
end
......@@ -39,6 +42,13 @@ class UploadsController < ApplicationController
end
end
def preprocess
@upload, @post, @source, @normalized_url, @remote_size = UploadService::ControllerHelper.prepare(
url: params[:url], file: params[:file], ref: params[:ref]
)
render body: nil
end
def create
@service = UploadService.new(upload_params)
@upload = @service.start!
......
class UploadService
module ControllerHelper
def self.prepare(url, ref = nil)
def self.prepare(url: nil, file: nil, ref: nil)
upload = Upload.new
if url
# this gets called from UploadsController#new so we need
# to preprocess async
Preprocessor.new(source: url).delay(queue: "default").start!(CurrentUser.user.id)
download = Downloads::File.new(url)
......@@ -21,6 +23,9 @@ class UploadService
end
return [upload, post, source, normalized_url, remote_size]
elsif file
# this gets called via XHR so we can process sync
Preprocessor.new(file: file).start!((CurrentUser.user.id))
end
return [upload]
......@@ -216,12 +221,26 @@ class UploadService
params[:source]
end
def md5
params[:md5_confirmation]
end
def in_progress?
Upload.where(status: "preprocessing", source: source).exists?
if source.present?
Upload.where(status: "preprocessing", source: source).exists?
elsif md5.present?
Upload.where(status: "preprocessing", md5: md5).exists?
else
false
end
end
def predecessor
Upload.where(status: ["preprocessed", "preprocessing"], source: source).first
if source.present?
Upload.where(status: ["preprocessed", "preprocessing"], source: source).first
elsif md5.present?
Upload.where(status: ["preprocessed", "preprocessing"], md5: md5).first
end
end
def completed?
......@@ -229,20 +248,22 @@ class UploadService
end
def start!(uploader_id)
if !Utils.is_downloadable?(source)
return
end
if source.present?
if !Utils.is_downloadable?(source)
return
end
if Post.where(source: source).exists?
return
end
if Post.where(source: source).exists?
return
end
if Upload.where(source: source, status: "completed").exists?
return
end
if Upload.where(source: source, status: "completed").exists?
return
end
if Upload.where(source: source).where("status like ?", "error%").exists?
return
if Upload.where(source: source).where("status like ?", "error%").exists?
return
end
end
params[:rating] ||= "q"
......@@ -254,13 +275,17 @@ class UploadService
upload.update(status: "preprocessing")
begin
file = download_from_source(source, referer_url: upload.referer_url) do |context|
upload.downloaded_source = context[:downloaded_source]
upload.source = context[:source]
if context[:ugoira]
upload.context = { ugoira: context[:ugoira] }
if source.present?
file = download_from_source(source, referer_url: upload.referer_url) do |context|
upload.downloaded_source = context[:downloaded_source]
upload.source = context[:source]
if context[:ugoira]
upload.context = { ugoira: context[:ugoira] }
end
end
elsif params[:file].present?
file = params[:file]
end
Utils.process_file(upload, file)
......@@ -306,6 +331,128 @@ class UploadService
end
end
class Replacer
attr_reader :post, :replacement
def initialize(post:, replacement:)
@post = post
@replacement = replacement
end
def comment_replacement_message(post, replacement)
%("#{replacement.creator.name}":[/users/#{replacement.creator.id}] replaced this post with a new image:\n\n#{replacement_message(post, replacement)})
end
def replacement_message(post, replacement)
linked_source = linked_source(replacement.replacement_url)
linked_source_was = linked_source(post.source_was)
<<-EOS.strip_heredoc
[table]
[tbody]
[tr]
[th]Old[/th]
[td]#{linked_source_was}[/td]
[td]#{post.md5_was}[/td]
[td]#{post.file_ext_was}[/td]
[td]#{post.image_width_was} x #{post.image_height_was}[/td]
[td]#{post.file_size_was.human_size(precision: 4)}[/td]
[/tr]
[tr]
[th]New[/th]
[td]#{linked_source}[/td]
[td]#{post.md5}[/td]
[td]#{post.file_ext}[/td]
[td]#{post.image_width} x #{post.image_height}[/td]
[td]#{post.file_size.human_size(precision: 4)}[/td]
[/tr]
[/tbody]
[/table]
EOS
end
def linked_source(source)
return nil if source.nil?
# truncate long sources in the middle: "www.pixiv.net...lust_id=23264933"
truncated_source = source.gsub(%r{\Ahttps?://}, "").truncate(64, omission: "...#{source.last(32)}")
if source =~ %r{\Ahttps?://}i
%("#{truncated_source}":[#{source}])
else
truncated_source
end
end
def suggested_tags_for_removal
tags = post.tag_array.select { |tag| Danbooru.config.remove_tag_after_replacement?(tag) }
tags = tags.map { |tag| "-#{tag}" }
tags.join(" ")
end
def process!
preprocessor = Preprocessor.new(
rating: post.rating,
tag_string: replacement.tags,
source: replacement.replacement_url,
file: replacement.replacement_file
)
upload = preprocessor.start!(CurrentUser.id)
upload = preprocessor.finish!
md5_changed = upload.md5 != post.md5
if replacement.replacement_file.present?
replacement.replacement_url = "file://#{replacement.replacement_file.original_filename}"
elsif upload.downloaded_source.present?
replacement.replacement_url = upload.downloaded_source
end
if md5_changed
post.queue_delete_files(PostReplacement::DELETION_GRACE_PERIOD)
end
replacement.file_ext = upload.file_ext
replacement.file_size = upload.file_size
replacement.image_height = upload.image_height
replacement.image_width = upload.image_width
replacement.md5 = upload.md5
post.md5 = upload.md5
post.file_ext = upload.file_ext
post.image_width = upload.image_width
post.image_height = upload.image_height
post.file_size = upload.file_size
post.source = upload.source
post.tag_string = upload.tag_string
rescale_notes(post)
update_ugoira_frame_data(post, upload)
if md5_changed
CurrentUser.as(User.system) do
post.comments.create!(body: comment_replacement_message(post, replacement), do_not_bump_post: true)
end
end
replacement.save!
post.save!
end
def rescale_notes(post)
x_scale = post.image_width.to_f / post.image_width_was.to_f
y_scale = post.image_height.to_f / post.image_height_was.to_f
post.notes.each do |note|
note.rescale!(x_scale, y_scale)
end
end
def update_ugoira_frame_data(post, upload)
post.pixiv_ugoira_frame_data.destroy if post.pixiv_ugoira_frame_data.present?
upload.ugoira_service.save_frame_data(post) if post.is_ugoira?
end
end
attr_reader :params, :post, :upload
def initialize(params)
......@@ -370,7 +517,7 @@ class UploadService
@post = convert_to_post(upload)
@post.save!
@upload.update(status: "error: " + @post.errors.full_messages.join(", "))
upload.update(status: "error: " + @post.errors.full_messages.join(", "))
if upload.context && upload.context["ugoira"]
PixivUgoiraFrameData.create(
......
......@@ -23,77 +23,6 @@ class PostReplacement < ApplicationRecord
undo_replacement.process!
end
def process!
upload = nil
transaction do
upload = Upload.create!(
file: replacement_file,
source: replacement_url,
rating: post.rating,
tag_string: self.tags,
replaced_post: post,
)
upload.process_upload
upload.update(status: "completed", post_id: post.id)
md5_changed = (upload.md5 != post.md5)
if replacement_file.present?
self.replacement_url = "file://#{replacement_file.original_filename}"
else
self.replacement_url = upload.downloaded_source
end
# queue the deletion *before* updating the post so that we use the old
# md5/file_ext to delete the old files. if saving the post fails,
# this is rolled back so the job won't run.
if md5_changed
post.queue_delete_files(DELETION_GRACE_PERIOD)
end
self.file_ext = upload.file_ext
self.file_size = upload.file_size
self.image_height = upload.image_height
self.image_width = upload.image_width
self.md5 = upload.md5
post.md5 = upload.md5
post.file_ext = upload.file_ext
post.image_width = upload.image_width
post.image_height = upload.image_height
post.file_size = upload.file_size
post.source = final_source.presence || upload.source
post.tag_string = upload.tag_string
rescale_notes
update_ugoira_frame_data(upload)
if md5_changed
CurrentUser.as(User.system) do
post.comments.create!(body: comment_replacement_message, do_not_bump_post: true)
end
end
save!
post.save!
end
# point of no return: these things can't be rolled back, so we do them
# only after the transaction successfully commits.
upload.distribute_files(post)
post.update_iqdb_async
end
def rescale_notes
x_scale = post.image_width.to_f / post.image_width_was.to_f
y_scale = post.image_height.to_f / post.image_height_was.to_f
post.notes.each do |note|
note.rescale!(x_scale, y_scale)
end
end
def update_ugoira_frame_data(upload)
post.pixiv_ugoira_frame_data.destroy if post.pixiv_ugoira_frame_data.present?
upload.ugoira_service.save_frame_data(post) if post.is_ugoira?
......@@ -127,57 +56,5 @@ class PostReplacement < ApplicationRecord
end
end
module PresenterMethods
def comment_replacement_message
%("#{creator.name}":[/users/#{creator.id}] replaced this post with a new image:\n\n#{replacement_message})
end
def replacement_message
linked_source = linked_source(replacement_url)
linked_source_was = linked_source(post.source_was)
<<-EOS.strip_heredoc
[table]
[tbody]
[tr]
[th]Old[/th]
[td]#{linked_source_was}[/td]
[td]#{post.md5_was}[/td]
[td]#{post.file_ext_was}[/td]
[td]#{post.image_width_was} x #{post.image_height_was}[/td]
[td]#{post.file_size_was.to_s(:human_size, precision: 4)}[/td]
[/tr]
[tr]
[th]New[/th]
[td]#{linked_source}[/td]
[td]#{post.md5}[/td]
[td]#{post.file_ext}[/td]
[td]#{post.image_width} x #{post.image_height}[/td]
[td]#{post.file_size.to_s(:human_size, precision: 4)}[/td]
[/tr]
[/tbody]
[/table]
EOS
end
def linked_source(source)
# truncate long sources in the middle: "www.pixiv.net...lust_id=23264933"
truncated_source = source.gsub(%r{\Ahttps?://}, "").truncate(64, omission: "...#{source.last(32)}")
if source =~ %r{\Ahttps?://}i
%("#{truncated_source}":[#{source}])
else
truncated_source
end
end
def suggested_tags_for_removal
tags = post.tag_array.select { |tag| Danbooru.config.remove_tag_after_replacement?(tag) }
tags = tags.map { |tag| "-#{tag}" }
tags.join(" ")
end
end
include PresenterMethods
extend SearchMethods
end
......@@ -21,6 +21,7 @@
<%= hidden_field_tag :url, params[:url] %>
<%= hidden_field_tag :ref, params[:ref] %>
<%= hidden_field_tag :normalized_url, @normalized_url %>
<%= f.hidden_field :md5_confirmation %>
<%= f.hidden_field :referer_url, :value => @source.try(:referer_url) %>
<% if CurrentUser.can_upload_free? %>
......@@ -37,6 +38,10 @@
<%= f.file_field :file, :size => 50 %>
</div>
<div class="input" id="filedropzone">
<span class="placeholder">Drag and drop a file here</span>
</div>
<div class="input">
<%= f.label :source %>
<% if params[:url].present? %>
......@@ -112,9 +117,9 @@
<%= button_tag "Related tags", :id => "related-tags-button", :type => "button", :class => "ui-button ui-widget ui-corner-all sub gradient" %>
<% TagCategory.related_button_list.each do |category| %>
<%= button_tag "#{TagCategory.related_button_mapping[category]}", :id => "related-#{category}-button", :type => "button", :class => "ui-button ui-widget ui-corner-all sub gradient" %>
<% end %>
<% TagCategory.related_button_list.each do |category| %>
<%= button_tag "#{TagCategory.related_button_mapping[category]}", :id => "related-#{category}-button", :type => "button", :class => "ui-button ui-widget ui-corner-all sub gradient" %>
<% end %>
</div>
<div class="input">
......@@ -143,4 +148,54 @@
Upload - <%= Danbooru.config.app_name %>
<% end %>
<% content_for(:html_header) do %>
<script async src="https://cdnjs.cloudflare.com/ajax/libs/dropzone/5.4.0/min/dropzone.min.js"></script>
<script async src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js"></script>
<script>
$(function() {
var enabled = true;
if (!window.FileReader) {
enabled = false;
}
if (!enabled) {
$("#filedropzone").remove();
return;
}
$("#filedropzone").dropzone({
paramName: "file",
url: "/uploads/preprocess",
createImageThumbnails: false,
addRemoveLinks: false,
maxFiles: 1,
acceptedFiles: "image/jpeg,image/png,image/gif",
previewTemplate: '<div class="dz-preview dz-file-preview"><div class="dz-details"><div class="dz-filename"><span data-dz-name></span></div><div class="dz-size" data-dz-size></div></div><div class="dz-progress"><span class="dz-upload" data-dz-uploadprogress></span></div><div class="dz-error-message"><span data-dz-errormessage></span></div></div>',
init: function() {
this.on("drop", function(event) {
$("#filedropzone .placeholder").hide();
});
this.on("complete", function(file) {
$("#filedropzone .dz-progress").hide();
});
this.on("addedfile", function(file) {
var reader = new FileReader()
reader.addEventListener("loadend", function() {
$("#upload_md5_confirmation").val(CryptoJS.MD5(CryptoJS.enc.Latin1.parse(this.result)).toString());
});
reader.readAsBinaryString(file);
});
this.on("success", function(file) {
$("#filedropzone").addClass("success");
});
this.on("error", function(file, msg) {
$("#filedropzone").addClass("error");
});
}
});
});
</script>
<% end %>
<%= render "uploads/secondary_links" %>
......@@ -279,6 +279,7 @@ Rails.application.routes.draw do
resource :tag_implication_request, :only => [:new, :create]
resources :uploads do
collection do
post :preprocess
get :batch
get :image_proxy
end
......
......@@ -326,7 +326,7 @@ class UploadServiceTest < ActiveSupport::TestCase
should "work for a jpeg" do
@service = subject.new(source: @jpeg)
@upload = @service.start!
@upload = @service.start!(CurrentUser.id)
assert_equal("preprocessed", @upload.status)
assert_not_nil(@upload.md5)
assert_equal("jpg", @upload.file_ext)
......@@ -339,7 +339,7 @@ class UploadServiceTest < ActiveSupport::TestCase
should "work for an ugoira" do
@service = subject.new(source: @ugoira)
@upload = @service.start!
@upload = @service.start!(CurrentUser.id)
assert_equal("preprocessed", @upload.status)
assert_not_nil(@upload.md5)
assert_equal("zip", @upload.file_ext)
......@@ -351,7 +351,7 @@ class UploadServiceTest < ActiveSupport::TestCase
should "work for a video" do
@service = subject.new(source: @video)
@upload = @service.start!
@upload = @service.start!(CurrentUser.id)
assert_equal("preprocessed", @upload.status)
assert_not_nil(@upload.md5)
assert_equal("mp4", @upload.file_ext)
......@@ -368,7 +368,7 @@ class UploadServiceTest < ActiveSupport::TestCase
should "leave the upload in an error state" do
@service = subject.new(source: @video)
@upload = @service.start!
@upload = @service.start!(CurrentUser.id)
assert_match(/error:/, @upload.status)
end
end
......@@ -376,6 +376,103 @@ class UploadServiceTest < ActiveSupport::TestCase
end
end
context "::Replacer" do
context "for a file replacement" do
setup do
@new_file = upload_file("test/files/test.jpg")
travel_to(1.month.ago) do
@user = FactoryBot.create(:user)
end
as_user do
@post = FactoryBot.create(:post)
@post.stubs(:queue_delete_files)
@replacement = FactoryBot.create(:post_replacement, post: @post, replacement_url: "", replacement_file: @new_file)
end
end
subject { UploadService::Replacer.new(post: @post, replacement: @replacement) }
context "#process!" do
should "create a new upload" do
assert_difference(-> { Upload.count }) do
as_user { subject.process! }
end
end
should "create a comment" do
assert_difference(-> { @post.comments.count }) do
as_user { subject.process! }
@post.reload
end
end
should "not create a new post" do
assert_difference(-> { Post.count }, 0) do
as_user { subject.process! }
end
end
should "update the post's MD5" do
assert_changes(-> { @post.md5 }) do
as_user { subject.process! }
@post.reload
end
end
end
end
context "for a source replacement" do
setup do
@new_url = "https://upload.wikimedia.org/wikipedia/commons/c/c5/Moraine_Lake_17092005.jpg"
travel_to(1.month.ago) do
@user = FactoryBot.create(:user)
end
as_user do
@post = FactoryBot.create(:post)
@post.stubs(:queue_delete_files)
@replacement = FactoryBot.create(:post_replacement, post: @post, replacement_url: @new_url)
end
end
subject { UploadService::Replacer.new(post: @post, replacement: @replacement) }
context "#process!" do
should "create a new upload" do
assert_difference(-> { Upload.count }) do
as_user { subject.process! }
end
end
should "create a comment" do
assert_difference(-> { @post.comments.count }) do
as_user { subject.process! }
@post.reload
end
end
should "not create a new post" do
assert_difference(-> { Post.count }, 0) do
as_user { subject.process! }
end
end
should "update the post's MD5" do
assert_changes(-> { @post.md5 }) do
as_user { subject.process! }
@post.reload
end
end
should "update the post's source" do
assert_changes(-> { @post.source }, nil, from: @post.source, to: @new_url) do
as_user { subject.process! }
@post.reload
end
end
end
end
end
context "#start!" do
subject { UploadService }
......@@ -450,7 +547,7 @@ class UploadServiceTest < ActiveSupport::TestCase
context "with a preprocessed predecessor" do
setup do
@predecessor = FactoryBot.create(:source_upload, status: "preprocessed", source: @source, image_height: 0, image_width: 0, file_ext: "jpg")
@predecessor = FactoryBot.create(:source_upload, status: "preprocessed", source: @source, file_size: 0, md5: "something", image_height: 0, image_width: 0, file_ext: "jpg")
@tags = 'hello world'
end
......
......@@ -27,8 +27,9 @@ class PostReplacementTest < ActiveSupport::TestCase
context "Replacing" do
setup do
CurrentUser.scoped(@uploader, "127.0.0.2") do
upload = FactoryBot.create(:jpg_upload, as_pending: "0", tag_string: "lowres tag1")
upload.process!
attributes = FactoryBot.attributes_for(:jpg_upload, as_pending: "0", tag_string: "lowres tag1")
service = UploadService.new(attributes)
upload = service.start!
@post = upload.post
end
end
......@@ -65,6 +66,7 @@ class PostReplacementTest < ActiveSupport::TestCase
end
should "create a post replacement record" do
binding.pry
assert_equal(@post.id, PostReplacement.last.post_id)
end
......