Browse Source

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
Eugen Rochko 1 year ago
parent
commit
f8ca3bb2a1

+ 13 - 3
app/controllers/admin/statuses_controller.rb

@@ -3,18 +3,23 @@
 module Admin
   class StatusesController < BaseController
     before_action :set_account
-    before_action :set_statuses
+    before_action :set_statuses, except: :show
+    before_action :set_status, only: :show
 
     PER_PAGE = 20
 
     def index
-      authorize :status, :index?
+      authorize [:admin, :status], :index?
 
       @status_batch_action = Admin::StatusBatchAction.new
     end
 
+    def show
+      authorize [:admin, @status], :show?
+    end
+
     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.save!
@@ -32,6 +37,7 @@ module Admin
 
     def after_create_redirect_path
       report_id = @status_batch_action&.report_id || params[:report_id]
+
       if report_id.present?
         admin_report_path(report_id)
       else
@@ -43,6 +49,10 @@ module Admin
       @account = Account.find(params[:account_id])
     end
 
+    def set_status
+      @status = @account.statuses.find(params[:id])
+    end
+
     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)
     end

+ 2 - 2
app/controllers/admin/trends/statuses_controller.rb

@@ -2,7 +2,7 @@
 
 class Admin::Trends::StatusesController < Admin::BaseController
   def index
-    authorize :status, :review?
+    authorize [:admin, :status], :review?
 
     @locales  = StatusTrend.pluck('distinct language')
     @statuses = filtered_statuses.page(params[:page])
@@ -10,7 +10,7 @@ class Admin::Trends::StatusesController < Admin::BaseController
   end
 
   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.save

+ 1 - 1
app/javascript/mastodon/components/status_action_bar.js

@@ -323,7 +323,7 @@ class StatusActionBar extends ImmutablePureComponent {
       if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         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_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')}` });
       }
     }
 

+ 1 - 1
app/javascript/mastodon/features/status/components/action_bar.js

@@ -254,7 +254,7 @@ class ActionBar extends React.PureComponent {
       if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
         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_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')}` });
       }
     }
 

+ 64 - 0
app/javascript/styles/mastodon/admin.scss

@@ -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;
+    }
+  }
+}

+ 1 - 4
app/models/admin/status_filter.rb

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

+ 11 - 2
app/models/status_edit.rb

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

+ 29 - 0
app/policies/admin/status_policy.rb

@@ -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

+ 2 - 10
app/policies/status_policy.rb

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

+ 8 - 0
app/views/admin/reports/_media_attachments.html.haml

@@ -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 }

+ 2 - 9
app/views/admin/reports/_status.html.haml

@@ -12,14 +12,7 @@
           = prerender_custom_emojis(status_content_format(status.proper), status.proper.emojis)
 
     - unless status.proper.ordered_media_attachments.empty?
-      - if status.proper.ordered_media_attachments.first.video?
-        - 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 }
+      = render partial: 'admin/reports/media_attachments', locals: { status: status.proper }
 
     .detailed-status__meta
       - if status.application
@@ -29,7 +22,7 @@
         %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
       - 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?
         ·
         %span.negative-hint= t('admin.statuses.deleted')

+ 20 - 0
app/views/admin/status_edits/_status_edit.html.haml

@@ -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')

+ 64 - 0
app/views/admin/statuses/show.html.haml

@@ -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

+ 13 - 0
config/locales/en.yml

@@ -705,16 +705,29 @@ en:
       delete: Delete uploaded file
       destroyed_msg: Site upload successfully deleted!
     statuses:
+      account: Author
+      application: Application
       back_to_account: Back to account page
       back_to_report: Back to report page
       batch:
         remove_from_report: Remove from report
         report: Report
       deleted: Deleted
+      favourites: Favourites
+      history: Version history
+      in_reply_to: Replying to
+      language: Language
       media:
         title: Media
+      metadata: Metadata
       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
+      trending: Trending
+      visibility: Visibility
       with_media: With media
     strikes:
       actions:

+ 1 - 1
config/routes.rb

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

+ 0 - 22
spec/policies/status_policy_spec.rb

@@ -96,10 +96,6 @@ RSpec.describe StatusPolicy, type: :model do
       expect(subject).to permit(status.account, status)
     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
       expect(subject).to_not permit(bob, status)
     end
@@ -125,27 +121,9 @@ RSpec.describe StatusPolicy, type: :model do
     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
-    it 'grants access if staff' do
-      expect(subject).to permit(admin.account, status)
-    end
-
     it 'grants access if owner' do
       expect(subject).to permit(status.account, status)
     end
-
-    it 'denies access unless staff' do
-      expect(subject).to_not permit(bob, status)
-    end
   end
 end