Add ability to view previous edits of a status in admin UI (#19462)

* Add ability to view previous edits of a status in admin UI

* Change moderator access to posts to be controlled by a separate policy
This commit is contained in:
Eugen Rochko 2022-10-26 13:42:29 +02:00 committed by GitHub
parent dee69be60e
commit f8ca3bb2a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 232 additions and 55 deletions

View file

@ -3,18 +3,23 @@
module Admin module Admin
class StatusesController < BaseController class StatusesController < BaseController
before_action :set_account before_action :set_account
before_action :set_statuses before_action :set_statuses, except: :show
before_action :set_status, only: :show
PER_PAGE = 20 PER_PAGE = 20
def index def index
authorize :status, :index? authorize [:admin, :status], :index?
@status_batch_action = Admin::StatusBatchAction.new @status_batch_action = Admin::StatusBatchAction.new
end end
def show
authorize [:admin, @status], :show?
end
def batch def batch
authorize :status, :index? authorize [:admin, :status], :index?
@status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button)) @status_batch_action = Admin::StatusBatchAction.new(admin_status_batch_action_params.merge(current_account: current_account, report_id: params[:report_id], type: action_from_button))
@status_batch_action.save! @status_batch_action.save!
@ -32,6 +37,7 @@ module Admin
def after_create_redirect_path def after_create_redirect_path
report_id = @status_batch_action&.report_id || params[:report_id] report_id = @status_batch_action&.report_id || params[:report_id]
if report_id.present? if report_id.present?
admin_report_path(report_id) admin_report_path(report_id)
else else
@ -43,6 +49,10 @@ module Admin
@account = Account.find(params[:account_id]) @account = Account.find(params[:account_id])
end end
def set_status
@status = @account.statuses.find(params[:id])
end
def set_statuses def set_statuses
@statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE) @statuses = Admin::StatusFilter.new(@account, filter_params).results.preload(:application, :preloadable_poll, :media_attachments, active_mentions: :account, reblog: [:account, :application, :preloadable_poll, :media_attachments, active_mentions: :account]).page(params[:page]).per(PER_PAGE)
end end

View file

@ -2,7 +2,7 @@
class Admin::Trends::StatusesController < Admin::BaseController class Admin::Trends::StatusesController < Admin::BaseController
def index def index
authorize :status, :review? authorize [:admin, :status], :review?
@locales = StatusTrend.pluck('distinct language') @locales = StatusTrend.pluck('distinct language')
@statuses = filtered_statuses.page(params[:page]) @statuses = filtered_statuses.page(params[:page])
@ -10,7 +10,7 @@ class Admin::Trends::StatusesController < Admin::BaseController
end end
def batch def batch
authorize :status, :review? authorize [:admin, :status], :review?
@form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button)) @form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save @form.save

View file

@ -323,7 +323,7 @@ class StatusActionBar extends ImmutablePureComponent {
if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
} }
} }

View file

@ -254,7 +254,7 @@ class ActionBar extends React.PureComponent {
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) { if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` }); menu.push({ text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }), href: `/admin/accounts/${status.getIn(['account', 'id'])}` });
menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses/${status.get('id')}` });
} }
} }

View file

@ -1752,3 +1752,67 @@ a.sparkline {
} }
} }
} }
.history {
counter-reset: step 0;
font-size: 15px;
line-height: 22px;
li {
counter-increment: step 1;
padding-left: 2.5rem;
padding-bottom: 8px;
position: relative;
margin-bottom: 8px;
&::before {
position: absolute;
content: counter(step);
font-size: 0.625rem;
font-weight: 500;
left: 0;
display: flex;
justify-content: center;
align-items: center;
width: calc(1.375rem + 1px);
height: calc(1.375rem + 1px);
background: $ui-base-color;
border: 1px solid $highlight-text-color;
color: $highlight-text-color;
border-radius: 8px;
}
&::after {
position: absolute;
content: "";
width: 1px;
background: $highlight-text-color;
bottom: 0;
top: calc(1.875rem + 1px);
left: 0.6875rem;
}
&:last-child {
margin-bottom: 0;
&::after {
display: none;
}
}
}
&__entry {
h5 {
font-weight: 500;
color: $primary-text-color;
line-height: 25px;
margin-bottom: 16px;
}
.status {
border: 1px solid lighten($ui-base-color, 4%);
background: $ui-base-color;
border-radius: 4px;
}
}
}

View file

@ -3,7 +3,6 @@
class Admin::StatusFilter class Admin::StatusFilter
KEYS = %i( KEYS = %i(
media media
id
report_id report_id
).freeze ).freeze
@ -28,12 +27,10 @@ class Admin::StatusFilter
private private
def scope_for(key, value) def scope_for(key, _value)
case key.to_s case key.to_s
when 'media' when 'media'
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id).reorder('statuses.id desc') Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id).reorder('statuses.id desc')
when 'id'
Status.where(id: value)
else else
raise "Unknown filter: #{key}" raise "Unknown filter: #{key}"
end end

