votes: allow admins to remove post votes.

Allow admins to remove votes on posts. This is for fixing vote abuse.

Votes can be removed by going to the vote list on the /post_votes page,
or by clicking on a post's score, then using the "Remove" option in the
"..." dropdown menu next to the vote.

Votes are soft-deleted - they're marked as deleted in the database, but
not fully deleted. Removed votes are only visible to admins, not to
regular users. When a vote is removed by an admin, it leaves a mod
action.

Technically it's possible to undelete votes, but there's no UI for it.
This commit is contained in:
evazion
2021-11-23 22:20:01 -06:00
parent 692f2848f2
commit 353e708538
19 changed files with 261 additions and 108 deletions

View File

@@ -2,7 +2,7 @@
<% if current_user.is_anonymous? %>
<%= link_to upvote_icon, login_path(url: request.fullpath), class: "post-upvote-link inactive-link" %>
<% elsif upvoted? %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %>
<%= link_to upvote_icon, post_vote_path(current_vote), class: "post-upvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %>
<%= link_to upvote_icon, post_post_votes_path(post_id: post.id, score: 1), class: "post-upvote-link inactive-link", method: :post, remote: true %>
<% end %>
@@ -14,7 +14,7 @@
<% if current_user.is_anonymous? %>
<%= link_to downvote_icon, login_path(url: request.fullpath), class: "post-downvote-link inactive-link" %>
<% elsif downvoted? %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %>
<%= link_to downvote_icon, post_vote_path(current_vote), class: "post-downvote-link post-unvote-link active-link", method: :delete, remote: true %>
<% else %>
<%= link_to downvote_icon, post_post_votes_path(post_id: post.id, score: -1), class: "post-downvote-link inactive-link", method: :post, remote: true %>
<% end %>

View File

@@ -12,7 +12,7 @@ class PostVotesTooltipComponent < ApplicationComponent
end
def votes
@post.votes.includes(:user).order(id: :desc)
@post.votes.active.includes(:user).order(id: :desc)
end
def vote_icon(vote)

View File

@@ -2,7 +2,7 @@ class PostVotesController < ApplicationController
respond_to :js, :json, :xml, :html
def index
@post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params, count_pages: true)
@post_votes = authorize PostVote.visible(CurrentUser.user).paginated_search(params)
@post_votes = @post_votes.includes(:user, post: [:uploader, :media_asset]) if request.format.html?
@post = Post.find(params.dig(:search, :post_id)) if params.dig(:search, :post_id).present?
@@ -15,23 +15,28 @@ class PostVotesController < ApplicationController
end
def create
@post = Post.find(params[:post_id])
@post.with_lock do
@post_vote = authorize PostVote.new(post: @post, score: params[:score], user: CurrentUser.user)
PostVote.where(post: @post, user: CurrentUser.user).destroy_all
@post_vote.save
end
@post_vote = authorize PostVote.new(post_id: params[:post_id], score: params[:score], user: CurrentUser.user)
@post_vote.save
@post = @post_vote.post.reload
flash.now[:notice] = @post_vote.errors.full_messages.join("; ") if @post_vote.errors.present?
respond_with(@post_vote)
end
def destroy
@post = Post.find(params[:post_id])
@post_vote = @post.votes.find_by(user: CurrentUser.user)
if params[:post_id].present?
@post_vote = PostVote.active.find_by(post_id: params[:post_id], user_id: CurrentUser.user)
@post = Post.find(params[:post_id])
else
@post_vote = PostVote.find(params[:id])
@post = @post_vote.post
end
if @post_vote.present?
authorize(@post_vote).soft_delete(updater: CurrentUser.user)
@post.reload
end
authorize(@post_vote).destroy if @post_vote
respond_with(@post_vote)
end
end

View File

@@ -215,9 +215,9 @@ class PostQueryBuilder
when "noteupdater"
user_subquery_matches(NoteVersion.unscoped, value, field: :updater)
when "upvoter", "upvote"
user_subquery_matches(PostVote.positive.visible(current_user), value, field: :user)
user_subquery_matches(PostVote.active.positive.visible(current_user), value, field: :user)
when "downvoter", "downvote"
user_subquery_matches(PostVote.negative.visible(current_user), value, field: :user)
user_subquery_matches(PostVote.active.negative.visible(current_user), value, field: :user)
when *CATEGORY_COUNT_METATAGS
short_category = name.delete_suffix("tags")
category = TagCategory.short_name_mapping[short_category]

