[Posts] Overhaul destroyed posts (#672)

This commit is contained in:
Donovan Daniels 2024-07-13 19:05:50 -05:00 committed by GitHub
parent c186b4ee2f
commit d9f6653e02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 323 additions and 40 deletions

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Admin
class DestroyedPostsController < ApplicationController
before_action :admin_only
before_action :is_bd_staff_only, only: %i[update]
respond_to :html
def index
@destroyed_posts = DestroyedPost.search(search_params).paginate(params[:page], limit: params[:limit])
end
def show
redirect_to(admin_destroyed_posts_path(search: { post_id: params[:id] }))
end
def update
@destroyed_post = DestroyedPost.find_by!(post_id: params[:id])
@destroyed_post.update(dp_params)
flash[:notice] = dp_params[:notify] == "true" ? "Re-uploads of that post will now notify admins" : "Re-uploads of that post will no longer notify admins"
redirect_to(admin_destroyed_posts_path)
end
private
def search_params
permit_search_params(%i[destroyer_id destroyer_name destroyer_ip_addr uploader_id uploader_name uploader_ip_addr post_id md5])
end
def dp_params
params.require(:destroyed_post).permit(:notify)
end
end
end

View File

@ -48,7 +48,7 @@ module Moderator
def expunge
@post = ::Post.find(params[:id])
@post.expunge!
@post.expunge!(reason: params[:reason])
respond_with(@post)
end

View File

@ -23,6 +23,7 @@ class PostReplacementsController < ApplicationController
check_allow_create
@post = Post.find(params[:post_id])
@post_replacement = @post.replacements.create(create_params.merge(creator_id: CurrentUser.id, creator_ip_addr: CurrentUser.ip_addr))
@post_replacement.notify_reupload
if @post_replacement.errors.none?
flash[:notice] = "Post replacement submitted"
end

View File

@ -354,9 +354,9 @@ Post.initialize_links = function() {
});
$("#destroy-post-link").on('click', e => {
e.preventDefault();
if(!confirm("This will permanently delete this post (meaning the file will be deleted). Are you sure you want to delete this post?"))
return;
Post.destroy($(e.target).data('pid'));
const reason = prompt("This will permanently delete this post (meaning the file will be deleted). What is the reason for destroying the post?")
if(reason === null) return;
Post.destroy($(e.target).data('pid'), reason);
});
$("#regenerate-image-samples-link").on('click', e => {
e.preventDefault();
@ -841,13 +841,13 @@ Post.unapprove = function(post_id) {
});
}
Post.destroy = function(post_id) {
$.post(`/moderator/post/posts/${post_id}/expunge.json`, {}
Post.destroy = function(post_id, reason) {
$.post(`/moderator/post/posts/${post_id}/expunge.json`, { reason }
).fail(data => {
var message = $.map(data.responseJSON.errors, function(msg, attr) { return msg; }).join("; ");
$(window).trigger("danbooru:error", "Error: " + message);
}).done(data => {
location.reload();
location.href = `/admin/destroyed_posts/${post_id}`;
});
};

View File

@ -39,6 +39,7 @@
@import "specific/bans.scss";
@import "specific/blips.scss";
@import "specific/comments.scss";
@import "specific/destroyed_posts.scss";
@import "specific/dmails.scss";
@import "specific/edit_history.scss";
@import "specific/error.scss";

View File

@ -0,0 +1,5 @@
#c-admin-destroyed-posts {
tr[data-notify=false] {
background-color: $negative-record-background;
}
}

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
class DummyTicket
def initialize(accused, post_id)
@ticket = Ticket.new(
id: 0,
created_at: Time.now,
updated_at: Time.now,
creator_id: User.system.id,
disp_id: 0,
status: "pending",
qtype: "user",
reason: "User ##{accused.id} (#{accused.name}) tried to reupload destroyed post ##{post_id}",
)
end
def notify
@ticket.push_pubsub("create")
end
end

View File

@ -1,5 +1,55 @@
# frozen_string_literal: true
class DestroyedPost < ApplicationRecord
belongs_to :destroyer, class_name: "User"
belongs_to :uploader, class_name: "User", optional: true
after_update :log_notify_change, if: :saved_change_to_notify?
def log_notify_change
action = notify? ? :enable_post_notifications : :disable_post_notifications
StaffAuditLog.log(action, CurrentUser.user, { destroyed_post_id: id, post_id: post_id })
end
module SearchMethods
def search(params)
q = super
q = q.where_user(:destroyer_id, :destroyer, params)
q = q.where_user(:uploader_id, :uploader, params)
if params[:destroyer_ip_addr].present?
q = q.where("destroyer_ip_addr <<= ?", params[:destroyer_ip_addr])
end
if params[:uploader_ip_addr].present?
q = q.where("uploader_ip_addr <<= ?", params[:uploader_ip_addr])
end
if params[:post_id].present?
q = q.attribute_matches(:post_id, params[:post_id])
end
if params[:md5].present?
q = q.attribute_matches(:md5, params[:md5])
end
q.apply_basic_order(params)
end
end
extend SearchMethods
def notify_reupload(uploader, replacement_post_id: nil)
return if notify == false
reason = "User tried to re-upload \"previously destroyed post ##{post_id}\":/admin/destroyed_posts/#{post_id}"
reason += " as a replacement for post ##{replacement_post_id}" if replacement_post_id.present?
Ticket.create!(
creator_id: User.system.id,
creator_ip_addr: "127.0.0.1",
disp_id: uploader.id,
status: "pending",
qtype: "user",
reason: reason,
).push_pubsub("create")
end
end

View File

@ -1133,7 +1133,7 @@ class Post < ApplicationRecord
end
module DeletionMethods
def backup_post_data_destroy
def backup_post_data_destroy(reason: "")
post_data = {
id: id,
description: description,
@ -1157,17 +1157,17 @@ class Post < ApplicationRecord
DestroyedPost.create!(post_id: id, post_data: post_data, md5: md5,
uploader_ip_addr: uploader_ip_addr, uploader_id: uploader_id,
destroyer_id: CurrentUser.id, destroyer_ip_addr: CurrentUser.ip_addr,
upload_date: created_at)
upload_date: created_at, reason: reason || "")
end
def expunge!
def expunge!(reason: "")
if is_status_locked?
self.errors.add(:is_status_locked, "; cannot delete post")
return false
end
transaction do
backup_post_data_destroy
backup_post_data_destroy(reason: reason)
end
transaction do

View File

@ -6,7 +6,7 @@ class PostReplacement < ApplicationRecord
belongs_to :creator, class_name: "User"
belongs_to :approver, class_name: "User", optional: true
belongs_to :uploader_on_approve, class_name: "User", foreign_key: :uploader_id_on_approve, optional: true
attr_accessor :replacement_file, :replacement_url, :tags, :is_backup
attr_accessor :replacement_file, :replacement_url, :tags, :is_backup, :is_destroyed_reupload
validate :user_is_not_limited, on: :create
validate :post_is_valid, on: :create
@ -33,6 +33,13 @@ class PostReplacement < ApplicationRecord
Addressable::URI.heuristic_parse(replacement_url) rescue nil
end
def notify_reupload
return unless is_destroyed_reupload
if (destroyed_post = DestroyedPost.find_by(md5: md5))
destroyed_post.notify_reupload(creator, replacement_post_id: post_id)
end
end
module PostMethods
def post_is_valid
if post.is_deleted?
@ -45,9 +52,9 @@ class PostReplacement < ApplicationRecord
def no_pending_duplicates
return true if is_backup
if (destroyed_post = DestroyedPost.find_by(md5: md5))
errors.add(:base, "An unexpected errror occured")
DummyTicket.new(creator, destroyed_post.post_id).notify
if DestroyedPost.find_by(md5: md5)
errors.add(:base, "That image had been deleted from our site, and cannot be re-uploaded")
self.is_destroyed_reupload = true
return
end

View File

@ -187,6 +187,7 @@ class Ticket < ApplicationRecord
end
def validate_creator_is_not_limited
return if creator == User.system
allowed = creator.can_ticket_with_reason
if allowed != true
errors.add(:creator, User.throttle_reason(allowed))
@ -330,6 +331,7 @@ class Ticket < ApplicationRecord
module NotificationMethods
def create_dmail
return if creator == User.system
should_send = saved_change_to_status? || (send_update_dmail.to_s.truthy? && saved_change_to_response?)
return unless should_send

View File

@ -178,8 +178,8 @@ class Upload < ApplicationRecord
end
if (destroyed_post = DestroyedPost.find_by(md5: md5))
errors.add(:base, "An unexpected errror occured")
DummyTicket.new(uploader, destroyed_post.post_id).notify
errors.add(:base, "That image had been deleted from our site, and cannot be re-uploaded")
destroyed_post.notify_reupload(uploader)
return
end

View File

@ -0,0 +1,70 @@
<div id="c-admin-destroyed-posts">
<div id="a-index">
<%= form_search(path: admin_destroyed_posts_path) do |f| %>
<%= f.user :destroyer %>
<%= f.input :destroyer_ip_addr, label: "Destroyer IP Address" %>
<%= f.user :uploader %>
<%= f.input :uploader_ip_addr, label: "Uploader IP Address" %>
<%= f.input :post_id, label: "Post ID" %>
<%= f.input :md5 %>
<% end %>
<table class="striped">
<thead>
<tr>
<th width="4%">ID</th>
<th width="12%">Destroyed At</th>
<th width="12%">Destroyer</th>
<th width="12%">Uploader</th>
<th width="20%">MD5</th>
<th>Reason</th>
<% if CurrentUser.user.is_bd_staff? %>
<th width="10%"></th>
<% end %>
</tr>
</thead>
<tbody>
<% @destroyed_posts.each do |destroyed_post| %>
<tr data-notify="<%= destroyed_post.notify %>">
<td><%= destroyed_post.post_id %></td>
<td><%= compact_time(destroyed_post.created_at) %></td>
<td>
<%= link_to_user(destroyed_post.destroyer) %><br>
<%= link_to_ip(destroyed_post.destroyer_ip_addr) %>
</td>
<td>
<% if destroyed_post.uploader_id.present? %>
<%= link_to_user(destroyed_post.uploader) %>
<% end %><br>
<% if destroyed_post.uploader_ip_addr.present? %>
<%= link_to_ip(destroyed_post.uploader_ip_addr) %>
<% end %>
</td>
<td>
<%= destroyed_post.md5 %>
</td>
<td>
<%= destroyed_post.reason %>
</td>
<% if CurrentUser.user.is_bd_staff? %>
<td>
<% if destroyed_post.notify %>
<%= link_to("Disable Notifications", admin_destroyed_post_path(id: destroyed_post.post_id, destroyed_post: { notify: "false" }), method: :put) %>
<% else %>
<%= link_to("Enable Notifications", admin_destroyed_post_path(id: destroyed_post.post_id, destroyed_post: { notify: "true" }), method: :put) %>
<% end %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% content_for(:page_title) do %>
Destroyed Posts
<% end %>
<%= numbered_paginator(@destroyed_posts) %>
</div>
</div>

View File

@ -116,6 +116,7 @@
<li><%= link_to("IP Bans", ip_bans_path) %></li>
<li><%= link_to("Alt list", alt_list_admin_users_path) %></li>
<li><%= link_to("Stuck DNP tags", new_admin_stuck_dnp_path) %></li>
<li><%= link_to("Destroyed Posts", admin_destroyed_posts_path) %></li>
<% end %>
<li><%= link_to("Upload Whitelist", upload_whitelists_path) %></li>
<li><%= link_to("Mod Actions", mod_actions_path) %></li>

View File

@ -24,6 +24,7 @@ Rails.application.routes.draw do
resources :exceptions, only: [:index, :show]
resource :reowner, controller: 'reowner', only: [:new, :create]
resource :stuck_dnp, controller: "stuck_dnp", only: %i[new create]
resources :destroyed_posts, only: %i[index show update]
resources :staff_notes, only: [:index]
resources :danger_zone, only: [:index] do
collection do

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddReasonAndNotifyToDestroyedPosts < ActiveRecord::Migration[7.1]
def change
add_column(:destroyed_posts, :reason, :string, null: false, default: "")
add_column(:destroyed_posts, :notify, :boolean, null: false, default: true)
end
end

View File

@ -407,7 +407,9 @@ CREATE TABLE public.destroyed_posts (
upload_date timestamp without time zone,
post_data json NOT NULL,
created_at timestamp(6) without time zone NOT NULL,
updated_at timestamp(6) without time zone NOT NULL
updated_at timestamp(6) without time zone NOT NULL,
reason character varying DEFAULT ''::character varying NOT NULL,
notify boolean DEFAULT true NOT NULL
);
@ -4493,6 +4495,7 @@ ALTER TABLE ONLY public.favorites
SET search_path TO "$user", public;
INSERT INTO "schema_migrations" (version) VALUES
('20240709134926'),
('20240101042716'),
('20230531080817'),
('20230518182034'),

View File

@ -22,5 +22,9 @@ FactoryBot.define do
factory(:large_jpg_upload) do
file { fixture_file_upload("test-large.jpg") }
end
factory(:png_upload) do
file { fixture_file_upload("test.png") }
end
end
end

View File

@ -47,5 +47,11 @@ FactoryBot.define do
level { 50 }
can_approve_posts { true }
end
factory(:bd_staff_user) do
level { 50 }
can_approve_posts { true }
is_bd_staff { true }
end
end
end

View File

@ -0,0 +1,50 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class DestroyedPostsControllerTest < ActionDispatch::IntegrationTest
context "The destroyed posts controller" do
setup do
@admin = create(:admin_user)
@bd_staff = create(:bd_staff_user)
@upload = UploadService.new(attributes_for(:jpg_upload).merge({ uploader: @admin })).start!
@post = @upload.post
as(@admin) { @post.expunge! }
@destroyed_post = DestroyedPost.find_by!(post_id: @post.id)
end
context "index action" do
should "render" do
get_auth admin_destroyed_posts_path, @admin
assert_response :success
end
end
context "show action" do
should "redirect" do
get_auth admin_destroyed_post_path(@post), @admin
assert_redirected_to(admin_destroyed_posts_path(search: { post_id: @post.id }))
end
end
context "update action" do
should "work" do
assert_difference("StaffAuditLog.count", 1) do
put_auth admin_destroyed_post_path(@post), @bd_staff, params: { destroyed_post: { notify: "false" } }
assert_redirected_to(admin_destroyed_posts_path)
assert_equal(false, @destroyed_post.reload.notify)
assert_equal("disable_post_notifications", StaffAuditLog.last.action)
end
assert_difference("StaffAuditLog.count", 1) do
put_auth admin_destroyed_post_path(@post), @bd_staff, params: { destroyed_post: { notify: "true" } }
assert_redirected_to(admin_destroyed_posts_path)
assert_equal(true, @destroyed_post.reload.notify)
assert_equal("enable_post_notifications", StaffAuditLog.last.action)
end
end
end
end
end
end

View File

@ -80,12 +80,20 @@ module Moderator
end
context "expunge action" do
should "render" do
should "work" do
post_auth expunge_moderator_post_post_path(@post), @admin, params: { format: :json }
assert_response :success
assert_equal(false, ::Post.exists?(@post.id))
end
should "work with reason" do
post_auth expunge_moderator_post_post_path(@post), @admin, params: { reason: "test", format: :json }
assert_response :success
assert_equal(false, ::Post.exists?(@post.id))
assert_equal("test", DestroyedPost.last.reason)
end
end
end
end

View File

@ -32,6 +32,34 @@ class PostReplacementsControllerTest < ActionDispatch::IntegrationTest
assert_equal @response.parsed_body["location"], post_path(@post)
end
context "with a previously destroyed post" do
setup do
@admin = create(:admin_user)
as(@admin) do
@replacement.destroy
@upload2 = UploadService.new(attributes_for(:png_upload).merge({ uploader: @user })).start!
@post2 = @upload2.post
@post2.expunge!
end
end
should "fail and create ticket" do
assert_difference({ "PostReplacement.count" => 0, "Ticket.count" => 1 }) do
file = fixture_file_upload("test.png")
post_auth post_replacements_path, @user, params: { post_id: @post.id, post_replacement: { replacement_file: file, reason: "test replacement" }, format: :json }
Rails.logger.debug PostReplacement.all.map(&:md5).join(", ")
end
end
should "fail and not create ticket if notify=false" do
DestroyedPost.find_by!(post_id: @post2.id).update_column(:notify, false)
assert_difference(%(Post.count Ticket.count), 0) do
file = fixture_file_upload("test.png")
post_auth post_replacements_path, @user, params: { post_id: @post.id, post_replacement: { replacement_file: file, reason: "test replacement" }, format: :json }
end
end
end
end
context "reject action" do

View File

@ -111,6 +111,30 @@ class UploadsControllerTest < ActionDispatch::IntegrationTest
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
end
end
context "with a previously destroyed post" do
setup do
@admin = create(:admin_user)
@upload = UploadService.new(attributes_for(:jpg_upload).merge({ uploader: @user })).start!
@post = @upload.post
as(@admin) { @post.expunge! }
end
should "fail and create ticket" do
assert_difference({ "Post.count" => 0, "Ticket.count" => 1 }) do
file = fixture_file_upload("test.jpg")
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
end
end
should "fail and not create ticket if notify=false" do
DestroyedPost.find_by!(post_id: @post.id).update_column(:notify, false)
assert_difference(%(Post.count Ticket.count), 0) do
file = fixture_file_upload("test.jpg")
post_auth uploads_path, @user, params: { upload: { file: file, tag_string: "aaa", rating: "q", source: "aaa" } }
end
end
end
end
end
end