View file

@ -30,7 +30,7 @@ class StatusEdit < ApplicationRecord
:preview_remote_url, :text_url, :meta, :blurhash, :preview_remote_url, :text_url, :meta, :blurhash,
:not_processed?, :needs_redownload?, :local?, :not_processed?, :needs_redownload?, :local?,
:file, :thumbnail, :thumbnail_remote_url, :file, :thumbnail, :thumbnail_remote_url,
:shortcode, to: :media_attachment :shortcode, :video?, :audio?, to: :media_attachment
end end
rate_limit by: :account, family: :statuses rate_limit by: :account, family: :statuses
@ -40,7 +40,8 @@ class StatusEdit < ApplicationRecord
default_scope { order(id: :asc) } default_scope { order(id: :asc) }
delegate :local?, to: :status delegate :local?, :application, :edited?, :edited_at,
:discarded?, :visibility, to: :status
def emojis def emojis
return @emojis if defined?(@emojis) return @emojis if defined?(@emojis)
@ -59,4 +60,12 @@ class StatusEdit < ApplicationRecord
end end
end end
end end
def proper
self
end
def reblog?
false
end
end end

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Admin::StatusPolicy < ApplicationPolicy
def initialize(current_account, record, preloaded_relations = {})
super(current_account, record)
@preloaded_relations = preloaded_relations
end
def index?
role.can?(:manage_reports, :manage_users)
end
def show?
role.can?(:manage_reports, :manage_users) && (record.public_visibility? || record.unlisted_visibility? || record.reported?)
end
def destroy?
role.can?(:manage_reports)
end
def update?
role.can?(:manage_reports)
end
def review?
role.can?(:manage_taxonomies)
end
end

View file

@ -7,10 +7,6 @@ class StatusPolicy < ApplicationPolicy
@preloaded_relations = preloaded_relations @preloaded_relations = preloaded_relations
end end
def index?
role.can?(:manage_reports, :manage_users)
end
def show? def show?
return false if author.suspended? return false if author.suspended?
@ -32,17 +28,13 @@ class StatusPolicy < ApplicationPolicy
end end
def destroy? def destroy?
role.can?(:manage_reports) || owned? owned?
end end
alias unreblog? destroy? alias unreblog? destroy?
def update? def update?
role.can?(:manage_reports) || owned? owned?
end
def review?
role.can?(:manage_taxonomies)
end end
private private

View file

@ -0,0 +1,8 @@
- if status.ordered_media_attachments.first.video?
- video = status.ordered_media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json
- elsif status.ordered_media_attachments.first.audio?
- audio = status.ordered_media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration)
- else
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, visible: false, media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }

View file

@ -12,14 +12,7 @@
= prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis) = prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
- unless status.proper.ordered_media_attachments.empty? - unless status.proper.ordered_media_attachments.empty?
- if status.proper.ordered_media_attachments.first.video? = render partial: 'admin/reports/media_attachments', locals: { status: status.proper }
- video = status.proper.ordered_media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), frameRate: video.file.meta.dig('original', 'frame_rate'), blurhash: video.blurhash, sensitive: status.proper.sensitive?, visible: false, width: 610, height: 343, inline: true, alt: video.description, media: [ActiveModelSerializers::SerializableResource.new(video, serializer: REST::MediaAttachmentSerializer)].as_json
- elsif status.proper.ordered_media_attachments.first.audio?
- audio = status.proper.ordered_media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration)
- else
= react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
.detailed-status__meta .detailed-status__meta
- if status.application - if status.application
@ -29,7 +22,7 @@
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
- if status.edited? - if status.edited?
· ·
= t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')) = link_to t('statuses.edited_at_html', date: content_tag(:time, l(status.edited_at), datetime: status.edited_at.iso8601, title: l(status.edited_at), class: 'formatted')), admin_account_status_path(status.account_id, status), class: 'detailed-status__datetime'
- if status.discarded? - if status.discarded?
· ·
%span.negative-hint= t('admin.statuses.deleted') %span.negative-hint= t('admin.statuses.deleted')

View file

@ -0,0 +1,20 @@
.status
.status__content><
- if status_edit.spoiler_text.blank?
= prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis)
- else
%details<
%summary><
%strong> Content warning: #{prerender_custom_emojis(h(status_edit.spoiler_text), status_edit.emojis)}
= prerender_custom_emojis(status_content_format(status_edit), status_edit.emojis)
- unless status_edit.ordered_media_attachments.empty?
= render partial: 'admin/reports/media_attachments', locals: { status: status_edit }
.detailed-status__meta
%time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at)
- if status_edit.sensitive?
·
= fa_icon('eye-slash fw')
= t('stream_entries.sensitive_content')