View File

@@ -28,15 +28,13 @@ class Favorite < ApplicationRecord
end
def upvote_post_on_create
if Pundit.policy!(user, PostVote).create?
PostVote.negative.destroy_by(post: post, user: user)
# Silently ignore the error if the user has already upvoted the post.
PostVote.create(post: post, user: user, score: 1)
if Pundit.policy!(user, PostVote).create? && !PostVote.active.exists?(post: post, user: user, score: 1)
PostVote.create!(post: post, user: user, score: 1)
end
end
def unvote_post_on_destroy
PostVote.positive.destroy_by(post: post, user: user)
vote = PostVote.active.positive.find_by(post: post, user: user)
vote&.soft_delete!(updater: user)
end
end

View File

@@ -35,6 +35,8 @@ class ModAction < ApplicationRecord
post_note_lock_delete: 212,
post_rating_lock_create: 220,
post_rating_lock_delete: 222,
post_vote_delete: 232,
post_vote_undelete: 233,
pool_delete: 62,
pool_undelete: 63,
artist_ban: 184,

View File

@@ -40,7 +40,7 @@ class Post < ApplicationRecord
has_one :upload, :dependent => :destroy
has_one :artist_commentary, :dependent => :destroy
has_one :pixiv_ugoira_frame_data, class_name: "PixivUgoiraFrameData", foreign_key: :md5, primary_key: :md5
has_one :vote_by_current_user, -> { where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong
has_one :vote_by_current_user, -> { active.where(user_id: CurrentUser.id) }, class_name: "PostVote" # XXX using current user here is wrong
has_many :flags, :class_name => "PostFlag", :dependent => :destroy
has_many :appeals, :class_name => "PostAppeal", :dependent => :destroy
has_many :votes, :class_name => "PostVote", :dependent => :destroy
@@ -701,18 +701,10 @@ class Post < ApplicationRecord
return unless Pundit.policy!(voter, PostVote).create?
with_lock do
votes.destroy_by(user: voter)
votes.create!(user: voter, score: score)
votes.create!(user: voter, score: score) unless votes.active.exists?(user: voter, score: score)
reload # PostVote.create modifies our score. Reload to get the new score.
end
end
def unvote!(voter)
return unless Pundit.policy!(voter, PostVote).create?
votes.destroy_by(user: voter)
reload
end
end
module ParentMethods

View File

@@ -1,25 +1,36 @@
class PostVote < ApplicationRecord
attr_accessor :updater
belongs_to :post
belongs_to :user
validates :user_id, uniqueness: { scope: :post_id, message: "have already voted for this post" }
validates :score, inclusion: { in: [1, -1], message: "must be 1 or -1" }
after_create :update_score_after_create
after_destroy :update_score_after_destroy
before_save { post.lock! }
before_save :update_score_on_delete, if: -> { !new_record? && is_deleted_changed?(from: false, to: true) }
before_save :update_score_on_undelete, if: -> { !new_record? && is_deleted_changed?(from: true, to: false) }
before_save :create_mod_action_on_delete_or_undelete
before_create :update_score_on_create
before_create :remove_conflicting_votes
scope :positive, -> { where("post_votes.score > 0") }
scope :negative, -> { where("post_votes.score < 0") }
scope :public_votes, -> { positive.where(user: User.has_public_favorites) }
scope :public_votes, -> { active.positive.where(user: User.has_public_favorites) }
deletable
def self.visible(user)
user.is_admin? ? all : where(user: user).or(public_votes)
if user.is_admin?
all
elsif user.is_anonymous?
public_votes
else
active.where(user: user).or(public_votes)
end
end
def self.search(params)
q = search_attributes(params, :id, :created_at, :updated_at, :score, :user, :post)
q = search_attributes(params, :id, :created_at, :updated_at, :score, :is_deleted, :user, :post)
q.apply_default_order(params)
end
@@ -32,7 +43,19 @@ class PostVote < ApplicationRecord
score < 0
end
def update_score_after_create
def remove_conflicting_votes
PostVote.active.where.not(id: id).where(post: post, user: user).each do |vote|
vote.soft_delete!(updater: updater)
end
end
def validate_vote_is_unique
if !is_deleted? && PostVote.active.where.not(id: id).exists?(post: post, user: user)
errors.add(:user, "have already voted for this post")
end
end
def update_score_on_create
if is_positive?
Post.update_counters(post_id, { score: score, up_score: score })
else
@@ -40,7 +63,7 @@ class PostVote < ApplicationRecord
end
end
def update_score_after_destroy
def update_score_on_delete
if is_positive?
Post.update_counters(post_id, { score: -score, up_score: -score })
else
@@ -48,6 +71,20 @@ class PostVote < ApplicationRecord
end
end
def update_score_on_undelete
update_score_on_create
end
def create_mod_action_on_delete_or_undelete
return if new_record? || updater.nil? || updater == user
if is_deleted_changed?(from: false, to: true)
ModAction.log("#{updater.name} deleted post vote ##{id} on post ##{post_id}", :post_vote_delete, updater)
elsif is_deleted_changed?(from: true, to: false)
ModAction.log("#{updater.name} undeleted post vote ##{id} on post ##{post_id}", :post_vote_undelete, updater)
end
end
def self.available_includes
[:user, :post]
end

View File

@@ -4,11 +4,11 @@ class PostVotePolicy < ApplicationPolicy
end
def destroy?
unbanned? && record.user == user
record.user == user || user.is_admin?
end
def show?
user.is_admin? || record.user == user || (record.is_positive? && !record.user.enable_private_favorites?)
user.is_admin? || record.user == user || (record.is_positive? && !record.is_deleted? && !record.user.enable_private_favorites?)
end
def can_see_voter?

View File

@@ -1 +1 @@
$("span.post-votes[data-id=<%= @post.reload.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>");
$("span.post-votes[data-id=<%= @post.id %>]").replaceWith("<%= j render_post_votes @post, current_user: CurrentUser.user %>");

View File

@@ -0,0 +1,2 @@
Danbooru.Utility.notice("Vote removed");
location.reload();

View File

@@ -7,6 +7,10 @@
<%= table_for @post_votes, class: "striped autofit" do |t| %>
<% t.column "Score" do |vote| %>
<%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %>
<% if vote.is_deleted? %>
(deleted)
<% end %>
<% end %>
<% t.column "Voter" do |vote| %>
@@ -17,6 +21,16 @@
<% t.column "Created" do |vote| %>
<%= time_ago_in_words_tagged(vote.created_at) %>
<% end %>
<% t.column column: "control" do |vote| %>
<% if !vote.is_deleted? && policy(vote).destroy? %>
<%= render PopupMenuComponent.new do |menu| %>
<% menu.item do %>
<%= link_to "Remove", post_vote_path(vote.id, variant: "listing"), method: :delete, remote: true %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>
<%= numbered_paginator(@post_votes) %>

View File

@@ -6,17 +6,25 @@
<% t.column "Post" do |vote| %>
<%= post_preview(vote.post, show_deleted: true) %>
<% end %>
<% t.column "Tags", td: {class: "col-expand"} do |vote| %>
<%= render_inline_tag_list(vote.post) %>
<% end %>
<% t.column "Score" do |vote| %>
<%= link_to sprintf("%+d", vote.score), post_votes_path(search: { score: vote.score }) %>
<% if vote.is_deleted? %>
(deleted)
<% end %>
<% end %>
<% t.column "Uploader" do |vote| %>
<%= link_to_user vote.post.uploader %>
<%= link_to "»", post_votes_path(search: { post_tags_match: "user:#{vote.post.uploader.name}" }) %>
<div><%= time_ago_in_words_tagged(vote.post.created_at) %></div>
<% end %>
<% t.column "Voter" do |vote| %>
<% if policy(vote).can_see_voter? %>
<%= link_to_user vote.user %>
@@ -26,9 +34,14 @@
<% end %>
<div><%= time_ago_in_words_tagged(vote.created_at) %></div>
<% end %>
<% t.column column: "control" do |vote| %>
<% if vote.user == CurrentUser.user %>
<%= link_to "unvote", post_post_votes_path(vote.post), remote: true, method: :delete %>
<% if !vote.is_deleted? && policy(vote).destroy? %>
<%= render PopupMenuComponent.new do |menu| %>
<% menu.item do %>
<%= link_to "Remove", post_vote_path(vote.id, variant: "listing"), method: :delete, remote: true %>
<% end %>
<% end %>
<% end %>
<% end %>
<% end %>