diff --git a/app/models/post.rb b/app/models/post.rb index 70c32496e..1a646e76f 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -7,8 +7,6 @@ class Post < ActiveRecord::Base class RevertError < Exception ; end class SearchError < Exception ; end - DELETION_GRACE_PERIOD = 30.days - before_validation :initialize_uploader, :on => :create before_validation :merge_old_changes before_validation :normalize_tags @@ -51,6 +49,7 @@ class Post < ActiveRecord::Base has_many :approvals, :class_name => "PostApproval", :dependent => :destroy has_many :disapprovals, :class_name => "PostDisapproval", :dependent => :destroy has_many :favorites, :dependent => :destroy + has_many :replacements, class_name: "PostReplacement" if PostArchive.enabled? has_many :versions, lambda {order("post_versions.updated_at ASC")}, :class_name => "PostArchive", :dependent => :destroy @@ -1430,50 +1429,9 @@ class Post < ActiveRecord::Base ModAction.log("undeleted post ##{id}") end - def replace!(url, replacer = CurrentUser.user) - # TODO for posts with notes we need to rescale the notes if the dimensions change. - if notes.size > 0 - raise NotImplementedError.new("Replacing images with notes not yet supported.") - end - - # TODO for ugoiras we need to replace the frame data. - if is_ugoira? - raise NotImplementedError.new("Replacing ugoira images not yet supported.") - end - - # TODO images hosted on s3 need to be deleted from s3 instead of the local filesystem. - if Danbooru.config.use_s3_proxy?(self) - raise NotImplementedError.new("Replacing S3 hosted images not yet supported.") - end - - transaction do - upload = Upload.create!(source: url, rating: self.rating, tag_string: self.tag_string) - upload.process_upload - upload.update(status: "completed", post_id: id) - - # 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. - Post.delay(queue: "default", run_at: Time.now + DELETION_GRACE_PERIOD).delete_files(id, file_path, large_file_path, preview_file_path) - - self.md5 = upload.md5 - self.file_ext = upload.file_ext - self.image_width = upload.image_width - self.image_height = upload.image_height - self.file_size = upload.file_size - self.source = upload.source - self.tag_string = upload.tag_string - - comments.create!({creator: User.system, body: presenter.comment_replacement_message(replacer), do_not_bump_post: true}, without_protection: true) - ModAction.log(presenter.modaction_replacement_message) - - save! - end - - # point of no return: these things can't be rolled back, so we do them - # only after the transaction successfully commits. - distribute_files - update_iqdb_async + def replace!(url) + replacement = replacements.create(replacement_url: url) + replacement.process! end end diff --git a/app/models/post_replacement.rb b/app/models/post_replacement.rb new file mode 100644 index 000000000..2c2407f74 --- /dev/null +++ b/app/models/post_replacement.rb @@ -0,0 +1,64 @@ +class PostReplacement < ActiveRecord::Base + DELETION_GRACE_PERIOD = 30.days + + belongs_to :post + belongs_to :creator, class_name: "User" + before_validation :initialize_fields + attr_accessible :replacement_url + + def initialize_fields + self.creator = CurrentUser.user + self.original_url = post.source + end + + def undo! + undo_replacement = post.replacements.create(replacement_url: original_url) + undo_replacement.process! + end + + def process! + # TODO for posts with notes we need to rescale the notes if the dimensions change. + if post.notes.any? + raise NotImplementedError.new("Replacing images with notes not yet supported.") + end + + # TODO for ugoiras we need to replace the frame data. + if post.is_ugoira? + raise NotImplementedError.new("Replacing ugoira images not yet supported.") + end + + # TODO images hosted on s3 need to be deleted from s3 instead of the local filesystem. + if Danbooru.config.use_s3_proxy?(post) + raise NotImplementedError.new("Replacing S3 hosted images not yet supported.") + end + + transaction do + upload = Upload.create!(source: replacement_url, rating: post.rating, tag_string: post.tag_string) + upload.process_upload + upload.update(status: "completed", post_id: post.id) + + # 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. + Post.delay(queue: "default", run_at: Time.now + DELETION_GRACE_PERIOD).delete_files(post.id, post.file_path, post.large_file_path, post.preview_file_path) + + 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 + + post.comments.create!({creator: User.system, body: post.presenter.comment_replacement_message(creator), do_not_bump_post: true}, without_protection: true) + ModAction.log(post.presenter.modaction_replacement_message) + + post.save! + end + + # point of no return: these things can't be rolled back, so we do them + # only after the transaction successfully commits. + post.distribute_files + post.update_iqdb_async + end +end diff --git a/db/migrate/20170512221200_create_post_replacements.rb b/db/migrate/20170512221200_create_post_replacements.rb new file mode 100644 index 000000000..c5b844590 --- /dev/null +++ b/db/migrate/20170512221200_create_post_replacements.rb @@ -0,0 +1,14 @@ +class CreatePostReplacements < ActiveRecord::Migration + def change + create_table :post_replacements do |t| + t.integer :post_id, null: false + t.integer :creator_id, null: false + t.text :original_url, null: false + t.text :replacement_url, null: false + t.timestamps null: false + end + + add_index :post_replacements, :post_id + add_index :post_replacements, :creator_id + end +end diff --git a/db/structure.sql b/db/structure.sql index 65dd4ebe7..b9adfc198 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2686,6 +2686,40 @@ CREATE SEQUENCE post_flags_id_seq ALTER SEQUENCE post_flags_id_seq OWNED BY post_flags.id; +-- +-- Name: post_replacements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE post_replacements ( + id integer NOT NULL, + post_id integer NOT NULL, + creator_id integer NOT NULL, + original_url text NOT NULL, + replacement_url text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + +-- +-- Name: post_replacements_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE post_replacements_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: post_replacements_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE post_replacements_id_seq OWNED BY post_replacements.id; + + -- -- Name: post_updates; Type: TABLE; Schema: public; Owner: - -- @@ -4265,6 +4299,13 @@ ALTER TABLE ONLY post_disapprovals ALTER COLUMN id SET DEFAULT nextval('post_dis ALTER TABLE ONLY post_flags ALTER COLUMN id SET DEFAULT nextval('post_flags_id_seq'::regclass); +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY post_replacements ALTER COLUMN id SET DEFAULT nextval('post_replacements_id_seq'::regclass); + + -- -- Name: id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4658,6 +4699,14 @@ ALTER TABLE ONLY post_flags ADD CONSTRAINT post_flags_pkey PRIMARY KEY (id); +-- +-- Name: post_replacements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY post_replacements + ADD CONSTRAINT post_replacements_pkey PRIMARY KEY (id); + + -- -- Name: post_votes_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -6780,6 +6829,20 @@ CREATE INDEX index_post_flags_on_post_id ON post_flags USING btree (post_id); CREATE INDEX index_post_flags_on_reason_tsvector ON post_flags USING gin (to_tsvector('english'::regconfig, reason)); +-- +-- Name: index_post_replacements_on_creator_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_post_replacements_on_creator_id ON post_replacements USING btree (creator_id); + + +-- +-- Name: index_post_replacements_on_post_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_post_replacements_on_post_id ON post_replacements USING btree (post_id); + + -- -- Name: index_post_votes_on_post_id; Type: INDEX; Schema: public; Owner: - -- @@ -7471,3 +7534,5 @@ INSERT INTO schema_migrations (version) VALUES ('20170416224142'); INSERT INTO schema_migrations (version) VALUES ('20170428220448'); +INSERT INTO schema_migrations (version) VALUES ('20170512221200'); + diff --git a/test/unit/post_test.rb b/test/unit/post_test.rb index 2f054a5c4..1cc8d4f0a 100644 --- a/test/unit/post_test.rb +++ b/test/unit/post_test.rb @@ -1,10 +1,12 @@ require 'test_helper' require 'helpers/pool_archive_test_helper' require 'helpers/saved_search_test_helper' +require 'helpers/iqdb_test_helper' class PostTest < ActiveSupport::TestCase include PoolArchiveTestHelper include SavedSearchTestHelper + include IqdbTestHelper def assert_tag_match(posts, query) assert_equal(posts.map(&:id), Post.tag_match(query).pluck(:id)) @@ -1588,6 +1590,7 @@ class PostTest < ActiveSupport::TestCase context "Replacing: " do setup do + mock_iqdb_service! Delayed::Worker.delay_jobs = true # don't delete the old images right away Danbooru.config.stubs(:use_s3_proxy?).returns(false) # don't fail on post ids < 10000 @@ -1612,11 +1615,38 @@ class PostTest < ActiveSupport::TestCase context "replacing a post from a generic source" do setup do + @post.update(source: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png") @post.replace!("https://www.google.com/intl/en_ALL/images/logo.gif") @upload = Upload.last @mod_action = ModAction.last end + context "that is then undone" do + setup do + Timecop.travel(Time.now + PostReplacement::DELETION_GRACE_PERIOD + 1.day) do + Delayed::Worker.new.work_off + end + + @replacement = @post.replacements.first + @replacement.undo! + @post.reload + end + + should "update the attributes" do + assert_equal(272, @post.image_width) + assert_equal(92, @post.image_height) + assert_equal(5969, @post.file_size) + assert_equal("png", @post.file_ext) + assert_equal("8f9327db2597fa57d2f42b4a6c5a9855", @post.md5) + assert_equal("8f9327db2597fa57d2f42b4a6c5a9855", Digest::MD5.file(@post.file_path).hexdigest) + assert_equal("https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", @post.source) + end + end + + should "create a post replacement record" do + assert_equal(@post.id, PostReplacement.last.post_id) + end + should "correctly update the attributes" do assert_equal(@post.id, @upload.post.id) assert_equal("completed", @upload.status) @@ -1670,7 +1700,7 @@ class PostTest < ActiveSupport::TestCase assert(File.exists?(old_preview_file_path)) assert(File.exists?(old_large_file_path)) - Timecop.travel(Time.now + Post::DELETION_GRACE_PERIOD + 1.day) do + Timecop.travel(Time.now + PostReplacement::DELETION_GRACE_PERIOD + 1.day) do Delayed::Worker.new.work_off end