View file

@ -0,0 +1,64 @@
- content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
- content_for :page_title do
= t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
- content_for :heading_actions do
= link_to t('admin.statuses.open'), ActivityPub::TagManager.instance.url_for(@status), class: 'button', target: '_blank'
%h3= t('admin.statuses.metadata')
.table-wrapper
%table.table.horizontal-table
%tbody
%tr
%th= t('admin.statuses.account')
%td= admin_account_link_to @status.account
- if @status.reply?
%tr
%th= t('admin.statuses.in_reply_to')
%td= admin_account_link_to @status.in_reply_to_account, path: admin_account_status_path(@status.thread.account_id, @status.in_reply_to_id)
%tr
%th= t('admin.statuses.application')
%td= @status.application&.name
%tr
%th= t('admin.statuses.language')
%td= standard_locale_name(@status.language)
%tr
%th= t('admin.statuses.visibility')
%td= t("statuses.visibilities.#{@status.visibility}")
- if @status.trend
%tr
%th= t('admin.statuses.trending')
%td
- if @status.trend.allowed?
%abbr{ title: t('admin.trends.tags.current_score', score: @status.trend.score) }= t('admin.trends.tags.trending_rank', rank: @status.trend.rank)
- elsif @status.trend.requires_review?
= t('admin.trends.pending_review')
- else
= t('admin.trends.not_allowed_to_trend')
%tr
%th= t('admin.statuses.reblogs')
%td= friendly_number_to_human @status.reblogs_count
%tr
%th= t('admin.statuses.favourites')
%td= friendly_number_to_human @status.favourites_count
%hr.spacer/
%h3= t('admin.statuses.history')
%ol.history
- @status.edits.includes(:account, status: [:account]).each.with_index do |status_edit, i|
%li
.history__entry
%h5
- if i.zero?
= t('admin.statuses.original_status')
- else
= t('admin.statuses.status_changed')
·
%time.formatted{ datetime: status_edit.created_at.iso8601, title: l(status_edit.created_at) }= l(status_edit.created_at)
= render status_edit

View file

@ -705,16 +705,29 @@ en:
delete: Delete uploaded file delete: Delete uploaded file
destroyed_msg: Site upload successfully deleted! destroyed_msg: Site upload successfully deleted!
statuses: statuses:
account: Author
application: Application
back_to_account: Back to account page back_to_account: Back to account page
back_to_report: Back to report page back_to_report: Back to report page
batch: batch:
remove_from_report: Remove from report remove_from_report: Remove from report
report: Report report: Report
deleted: Deleted deleted: Deleted
favourites: Favourites
history: Version history
in_reply_to: Replying to
language: Language
media: media:
title: Media title: Media
metadata: Metadata
no_status_selected: No posts were changed as none were selected no_status_selected: No posts were changed as none were selected
open: Open post
original_status: Original post
reblogs: Reblogs
status_changed: Post changed
title: Account posts title: Account posts
trending: Trending
visibility: Visibility
with_media: With media with_media: With media
strikes: strikes:
actions: actions:

View file

@ -325,7 +325,7 @@ Rails.application.routes.draw do
resource :reset, only: [:create] resource :reset, only: [:create]
resource :action, only: [:new, :create], controller: 'account_actions' resource :action, only: [:new, :create], controller: 'account_actions'
resources :statuses, only: [:index] do resources :statuses, only: [:index, :show] do
collection do collection do
post :batch post :batch
end end

View file

@ -96,10 +96,6 @@ RSpec.describe StatusPolicy, type: :model do
expect(subject).to permit(status.account, status) expect(subject).to permit(status.account, status)
end end
it 'grants access when account is admin' do
expect(subject).to permit(admin.account, status)
end
it 'denies access when account is not deleter' do it 'denies access when account is not deleter' do
expect(subject).to_not permit(bob, status) expect(subject).to_not permit(bob, status)
end end
@ -125,27 +121,9 @@ RSpec.describe StatusPolicy, type: :model do
end end
end end
permissions :index? do
it 'grants access if staff' do
expect(subject).to permit(admin.account)
end
it 'denies access unless staff' do
expect(subject).to_not permit(alice)
end
end
permissions :update? do permissions :update? do
it 'grants access if staff' do
expect(subject).to permit(admin.account, status)
end
it 'grants access if owner' do it 'grants access if owner' do
expect(subject).to permit(status.account, status) expect(subject).to permit(status.account, status)
end end
it 'denies access unless staff' do
expect(subject).to_not permit(bob, status)
end
end end
end end