Add notifications for statuses deleted by moderators (#17204)

This commit is contained in:
Eugen Rochko 2022-01-17 09:41:33 +01:00 committed by GitHub
parent d5c9feb7b7
commit 14f436c457
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1220 additions and 598 deletions

View File

@ -14,7 +14,7 @@ module Admin
else else
@account = @account_moderation_note.target_account @account = @account_moderation_note.target_account
@moderation_notes = @account.targeted_moderation_notes.latest @moderation_notes = @account.targeted_moderation_notes.latest
@warnings = @account.targeted_account_warnings.latest.custom @warnings = @account.strikes.custom.latest
render template: 'admin/accounts/show' render template: 'admin/accounts/show'
end end

View File

@ -28,7 +28,7 @@ module Admin
@deletion_request = @account.deletion_request @deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest @moderation_notes = @account.targeted_moderation_notes.latest
@warnings = @account.targeted_account_warnings.latest.custom @warnings = @account.strikes.custom.latest
@domain_block = DomainBlock.rule_for(@account.domain) @domain_block = DomainBlock.rule_for(@account.domain)
end end

View File

@ -14,20 +14,17 @@ module Admin
if params[:create_and_resolve] if params[:create_and_resolve]
@report.resolve!(current_account) @report.resolve!(current_account)
log_action :resolve, @report log_action :resolve, @report
elsif params[:create_and_unresolve]
redirect_to admin_reports_path, notice: I18n.t('admin.reports.resolved_msg')
return
end
if params[:create_and_unresolve]
@report.unresolve! @report.unresolve!
log_action :reopen, @report log_action :reopen, @report
end end
redirect_to admin_report_path(@report), notice: I18n.t('admin.report_notes.created_msg') redirect_to after_create_redirect_path, notice: I18n.t('admin.report_notes.created_msg')
else else
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) @report_notes = @report.notes.includes(:account).order(id: :desc)
@form = Form::StatusBatch.new @action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes
render template: 'admin/reports/show' render template: 'admin/reports/show'
end end
@ -41,6 +38,14 @@ module Admin
private private
def after_create_redirect_path
if params[:create_and_resolve]
admin_reports_path
else
admin_report_path(@report)
end
end
def resource_params def resource_params
params.require(:report_note).permit( params.require(:report_note).permit(
:content, :content,

View File

@ -1,44 +0,0 @@
# frozen_string_literal: true
module Admin
class ReportedStatusesController < BaseController
before_action :set_report
def create
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_report_path(@report)
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected')
redirect_to admin_report_path(@report)
end
private
def status_params
params.require(:status).permit(:sensitive)
end
def form_status_batch_params
params.require(:form_status_batch).permit(status_ids: [])
end
def action_from_button
if params[:nsfw_on]
'nsfw_on'
elsif params[:nsfw_off]
'nsfw_off'
elsif params[:delete]
'delete'
end
end
def set_report
@report = Report.find(params[:report_id])
end
end
end

View File

@ -13,8 +13,10 @@ module Admin
authorize @report, :show? authorize @report, :show?
@report_note = @report.notes.new @report_note = @report.notes.new
@report_notes = (@report.notes.latest + @report.history + @report.target_account.targeted_account_warnings.latest.custom).sort_by(&:created_at) @report_notes = @report.notes.includes(:account).order(id: :desc)
@form = Form::StatusBatch.new @action_logs = @report.history.includes(:target)
@form = Admin::StatusBatchAction.new
@statuses = @report.statuses.with_includes
end end
def assign_to_self def assign_to_self

View File

@ -2,71 +2,57 @@
module Admin module Admin
class StatusesController < BaseController class StatusesController < BaseController
helper_method :current_params
before_action :set_account before_action :set_account
before_action :set_statuses
PER_PAGE = 20 PER_PAGE = 20
def index def index
authorize :status, :index? authorize :status, :index?
@statuses = @account.statuses.where(visibility: [:public, :unlisted]) @status_batch_action = Admin::StatusBatchAction.new
if params[:media]
@statuses = @statuses.merge(Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)).reorder('statuses.id desc')
end
@statuses = @statuses.preload(:media_attachments, :mentions).page(params[:page]).per(PER_PAGE)
@form = Form::StatusBatch.new
end end
def show def batch
authorize :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!
@statuses = @account.statuses.where(id: params[:id])
authorize @statuses.first, :show?
@form = Form::StatusBatch.new
end
def create
authorize :status, :update?
@form = Form::StatusBatch.new(form_status_batch_params.merge(current_account: current_account, action: action_from_button))
flash[:alert] = I18n.t('admin.statuses.failed_to_execute') unless @form.save
redirect_to admin_account_statuses_path(@account.id, current_params)
rescue ActionController::ParameterMissing rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.statuses.no_status_selected') flash[:alert] = I18n.t('admin.statuses.no_status_selected')
ensure
redirect_to admin_account_statuses_path(@account.id, current_params) redirect_to after_create_redirect_path
end end
private private
def form_status_batch_params def admin_status_batch_action_params
params.require(:form_status_batch).permit(:action, status_ids: []) params.require(:admin_status_batch_action).permit(status_ids: [])
end
def after_create_redirect_path
if @status_batch_action.report_id.present?
admin_report_path(@status_batch_action.report_id)
else
admin_account_statuses_path(params[:account_id], current_params)
end
end end
def set_account def set_account
@account = Account.find(params[:account_id]) @account = Account.find(params[:account_id])
end end
def current_params def set_statuses
page = (params[:page] || 1).to_i @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
{ def filter_params
media: params[:media], params.slice(*Admin::StatusFilter::KEYS).permit(*Admin::StatusFilter::KEYS)
page: page > 1 && page,
}.select { |_, value| value.present? }
end end
def action_from_button def action_from_button
if params[:nsfw_on] if params[:report]
'nsfw_on' 'report'
elsif params[:nsfw_off] elsif params[:remove_from_report]
'nsfw_off' 'remove_from_report'
elsif params[:delete] elsif params[:delete]
'delete' 'delete'
end end

View File

@ -1,7 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Admin::AccountActionsController < Api::BaseController class Api::V1::Admin::AccountActionsController < Api::BaseController
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' } protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }
before_action :require_staff! before_action :require_staff!
before_action :set_account before_action :set_account

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Admin::AccountsController < Api::BaseController class Api::V1::Admin::AccountsController < Api::BaseController
protect_from_forgery with: :exception
include Authorization include Authorization
include AccountableConcern include AccountableConcern
LIMIT = 100 LIMIT = 100
before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:accounts' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:accounts' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:accounts' }, except: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:accounts' }, except: [:index, :show]
before_action :require_staff! before_action :require_staff!
before_action :set_accounts, only: :index before_action :set_accounts, only: :index
before_action :set_account, except: :index before_action :set_account, except: :index

View File

@ -3,6 +3,7 @@
class Api::V1::Admin::DimensionsController < Api::BaseController class Api::V1::Admin::DimensionsController < Api::BaseController
protect_from_forgery with: :exception protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff! before_action :require_staff!
before_action :set_dimensions before_action :set_dimensions

View File

@ -3,6 +3,7 @@
class Api::V1::Admin::MeasuresController < Api::BaseController class Api::V1::Admin::MeasuresController < Api::BaseController
protect_from_forgery with: :exception protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff! before_action :require_staff!
before_action :set_measures before_action :set_measures

View File

@ -1,13 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Admin::ReportsController < Api::BaseController class Api::V1::Admin::ReportsController < Api::BaseController
protect_from_forgery with: :exception
include Authorization include Authorization
include AccountableConcern include AccountableConcern
LIMIT = 100 LIMIT = 100
before_action -> { doorkeeper_authorize! :'admin:read', :'admin:read:reports' }, only: [:index, :show] before_action -> { authorize_if_got_token! :'admin:read', :'admin:read:reports' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :'admin:write', :'admin:write:reports' }, except: [:index, :show] before_action -> { authorize_if_got_token! :'admin:write', :'admin:write:reports' }, except: [:index, :show]
before_action :require_staff! before_action :require_staff!
before_action :set_reports, only: :index before_action :set_reports, only: :index
before_action :set_report, except: :index before_action :set_report, except: :index
@ -32,6 +34,12 @@ class Api::V1::Admin::ReportsController < Api::BaseController
render json: @report, serializer: REST::Admin::ReportSerializer render json: @report, serializer: REST::Admin::ReportSerializer
end end
def update
authorize @report, :update?
@report.update!(report_params)
render json: @report, serializer: REST::Admin::ReportSerializer
end
def assign_to_self def assign_to_self
authorize @report, :update? authorize @report, :update?
@report.update!(assigned_account_id: current_account.id) @report.update!(assigned_account_id: current_account.id)
@ -74,6 +82,10 @@ class Api::V1::Admin::ReportsController < Api::BaseController
ReportFilter.new(filter_params).results ReportFilter.new(filter_params).results
end end
def report_params
params.permit(:category, rule_ids: [])
end
def filter_params def filter_params
params.permit(*FILTER_PARAMS) params.permit(*FILTER_PARAMS)
end end

View File

@ -3,6 +3,7 @@
class Api::V1::Admin::RetentionController < Api::BaseController class Api::V1::Admin::RetentionController < Api::BaseController
protect_from_forgery with: :exception protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff! before_action :require_staff!
before_action :set_cohorts before_action :set_cohorts

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Admin::Trends::TagsController < Api::BaseController class Api::V1::Admin::Trends::TagsController < Api::BaseController
protect_from_forgery with: :exception
before_action -> { authorize_if_got_token! :'admin:read' }
before_action :require_staff! before_action :require_staff!
before_action :set_tags before_action :set_tags

View File

@ -13,6 +13,7 @@ module Admin::FilterHelper
RelationshipFilter::KEYS, RelationshipFilter::KEYS,
AnnouncementFilter::KEYS, AnnouncementFilter::KEYS,
Admin::ActionLogFilter::KEYS, Admin::ActionLogFilter::KEYS,
Admin::StatusFilter::KEYS,
].flatten.freeze ].flatten.freeze
def filter_link_to(text, link_to_params, link_class_params = link_to_params) def filter_link_to(text, link_to_params, link_class_params = link_to_params)

View File

@ -0,0 +1,159 @@
import React from 'react';
import PropTypes from 'prop-types';
import api from 'mastodon/api';
import { injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
const messages = defineMessages({
other: { id: 'report.categories.other', defaultMessage: 'Other' },
spam: { id: 'report.categories.spam', defaultMessage: 'Spam' },
violation: { id: 'report.categories.violation', defaultMessage: 'Content violates one or more server rules' },
});
class Category extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
selected: PropTypes.bool,
disabled: PropTypes.bool,
onSelect: PropTypes.func,
children: PropTypes.node,
};
handleClick = () => {
const { id, disabled, onSelect } = this.props;
if (!disabled) {
onSelect(id);
}
};
render () {
const { id, text, disabled, selected, children } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__category', { selected, disabled })} onClick={this.handleClick}>
{selected && <input type='hidden' name='report[category]' value={id} />}
<div className='report-reason-selector__category__label'>
<span className={classNames('poll__input', { active: selected, disabled })} />
{text}
</div>
{(selected && children) && (
<div className='report-reason-selector__category__rules'>
{children}
</div>
)}
</div>
);
}
}
class Rule extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
selected: PropTypes.bool,
disabled: PropTypes.bool,
onToggle: PropTypes.func,
};
handleClick = () => {
const { id, disabled, onToggle } = this.props;
if (!disabled) {
onToggle(id);
}
};
render () {
const { id, text, disabled, selected } = this.props;
return (
<div tabIndex='0' role='button' className={classNames('report-reason-selector__rule', { selected, disabled })} onClick={this.handleClick}>
<span className={classNames('poll__input', { checkbox: true, active: selected, disabled })} />
{selected && <input type='hidden' name='report[rule_ids][]' value={id} />}
{text}
</div>
);
}
}
export default @injectIntl
class ReportReasonSelector extends React.PureComponent {
static propTypes = {
id: PropTypes.string.isRequired,
category: PropTypes.string.isRequired,
rule_ids: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
category: this.props.category,
rule_ids: this.props.rule_ids || [],
rules: [],
};
componentDidMount() {
api().get('/api/v1/instance').then(res => {
this.setState({
rules: res.data.rules,
});
}).catch(err => {
console.error(err);
});
}
_save = () => {
const { id, disabled } = this.props;
const { category, rule_ids } = this.state;
if (disabled) {
return;
}
api().put(`/api/v1/admin/reports/${id}`, {
category,
rule_ids,
}).catch(err => {
console.error(err);
});
};
handleSelect = id => {
this.setState({ category: id }, () => this._save());
};
handleToggle = id => {
const { rule_ids } = this.state;
if (rule_ids.includes(id)) {
this.setState({ rule_ids: rule_ids.filter(x => x !== id ) }, () => this._save());
} else {
this.setState({ rule_ids: [...rule_ids, id] }, () => this._save());
}
};
render () {
const { disabled, intl } = this.props;
const { rules, category, rule_ids } = this.state;
return (
<div className='report-reason-selector'>
<Category id='other' text={intl.formatMessage(messages.other)} selected={category === 'other'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='spam' text={intl.formatMessage(messages.spam)} selected={category === 'spam'} onSelect={this.handleSelect} disabled={disabled} />
<Category id='violation' text={intl.formatMessage(messages.violation)} selected={category === 'violation'} onSelect={this.handleSelect} disabled={disabled}>
{rules.map(rule => <Rule key={rule.id} id={rule.id} text={rule.text} selected={rule_ids.includes(rule.id)} onToggle={this.handleToggle} disabled={disabled} />)}
</Category>
</div>
);
}
}

View File

@ -291,7 +291,7 @@ class StatusActionBar extends ImmutablePureComponent {
if (isStaff) { if (isStaff) {
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/${status.get('id')}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
} }
} }

View File

@ -245,7 +245,7 @@ class ActionBar extends React.PureComponent {
if (isStaff) { if (isStaff) {
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/${status.get('id')}` }); menu.push({ text: intl.formatMessage(messages.admin_status), href: `/admin/accounts/${status.getIn(['account', 'id'])}/statuses?id=${status.get('id')}` });
} }
} }

View File

@ -533,6 +533,10 @@ ul {
} }
} }
ul.rules-list {
padding-top: 0;
}
@media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) { @media only screen and (min-device-width: 768px) and (max-device-width: 1024px) and (orientation: landscape) {
body { body {
min-height: 1024px !important; min-height: 1024px !important;

View File

@ -579,39 +579,44 @@ body,
.log-entry { .log-entry {
line-height: 20px; line-height: 20px;
padding: 15px 0; padding: 15px;
padding-left: 15px * 2 + 40px;
background: $ui-base-color; background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 4%); border-bottom: 1px solid darken($ui-base-color, 8%);
position: relative;
&:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
&:last-child { &:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: 0; border-bottom: 0;
} }
&:hover {
background: lighten($ui-base-color, 4%);
}
&__header { &__header {
display: flex;
justify-content: flex-start;
align-items: center;
color: $darker-text-color; color: $darker-text-color;
font-size: 14px; font-size: 14px;
padding: 0 10px;
} }
&__avatar { &__avatar {
margin-right: 10px; position: absolute;
left: 15px;
top: 15px;
.avatar { .avatar {
display: block; border-radius: 4px;
margin: 0;
border-radius: 50%;
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
} }
&__content {
max-width: calc(100% - 90px);
}
&__title { &__title {
word-wrap: break-word; word-wrap: break-word;
} }
@ -627,6 +632,14 @@ body,
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
} }
a {
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
} }
a.name-tag, a.name-tag,
@ -655,8 +668,9 @@ a.inline-name-tag,
a.name-tag, a.name-tag,
.name-tag { .name-tag {
display: flex; display: inline-flex;
align-items: center; align-items: center;
vertical-align: top;
.avatar { .avatar {
display: block; display: block;
@ -1114,3 +1128,287 @@ a.sparkline {
} }
} }
} }
.report-reason-selector {
border-radius: 4px;
background: $ui-base-color;
margin-bottom: 20px;
&__category {
cursor: pointer;
border-bottom: 1px solid darken($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
&__label {
padding: 15px;
}
&__rules {
margin-left: 30px;
}
}
&__rule {
cursor: pointer;
padding: 15px;
}
}
.report-header {
display: grid;
grid-gap: 15px;
grid-template-columns: minmax(0, 1fr) 300px;
&__details {
&__item {
border-bottom: 1px solid lighten($ui-base-color, 8%);
padding: 15px 0;
&:last-child {
border-bottom: 0;
}
&__header {
font-weight: 600;
padding: 4px 0;
}
}
&--horizontal {
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
.report-header__details__item {
border-bottom: 0;
}
}
}
}
.account-card {
background: $ui-base-color;
border-radius: 4px;
&__header {
padding: 4px;
border-radius: 4px;
height: 128px;
img {
display: block;
margin: 0;
width: 100%;
height: 100%;
object-fit: cover;
background: darken($ui-base-color, 8%);
}
}
&__title {
margin-top: -25px;
display: flex;
align-items: flex-end;
&__avatar {
padding: 15px;
img {
display: block;
margin: 0;
width: 56px;
height: 56px;
background: darken($ui-base-color, 8%);
border-radius: 8px;
}
}
.display-name {
color: $darker-text-color;
padding-bottom: 15px;
font-size: 15px;
bdi {
display: block;
color: $primary-text-color;
font-weight: 500;
}
}
}
&__bio {
padding: 0 15px;
overflow: hidden;
text-overflow: ellipsis;
word-wrap: break-word;
max-height: 18px * 2;
position: relative;
&::after {
display: block;
content: "";
width: 50px;
height: 18px;
position: absolute;
bottom: 0;
right: 15px;
background: linear-gradient(to left, $ui-base-color, transparent);
pointer-events: none;
}
}
&__actions {
display: flex;
align-items: center;
padding-top: 10px;
&__button {
flex: 0 0 auto;
padding: 0 15px;
}
}
&__counters {
flex: 1 1 auto;
display: grid;
grid-auto-columns: minmax(0, 1fr);
grid-auto-flow: column;
&__item {
padding: 15px;
text-align: center;
color: $primary-text-color;
font-weight: 600;
font-size: 15px;
small {
display: block;
color: $darker-text-color;
font-weight: 400;
font-size: 13px;
}
}
}
}
.report-notes {
margin-bottom: 20px;
&__item {
background: $ui-base-color;
position: relative;
padding: 15px;
padding-left: 15px * 2 + 40px;
border-bottom: 1px solid darken($ui-base-color, 8%);
&:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
&:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: 0;
}
&:hover {
background-color: lighten($ui-base-color, 4%);
}
&__avatar {
position: absolute;
left: 15px;
top: 15px;
border-radius: 4px;
width: 40px;
height: 40px;
}
&__header {
color: $darker-text-color;
font-size: 15px;
line-height: 20px;
margin-bottom: 4px;
.username a {
color: $primary-text-color;
font-weight: 500;
text-decoration: none;
margin-right: 5px;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
time {
margin-left: 5px;
vertical-align: baseline;
}
}
&__content {
font-size: 15px;
line-height: 20px;
word-wrap: break-word;
font-weight: 400;
color: $primary-text-color;
p {
margin-bottom: 20px;
white-space: pre-wrap;
unicode-bidi: plaintext;
&:last-child {
margin-bottom: 0;
}
}
}
&__actions {
position: absolute;
top: 15px;
right: 15px;
text-align: right;
}
}
}
.report-actions {
border: 1px solid darken($ui-base-color, 8%);
&__item {
display: flex;
align-items: center;
line-height: 18px;
border-bottom: 1px solid darken($ui-base-color, 8%);
&:last-child {
border-bottom: 0;
}
&__button {
flex: 0 0 auto;
width: 100px;
padding: 15px;
padding-right: 0;
.button {
display: block;
width: 100%;
}
}
&__description {
padding: 15px;
font-size: 14px;
color: $dark-text-color;
}
}
}

View File

@ -143,6 +143,21 @@
&:active { &:active {
outline: 0 !important; outline: 0 !important;
} }
&.disabled {
border-color: $dark-text-color;
&.active {
background: $dark-text-color;
}
&:active,
&:focus,
&:hover {
border-color: $dark-text-color;
border-width: 1px;
}
}
} }
&__number { &__number {

View File

@ -6,11 +6,11 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
end end
def total def total
Report.resolved.where(updated_at: time_period).count Report.resolved.where(action_taken_at: time_period).count
end end
def previous_total def previous_total
Report.resolved.where(updated_at: previous_time_period).count Report.resolved.where(action_taken_at: previous_time_period).count
end end
def data def data
@ -19,8 +19,7 @@ class Admin::Metrics::Measure::ResolvedReportsMeasure < Admin::Metrics::Measure:
WITH resolved_reports AS ( WITH resolved_reports AS (
SELECT reports.id SELECT reports.id
FROM reports FROM reports
WHERE action_taken WHERE date_trunc('day', reports.action_taken_at)::date = axis.period
AND date_trunc('day', reports.updated_at)::date = axis.period
) )
SELECT count(*) FROM resolved_reports SELECT count(*) FROM resolved_reports
) AS value ) AS value

View File

@ -160,11 +160,11 @@ class UserMailer < Devise::Mailer
end end
end end
def warning(user, warning, status_ids = nil) def warning(user, warning)
@resource = user @resource = user
@warning = warning @warning = warning
@instance = Rails.configuration.x.local_domain @instance = Rails.configuration.x.local_domain
@statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array) @statuses = @warning.statuses.includes(:account, :preloadable_poll, :media_attachments, active_mentions: [:account])
I18n.with_locale(@resource.locale || I18n.default_locale) do I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, mail to: @resource.email,

View File

@ -10,14 +10,30 @@
# text :text default(""), not null # text :text default(""), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# report_id :bigint(8)
# status_ids :string is an Array
# #
class AccountWarning < ApplicationRecord class AccountWarning < ApplicationRecord
enum action: %i(none disable sensitive silence suspend), _suffix: :action enum action: {
none: 0,
disable: 1_000,
delete_statuses: 1_500,
sensitive: 2_000,
silence: 3_000,
suspend: 4_000,
}, _suffix: :action
belongs_to :account, inverse_of: :account_warnings belongs_to :account, inverse_of: :account_warnings
belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings belongs_to :target_account, class_name: 'Account', inverse_of: :strikes
belongs_to :report, optional: true
scope :latest, -> { order(created_at: :desc) } has_one :appeal, dependent: :destroy
scope :latest, -> { order(id: :desc) }
scope :custom, -> { where.not(text: '') } scope :custom, -> { where.not(text: '') }
def statuses
Status.with_discarded.where(id: status_ids || [])
end
end end

View File

@ -33,7 +33,7 @@ class Admin::AccountAction
def save! def save!
ApplicationRecord.transaction do ApplicationRecord.transaction do
process_action! process_action!
process_warning! process_strike!
end end
process_email! process_email!
@ -74,20 +74,14 @@ class Admin::AccountAction
end end
end end
def process_warning! def process_strike!
return unless warnable? @warning = target_account.strikes.create!(
account: current_account,
authorize(target_account, :warn?) report: report,
action: type,
@warning = AccountWarning.create!(target_account: target_account, text: text_for_warning,
account: current_account, status_ids: status_ids
action: type, )
text: text_for_warning)
# A log entry is only interesting if the warning contains
# custom text from someone. Otherwise it's just noise.
log_action(:create, warning) if warning.text.present?
end end
def process_reports! def process_reports!
@ -143,7 +137,7 @@ class Admin::AccountAction
end end
def process_email! def process_email!
UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable? UserMailer.warning(target_account.user, warning).deliver_later! if warnable?
end end
def warnable? def warnable?
@ -151,7 +145,7 @@ class Admin::AccountAction
end end
def status_ids def status_ids
report.status_ids if report && include_statuses report.status_ids if with_report? && include_statuses
end end
def reports def reports

View File

@ -0,0 +1,92 @@
# frozen_string_literal: true
class Admin::StatusBatchAction
include ActiveModel::Model
include AccountableConcern
include Authorization
attr_accessor :current_account, :type,
:status_ids, :report_id
def save!
process_action!
end
private
def statuses
Status.with_discarded.where(id: status_ids)
end
def process_action!
return if status_ids.empty?
case type
when 'delete'
handle_delete!
when 'report'
handle_report!
when 'remove_from_report'
handle_remove_from_report!
end
end
def handle_delete!
statuses.each { |status| authorize(status, :destroy?) }
ApplicationRecord.transaction do
statuses.each do |status|
status.discard
log_action(:destroy, status)
end
if with_report?
report.resolve!(current_account)
log_action(:resolve, report)
end
@warning = target_account.strikes.create!(
action: :delete_statuses,
account: current_account,
report: report,
status_ids: status_ids
)
statuses.each { |status| Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) } unless target_account.local?
end
UserMailer.warning(target_account.user, @warning).deliver_later! if target_account.local?
RemovalWorker.push_bulk(status_ids) { |status_id| [status_id, preserve: target_account.local?, immediate: !target_account.local?] }
end
def handle_report!
@report = Report.new(report_params) unless with_report?
@report.status_ids = (@report.status_ids + status_ids.map(&:to_i)).uniq
@report.save!
@report_id = @report.id
end
def handle_remove_from_report!
return unless with_report?
report.status_ids -= status_ids.map(&:to_i)
report.save!
end
def report
@report ||= Report.find(report_id) if report_id.present?
end
def with_report?
!report.nil?
end
def target_account
@target_account ||= statuses.first.account
end
def report_params
{ account: current_account, target_account: target_account }
end
end

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
class Admin::StatusFilter
KEYS = %i(
media
id
report_id
).freeze
attr_reader :params
def initialize(account, params)
@account = account
@params = params
end
def results
scope = @account.statuses.where(visibility: [:public, :unlisted])
params.each do |key, value|
next if %w(page report_id).include?(key.to_s)
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
end
scope
end
private
def scope_for(key, value)
case key.to_s
when 'media'
Status.joins(:media_attachments).merge(@account.media_attachments.reorder(nil)).group(:id)
when 'id'
Status.where(id: value)
else
raise "Unknown filter: #{key}"
end
end
end

View File

@ -42,7 +42,7 @@ module AccountAssociations
has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account has_many :account_moderation_notes, dependent: :destroy, inverse_of: :account
has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
has_many :account_warnings, dependent: :destroy, inverse_of: :account has_many :account_warnings, dependent: :destroy, inverse_of: :account
has_many :targeted_account_warnings, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account has_many :strikes, class_name: 'AccountWarning', foreign_key: :target_account_id, dependent: :destroy, inverse_of: :target_account
# Lists (that the account is on, not owned by the account) # Lists (that the account is on, not owned by the account)
has_many :list_accounts, inverse_of: :account, dependent: :destroy has_many :list_accounts, inverse_of: :account, dependent: :destroy

View File

@ -1,45 +0,0 @@
# frozen_string_literal: true
class Form::StatusBatch
include ActiveModel::Model
include AccountableConcern
attr_accessor :status_ids, :action, :current_account
def save
case action
when 'nsfw_on', 'nsfw_off'
change_sensitive(action == 'nsfw_on')
when 'delete'
delete_statuses
end
end
private
def change_sensitive(sensitive)
media_attached_status_ids = MediaAttachment.where(status_id: status_ids).pluck(:status_id)
ApplicationRecord.transaction do
Status.where(id: media_attached_status_ids).reorder(nil).find_each do |status|
status.update!(sensitive: sensitive)
log_action :update, status
end
end
true
rescue ActiveRecord::RecordInvalid
false
end
def delete_statuses
Status.where(id: status_ids).reorder(nil).find_each do |status|
status.discard
RemovalWorker.perform_async(status.id, immediate: true)
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
log_action :destroy, status
end
true
end
end

View File

@ -6,7 +6,6 @@
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# status_ids :bigint(8) default([]), not null, is an Array # status_ids :bigint(8) default([]), not null, is an Array
# comment :text default(""), not null # comment :text default(""), not null
# action_taken :boolean default(FALSE), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# account_id :bigint(8) not null # account_id :bigint(8) not null
@ -15,9 +14,14 @@
# assigned_account_id :bigint(8) # assigned_account_id :bigint(8)
# uri :string # uri :string
# forwarded :boolean # forwarded :boolean
# category :integer default("other"), not null
# action_taken_at :datetime
# rule_ids :bigint(8) is an Array
# #
class Report < ApplicationRecord class Report < ApplicationRecord
self.ignored_columns = %w(action_taken)
include Paginable include Paginable
include RateLimitable include RateLimitable
@ -30,11 +34,17 @@ class Report < ApplicationRecord
has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy has_many :notes, class_name: 'ReportNote', foreign_key: :report_id, inverse_of: :report, dependent: :destroy
scope :unresolved, -> { where(action_taken: false) } scope :unresolved, -> { where(action_taken_at: nil) }
scope :resolved, -> { where(action_taken: true) } scope :resolved, -> { where.not(action_taken_at: nil) }
scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) } scope :with_accounts, -> { includes([:account, :target_account, :action_taken_by_account, :assigned_account].index_with({ user: [:invite_request, :invite] })) }
validates :comment, length: { maximum: 1000 } validates :comment, length: { maximum: 1_000 }
enum category: {
other: 0,
spam: 1_000,
violation: 2_000,
}
def local? def local?
false # Force uri_for to use uri attribute false # Force uri_for to use uri attribute
@ -47,13 +57,17 @@ class Report < ApplicationRecord
end end
def statuses def statuses
Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions) Status.with_discarded.where(id: status_ids)
end end
def media_attachments def media_attachments
MediaAttachment.where(status_id: status_ids) MediaAttachment.where(status_id: status_ids)
end end
def rules
Rule.with_discarded.where(id: rule_ids)
end
def assign_to_self!(current_account) def assign_to_self!(current_account)
update!(assigned_account_id: current_account.id) update!(assigned_account_id: current_account.id)
end end
@ -63,22 +77,19 @@ class Report < ApplicationRecord
end end
def resolve!(acting_account) def resolve!(acting_account)
if account_id == -99 && target_account.trust_level == Account::TRUST_LEVELS[:untrusted] update!(action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id)
# This is an automated report and it is being dismissed, so it's
# a false positive, in which case update the account's trust level
# to prevent further spam checks
target_account.update(trust_level: Account::TRUST_LEVELS[:trusted])
end
RemovalWorker.push_bulk(Status.with_discarded.discarded.where(id: status_ids).pluck(:id)) { |status_id| [status_id, { immediate: true }] }
update!(action_taken: true, action_taken_by_account_id: acting_account.id)
end end
def unresolve! def unresolve!
update!(action_taken: false, action_taken_by_account_id: nil) update!(action_taken_at: nil, action_taken_by_account_id: nil)
end end
def action_taken?
action_taken_at.present?
end
alias action_taken action_taken?
def unresolved? def unresolved?
!action_taken? !action_taken?
end end
@ -88,29 +99,24 @@ class Report < ApplicationRecord
end end
def history def history
time_range = created_at..updated_at subquery = [
sql = [
Admin::ActionLog.where( Admin::ActionLog.where(
target_type: 'Report', target_type: 'Report',
target_id: id, target_id: id
created_at: time_range ).unscope(:order).arel,
).unscope(:order),
Admin::ActionLog.where( Admin::ActionLog.where(
target_type: 'Account', target_type: 'Account',
target_id: target_account_id, target_id: target_account_id
created_at: time_range ).unscope(:order).arel,
).unscope(:order),
Admin::ActionLog.where( Admin::ActionLog.where(
target_type: 'Status', target_type: 'Status',
target_id: status_ids, target_id: status_ids
created_at: time_range ).unscope(:order).arel,
).unscope(:order), ].reduce { |union, query| Arel::Nodes::UnionAll.new(union, query) }
].map { |query| "(#{query.to_sql})" }.join(' UNION ALL ')
Admin::ActionLog.from("(#{sql}) AS admin_action_logs") Admin::ActionLog.from(Arel::Nodes::As.new(subquery, Admin::ActionLog.arel_table))
end end
def set_uri def set_uri

View File

@ -19,7 +19,7 @@ class ReportFilter
scope = Report.unresolved scope = Report.unresolved
params.each do |key, value| params.each do |key, value|
scope = scope.merge scope_for(key, value) scope = scope.merge scope_for(key, value), rewhere: true
end end
scope scope

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::Admin::ReportSerializer < ActiveModel::Serializer class REST::Admin::ReportSerializer < ActiveModel::Serializer
attributes :id, :action_taken, :comment, :created_at, :updated_at attributes :id, :action_taken, :category, :comment, :created_at, :updated_at
has_one :account, serializer: REST::Admin::AccountSerializer has_one :account, serializer: REST::Admin::AccountSerializer
has_one :target_account, serializer: REST::Admin::AccountSerializer has_one :target_account, serializer: REST::Admin::AccountSerializer
@ -9,8 +9,13 @@ class REST::Admin::ReportSerializer < ActiveModel::Serializer
has_one :action_taken_by_account, serializer: REST::Admin::AccountSerializer has_one :action_taken_by_account, serializer: REST::Admin::AccountSerializer
has_many :statuses, serializer: REST::StatusSerializer has_many :statuses, serializer: REST::StatusSerializer
has_many :rules, serializer: REST::RuleSerializer
def id def id
object.id.to_s object.id.to_s
end end
def statuses
object.statuses.with_includes
end
end end

View File

@ -9,6 +9,7 @@ class RemoveStatusService < BaseService
# @param [Hash] options # @param [Hash] options
# @option [Boolean] :redraft # @option [Boolean] :redraft
# @option [Boolean] :immediate # @option [Boolean] :immediate
# @option [Boolean] :preserve
# @option [Boolean] :original_removed # @option [Boolean] :original_removed
def call(status, **options) def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s) @payload = Oj.dump(event: :delete, payload: status.id.to_s)
@ -43,7 +44,7 @@ class RemoveStatusService < BaseService
remove_media remove_media
end end
@status.destroy! if @options[:immediate] || !@status.reported? @status.destroy! if permanently?
else else
raise Mastodon::RaceConditionError raise Mastodon::RaceConditionError
end end
@ -135,11 +136,15 @@ class RemoveStatusService < BaseService
end end
def remove_media def remove_media
return if @options[:redraft] || (!@options[:immediate] && @status.reported?) return if @options[:redraft] || !permanently?
@status.media_attachments.destroy_all @status.media_attachments.destroy_all
end end
def permanently?
@options[:immediate] || !(@options[:preserve] || @status.reported?)
end
def lock_options def lock_options
{ redis: Redis.current, key: "distribute:#{@status.id}", autorelease: 5.minutes.seconds } { redis: Redis.current, key: "distribute:#{@status.id}", autorelease: 5.minutes.seconds }
end end

View File

@ -22,7 +22,7 @@
%div.muted-hint.center-text %div.muted-hint.center-text
= t 'admin.action_logs.empty' = t 'admin.action_logs.empty'
- else - else
.announcements-list .report-notes
= render partial: 'action_log', collection: @action_logs = render partial: 'action_log', collection: @action_logs
= paginate @action_logs = paginate @action_logs

View File

@ -1,7 +1,18 @@
.speech-bubble .report-notes__item
.speech-bubble__bubble = image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar'
.report-notes__item__header
%span.username
= link_to display_name(report_note.account), admin_account_path(report_note.account_id)
%time{ datetime: report_note.created_at.iso8601, title: l(report_note.created_at) }
- if report_note.created_at.today?
= t('admin.report_notes.today_at', time: l(report_note.created_at, format: :time))
- else
= l report_note.created_at.to_date
.report-notes__item__content
= simple_format(h(report_note.content)) = simple_format(h(report_note.content))
.speech-bubble__owner
= admin_account_link_to report_note.account - if can?(:destroy, report_note)
%time.formatted{ datetime: report_note.created_at.iso8601 }= l report_note.created_at .report-notes__item__actions
= table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete if can?(:destroy, report_note) = table_link_to 'trash', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete

View File

@ -1,6 +0,0 @@
.speech-bubble.positive
.speech-bubble__bubble
= t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}_html", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target'))
.speech-bubble__owner
= admin_account_link_to(action_log.account)
%time.formatted{ datetime: action_log.created_at.iso8601 }= l action_log.created_at

View File

@ -22,6 +22,9 @@
= react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } = react_component :media_gallery, height: 343, sensitive: status.proper.sensitive?, visible: false, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
.detailed-status__meta .detailed-status__meta
- if status.application
= status.application.name
·
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener noreferrer' do
%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.discarded? - if status.discarded?

View File

@ -1,5 +1,6 @@
- content_for :header_tags do - content_for :header_tags do
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous' = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
= javascript_pack_tag 'public', async: true, crossorigin: 'anonymous'
- content_for :page_title do - content_for :page_title do
= t('admin.reports.report', id: @report.id) = t('admin.reports.report', id: @report.id)
@ -10,122 +11,199 @@
- else - else
= link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button' = link_to t('admin.reports.mark_as_unresolved'), reopen_admin_report_path(@report), method: :post, class: 'button'
.table-wrapper .report-header
%table.table.inline-table .report-header__card
%tbody .account-card
%tr .account-card__header
%th= t('admin.reports.reported_account') = image_tag @report.target_account.header.url, alt: ''
%td= admin_account_link_to @report.target_account .account-card__title
%td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.target_account.targeted_reports.count), admin_reports_path(target_account_id: @report.target_account.id) .account-card__title__avatar
%td= table_link_to 'file', t('admin.reports.account.notes', count: @report.target_account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.target_account.id) = image_tag @report.target_account.avatar.url, alt: ''
%tr .display-name
%th= t('admin.reports.reported_by') %bdi
%strong.emojify.p-name= display_name(@report.target_account, custom_emojify: true)
%span
= acct(@report.target_account)
= fa_icon('lock') if @report.target_account.locked?
- if @report.target_account.note.present?
.account-card__bio.emojify
= Formatter.instance.simplified_format(@report.target_account, custom_emojify: true)
.account-card__actions
.account-card__counters
.account-card__counters__item
= friendly_number_to_human @report.target_account.statuses_count
%small= t('accounts.posts', count: @report.target_account.statuses_count).downcase
.account-card__counters__item
= friendly_number_to_human @report.target_account.followers_count
%small= t('accounts.followers', count: @report.target_account.followers_count).downcase
.account-card__counters__item
= friendly_number_to_human @report.target_account.following_count
%small= t('accounts.following', count: @report.target_account.following_count).downcase
.account-card__actions__button
= link_to t('admin.reports.view_profile'), admin_account_path(@report.target_account_id), class: 'button'
.report-header__details.report-header__details--horizontal
.report-header__details__item
.report-header__details__item__header
%strong= t('admin.accounts.joined')
.report-header__details__item__content
%time.time-ago{ datetime: @report.target_account.created_at.iso8601, title: l(@report.target_account.created_at) }= l @report.target_account.created_at
.report-header__details__item
.report-header__details__item__header
%strong= t('accounts.last_active')
.report-header__details__item__content
- if @report.target_account.last_status_at.present?
%time.time-ago{ datetime: @report.target_account.last_status_at.to_date.iso8601, title: l(@report.target_account.last_status_at.to_date) }= l @report.target_account.last_status_at
.report-header__details__item
.report-header__details__item__header
%strong= t('admin.accounts.strikes')
.report-header__details__item__content
= @report.target_account.strikes.count
.report-header__details
.report-header__details__item
.report-header__details__item__header
%strong= t('admin.reports.created_at')
.report-header__details__item__content
%time.formatted{ datetime: @report.created_at.iso8601 }
.report-header__details__item
.report-header__details__item__header
%strong= t('admin.reports.reported_by')
.report-header__details__item__content
- if @report.account.instance_actor? - if @report.account.instance_actor?
%td{ colspan: 3 }= site_hostname = site_hostname
- elsif @report.account.local? - elsif @report.account.local?
%td= admin_account_link_to @report.account = admin_account_link_to @report.account
%td= table_link_to 'flag', t('admin.reports.account.reports', count: @report.account.targeted_reports.count), admin_reports_path(target_account_id: @report.account.id)
%td= table_link_to 'file', t('admin.reports.account.notes', count: @report.account.targeted_moderation_notes.count), admin_reports_path(target_account_id: @report.account.id)
- else - else
%td{ colspan: 3 }= @report.account.domain = @report.account.domain
%tr .report-header__details__item
%th= t('admin.reports.created_at') .report-header__details__item__header
%td{ colspan: 3 } %strong= t('admin.reports.status')
%time.formatted{ datetime: @report.created_at.iso8601 } .report-header__details__item__content
%tr - if @report.action_taken?
%th= t('admin.reports.updated_at') = t('admin.reports.resolved')
%td{ colspan: 3 } - else
%time.formatted{ datetime: @report.updated_at.iso8601 } = t('admin.reports.unresolved')
%tr - unless @report.target_account.local?
%th= t('admin.reports.status') .report-header__details__item
%td .report-header__details__item__header
- if @report.action_taken? %strong= t('admin.reports.forwarded')
= t('admin.reports.resolved') .report-header__details__item__content
- if @report.forwarded?
= t('simple_form.yes')
- else - else
= t('admin.reports.unresolved') = t('simple_form.no')
%td{ colspan: 2 } - if !@report.action_taken_by_account.nil?
- if @report.action_taken? .report-header__details__item
= table_link_to 'envelope-open', t('admin.reports.reopen'), admin_report_path(@report, outcome: 'reopen'), method: :put .report-header__details__item__header
- unless @report.target_account.local? %strong= t('admin.reports.action_taken_by')
%tr .report-header__details__item__content
%th= t('admin.reports.forwarded') = admin_account_link_to @report.action_taken_by_account
%td{ colspan: 3 }
- if @report.forwarded.nil?
\-
- elsif @report.forwarded?
= t('simple_form.yes')
- else
= t('simple_form.no')
- if !@report.action_taken_by_account.nil?
%tr
%th= t('admin.reports.action_taken_by')
%td{ colspan: 3 }
= admin_account_link_to @report.action_taken_by_account
- else
%tr
%th= t('admin.reports.assigned')
%td
- if @report.assigned_account.nil?
\-
- else
= admin_account_link_to @report.assigned_account
%td
- if @report.assigned_account != current_user.account
= table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
%td
- if !@report.assigned_account.nil?
= table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
%hr.spacer
%div.action-buttons
%div
- if @report.unresolved?
%div
- if @report.target_account.local?
= link_to t('admin.accounts.warn'), new_admin_account_action_path(@report.target_account_id, type: 'none', report_id: @report.id), class: 'button'
= link_to t('admin.accounts.disable'), new_admin_account_action_path(@report.target_account_id, type: 'disable', report_id: @report.id), class: 'button button--destructive'
= link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
= link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, type: 'suspend', report_id: @report.id), class: 'button button--destructive'
%hr.spacer
.speech-bubble
.speech-bubble__bubble= simple_format(@report.comment.presence || t('admin.reports.comment.none'))
.speech-bubble__owner
- if @report.account.local?
= admin_account_link_to @report.account
- else - else
= @report.account.domain .report-header__details__item
%br/ .report-header__details__item__header
%time.formatted{ datetime: @report.created_at.iso8601 } %strong= t('admin.reports.assigned')
.report-header__details__item__content
- if @report.assigned_account.nil?
= t 'admin.reports.no_one_assigned'
- else
= admin_account_link_to @report.assigned_account
- if @report.assigned_account != current_user.account
= table_link_to 'user', t('admin.reports.assign_to_self'), assign_to_self_admin_report_path(@report), method: :post
- elsif !@report.assigned_account.nil?
= table_link_to 'trash', t('admin.reports.unassign'), unassign_admin_report_path(@report), method: :post
- unless @report.statuses.empty? %hr.spacer
%hr.spacer/
= form_for(@form, url: admin_report_reported_statuses_path(@report.id)) do |f| %h3= t 'admin.reports.category'
.batch-table
.batch-table__toolbar %p= t 'admin.reports.category_description_html'
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false = react_admin_component :report_reason_selector, id: @report.id, category: @report.category, rule_ids: @report.rule_ids&.map(&:to_s), disabled: @report.action_taken?
.batch-table__toolbar__actions
= f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - if @report.comment.present?
= f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } %p= t('admin.reports.comment_description_html', name: content_tag(:strong, @report.account.username, class: 'username'))
= f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body .report-notes__item
= render partial: 'admin/reports/status', collection: @report.statuses, locals: { f: f } = image_tag @report.account.avatar.url, class: 'report-notes__item__avatar'
.report-notes__item__header
%span.username
= link_to display_name(@report.account), admin_account_path(@report.account_id)
%time{ datetime: @report.created_at.iso8601, title: l(@report.created_at) }
- if @report.created_at.today?
= t('admin.report_notes.today_at', time: l(@report.created_at, format: :time))
- else
= l @report.created_at.to_date
.report-notes__item__content
= simple_format(h(@report.comment))
%hr.spacer/ %hr.spacer/
- @report_notes.each do |item| %h3= t 'admin.reports.statuses'
- if item.is_a?(Admin::ActionLog)
= render partial: 'action_log', locals: { action_log: item } %p
- else = t 'admin.reports.statuses_description_html'
= render item
= link_to safe_join([fa_icon('plus'), t('admin.reports.add_to_report')]), admin_account_statuses_path(@report.target_account_id, report_id: @report.id), class: 'table-action-link'
= form_for(@form, url: batch_admin_account_statuses_path(@report.target_account_id, report_id: @report.id)) do |f|
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
- if !@statuses.empty? && @report.unresolved?
= f.button safe_join([fa_icon('times'), t('admin.statuses.batch.remove_from_report')]), name: :remove_from_report, class: 'table-action-link', type: :submit
= f.button safe_join([fa_icon('trash'), t('admin.reports.delete_and_resolve')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
- else
.batch-table__body
- if @statuses.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
- if @report.unresolved?
%hr.spacer/
%p= t 'admin.reports.actions_description_html'
.report-actions
.report-actions__item
.report-actions__item__button
= link_to t('admin.accounts.silence'), new_admin_account_action_path(@report.target_account_id, type: 'silence', report_id: @report.id), class: 'button button--destructive'
.report-actions__item__description
= t('admin.reports.actions.silence_description_html')
.report-actions__item
.report-actions__item__button
= link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id, type: 'suspend'), class: 'button button--destructive'
.report-actions__item__description
= t('admin.reports.actions.suspend_description_html')
.report-actions__item
.report-actions__item__button
= link_to t('admin.accounts.custom'), new_admin_account_action_path(@report.target_account_id, report_id: @report.id), class: 'button'
.report-actions__item__description
= t('admin.reports.actions.other_description_html')
- unless @action_logs.empty?
%hr.spacer/
%h3= t 'admin.reports.action_log'
.report-notes
= render @action_logs
%hr.spacer/
%h3= t 'admin.reports.notes.title'
%p= t 'admin.reports.notes_description_html'
.report-notes
= render @report_notes
= simple_form_for @report_note, url: admin_report_notes_path do |f| = simple_form_for @report_note, url: admin_report_notes_path do |f|
= render 'shared/error_messages', object: @report_note
= f.input :report_id, as: :hidden = f.input :report_id, as: :hidden
.field-group .field-group

View File

@ -10,28 +10,37 @@
.filter-subset .filter-subset
%strong= t('admin.statuses.media.title') %strong= t('admin.statuses.media.title')
%ul %ul
%li= link_to t('admin.statuses.no_media'), admin_account_statuses_path(@account.id, current_params.merge(media: nil)), class: !params[:media] && 'selected' %li= filter_link_to t('generic.all'), media: nil, id: nil
%li= link_to t('admin.statuses.with_media'), admin_account_statuses_path(@account.id, current_params.merge(media: true)), class: params[:media] && 'selected' %li= filter_link_to t('admin.statuses.with_media'), media: '1'
.back-link .back-link
= link_to admin_account_path(@account.id) do - if params[:report_id]
= fa_icon 'chevron-left fw' = link_to admin_report_path(params[:report_id].to_i) do
= t('admin.statuses.back_to_account') = fa_icon 'chevron-left fw'
= t('admin.statuses.back_to_report')
- else
= link_to admin_account_path(@account.id) do
= fa_icon 'chevron-left fw'
= t('admin.statuses.back_to_account')
%hr.spacer/ %hr.spacer/
= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f| = form_for(@status_batch_action, url: batch_admin_account_statuses_path(@account.id)) do |f|
= hidden_field_tag :page, params[:page] = hidden_field_tag :page, params[:page] || 1
= hidden_field_tag :media, params[:media]
- Admin::StatusFilter::KEYS.each do |key|
= hidden_field_tag key, params[key] if params[key].present?
.batch-table .batch-table
.batch-table__toolbar .batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all %label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false = check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions .batch-table__toolbar__actions
= f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - unless @statuses.empty?
= f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } = f.button safe_join([fa_icon('flag'), t('admin.statuses.batch.report')]), name: :report, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body .batch-table__body
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f } - if @statuses.empty?
= nothing_here 'nothing-here--under-tabs'
- else
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }
= paginate @statuses = paginate @statuses

View File

@ -1,27 +0,0 @@
- content_for :page_title do
= t('admin.statuses.title')
\-
= "@#{@account.acct}"
.filters
.back-link
= link_to admin_account_path(@account.id) do
%i.fa.fa-chevron-left.fa-fw
= t('admin.statuses.back_to_account')
%hr.spacer/
= form_for(@form, url: admin_account_statuses_path(@account.id)) do |f|
= hidden_field_tag :page, params[:page]
= hidden_field_tag :media, params[:media]
.batch-table
.batch-table__toolbar
%label.batch-table__toolbar__select.batch-checkbox-all
= check_box_tag :batch_checkbox_all, nil, false
.batch-table__toolbar__actions
= f.button safe_join([fa_icon('eye-slash'), t('admin.statuses.batch.nsfw_on')]), name: :nsfw_on, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('eye'), t('admin.statuses.batch.nsfw_off')]), name: :nsfw_off, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
= f.button safe_join([fa_icon('trash'), t('admin.statuses.batch.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
.batch-table__body
= render partial: 'admin/reports/status', collection: @statuses, locals: { f: f }

View File

@ -1,8 +1,8 @@
<% if status.spoiler_text? %> <% if status.spoiler_text? %>
<%= raw status.spoiler_text %> > <%= raw word_wrap(status.spoiler_text, break_sequence: "\n> ") %>
---- > ----
>
<% end %> <% end %>
<%= raw Formatter.instance.plaintext(status) %> > <%= raw word_wrap(Formatter.instance.plaintext(status), break_sequence: "\n> ") %>
<%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %> <%= raw t('application_mailer.view')%> <%= web_url("statuses/#{status.id}") %>

View File

@ -37,16 +37,26 @@
%tr %tr
%td.column-cell.text-center %td.column-cell.text-center
- unless @warning.none_action? - unless @warning.none_action?
%p= t "user_mailer.warning.explanation.#{@warning.action}" %p= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance
- unless @warning.text.blank? - unless @warning.text.blank?
= Formatter.instance.linkify(@warning.text) = Formatter.instance.linkify(@warning.text)
- if !@statuses.nil? && !@statuses.empty? - if @warning.report && !@warning.report.other?
%p
%strong= t('user_mailer.warning.reason')
= t("user_mailer.warning.categories.#{@warning.report.category}")
- if @warning.report.violation? && @warning.report.rule_ids.present?
%ul.rules-list
- @warning.report.rules.each do |rule|
%li= rule.text
- unless @statuses.empty?
%p %p
%strong= t('user_mailer.warning.statuses') %strong= t('user_mailer.warning.statuses')
- if !@statuses.nil? && !@statuses.empty? - unless @statuses.empty?
- @statuses.each_with_index do |status, i| - @statuses.each_with_index do |status, i|
= render 'notification_mailer/status', status: status, i: i + 1, highlighted: true = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true

View File

@ -3,11 +3,24 @@
=== ===
<% unless @warning.none_action? %> <% unless @warning.none_action? %>
<%= t "user_mailer.warning.explanation.#{@warning.action}" %> <%= t "user_mailer.warning.explanation.#{@warning.action}", instance: @instance %>
<% end %> <% end %>
<% if @warning.text.present? %>
<%= @warning.text %> <%= @warning.text %>
<% if !@statuses.nil? && !@statuses.empty? %>
<% end %>
<% if @warning.report && !@warning.report.other? %>
**<%= t('user_mailer.warning.reason') %>** <%= t("user_mailer.warning.categories.#{@warning.report.category}") %>
<% if @warning.report.violation? && @warning.report.rule_ids.present? %>
<% @warning.report.rules.each do |rule| %>
- <%= rule.text %>
<% end %>
<% end %>
<% end %>
<% if !@statuses.empty? %>
<%= t('user_mailer.warning.statuses') %> <%= t('user_mailer.warning.statuses') %>
<% @statuses.each do |status| %> <% @statuses.each do |status| %>

View File

@ -8,6 +8,7 @@ class Scheduler::UserCleanupScheduler
def perform def perform
clean_unconfirmed_accounts! clean_unconfirmed_accounts!
clean_suspended_accounts! clean_suspended_accounts!
clean_discarded_statuses!
end end
private private
@ -24,4 +25,12 @@ class Scheduler::UserCleanupScheduler
Admin::AccountDeletionWorker.perform_async(deletion_request.account_id) Admin::AccountDeletionWorker.perform_async(deletion_request.account_id)
end end
end end
def clean_discarded_statuses!
Status.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
RemovalWorker.push_bulk(statuses) do |status|
[status.id, { immediate: true }]
end
end
end
end end

View File

@ -113,6 +113,7 @@ en:
confirm: Confirm confirm: Confirm
confirmed: Confirmed confirmed: Confirmed
confirming: Confirming confirming: Confirming
custom: Custom
delete: Delete data delete: Delete data
deleted: Deleted deleted: Deleted
demote: Demote demote: Demote
@ -203,6 +204,7 @@ en:
silence: Limit silence: Limit
silenced: Limited silenced: Limited
statuses: Posts statuses: Posts
strikes: Previous strikes
subscribe: Subscribe subscribe: Subscribe
suspended: Suspended suspended: Suspended
suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had. suspension_irreversible: The data of this account has been irreversibly deleted. You can unsuspend the account to make it usable but it will not recover any data it previously had.
@ -549,32 +551,44 @@ en:
report_notes: report_notes:
created_msg: Report note successfully created! created_msg: Report note successfully created!
destroyed_msg: Report note successfully deleted! destroyed_msg: Report note successfully deleted!
today_at: Today at %{time}
reports: reports:
account: account:
notes: notes:
one: "%{count} note" one: "%{count} note"
other: "%{count} notes" other: "%{count} notes"
reports: action_log: Audit log
one: "%{count} report"
other: "%{count} reports"
action_taken_by: Action taken by action_taken_by: Action taken by
actions:
other_description_html: See more options for controlling the account's behaviour and customize communication to the reported account.
silence_description_html: The profile will be visible only to those who already follow it or manually look it up, severely limiting its reach. Can always be reverted.
suspend_description_html: The profile and all its contents will become inaccessible until it is eventually deleted. Interacting with the account will be impossible. Reversible within 30 days.
actions_description_html: 'If removing the offending content above is insufficient:'
add_to_report: Add more to report
are_you_sure: Are you sure? are_you_sure: Are you sure?
assign_to_self: Assign to me assign_to_self: Assign to me
assigned: Assigned moderator assigned: Assigned moderator
by_target_domain: Domain of reported account by_target_domain: Domain of reported account
category: Category
category_description_html: The reason this account and/or content was reported will be cited in communication with the reported account
comment: comment:
none: None none: None
comment_description_html: 'To provide more information, %{name} wrote:'
created_at: Reported created_at: Reported
delete_and_resolve: Delete and resolve
forwarded: Forwarded forwarded: Forwarded
forwarded_to: Forwarded to %{domain} forwarded_to: Forwarded to %{domain}
mark_as_resolved: Mark as resolved mark_as_resolved: Mark as resolved
mark_as_unresolved: Mark as unresolved mark_as_unresolved: Mark as unresolved
no_one_assigned: No one
notes: notes:
create: Add note create: Add note
create_and_resolve: Resolve with note create_and_resolve: Resolve with note
create_and_unresolve: Reopen with note create_and_unresolve: Reopen with note
delete: Delete delete: Delete
placeholder: Describe what actions have been taken, or any other related updates... placeholder: Describe what actions have been taken, or any other related updates...
title: Notes
notes_description_html: View and leave notes to other moderators and your future self
reopen: Reopen report reopen: Reopen report
report: 'Report #%{id}' report: 'Report #%{id}'
reported_account: Reported account reported_account: Reported account
@ -582,11 +596,14 @@ en:
resolved: Resolved resolved: Resolved
resolved_msg: Report successfully resolved! resolved_msg: Report successfully resolved!
status: Status status: Status
statuses: Reported content
statuses_description_html: Offending content will be cited in communication with the reported account
target_origin: Origin of reported account target_origin: Origin of reported account
title: Reports title: Reports
unassign: Unassign unassign: Unassign
unresolved: Unresolved unresolved: Unresolved
updated_at: Updated updated_at: Updated
view_profile: View profile
rules: rules:
add_new: Add rule add_new: Add rule
delete: Delete delete: Delete
@ -688,15 +705,13 @@ en:
destroyed_msg: Site upload successfully deleted! destroyed_msg: Site upload successfully deleted!
statuses: statuses:
back_to_account: Back to account page back_to_account: Back to account page
back_to_report: Back to report page
batch: batch:
delete: Delete remove_from_report: Remove from report
nsfw_off: Mark as not sensitive report: Report
nsfw_on: Mark as sensitive
deleted: Deleted deleted: Deleted
failed_to_execute: Failed to execute
media: media:
title: Media title: Media
no_media: No media
no_status_selected: No posts were changed as none were selected no_status_selected: No posts were changed as none were selected
title: Account posts title: Account posts
with_media: With media with_media: With media
@ -1457,6 +1472,7 @@ en:
formats: formats:
default: "%b %d, %Y, %H:%M" default: "%b %d, %Y, %H:%M"
month: "%b %Y" month: "%b %Y"
time: "%H:%M"
two_factor_authentication: two_factor_authentication:
add: Add add: Add
disable: Disable 2FA disable: Disable 2FA
@ -1484,24 +1500,31 @@ en:
subject: Please confirm attempted sign in subject: Please confirm attempted sign in
title: Sign in attempt title: Sign in attempt
warning: warning:
categories:
spam: Spam
violation: Content violates the following community guidelines
explanation: explanation:
disable: You can no longer login to your account or use it in any other way, but your profile and other data remains intact. delete_statuses: Some of your posts have been found to violate one or more community guidelines and have been subsequently removed by the moderators of %{instance}. Future violations may result in harsher punitive actions against your account.
sensitive: Your uploaded media files and linked media will be treated as sensitive. disable: You can no longer use your account, but your profile and other data remains intact. You can request a backup of your data, change account settings or delete your account.
silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various public listings. However, others may still manually follow you. sensitive: From now on, all your uploaded media files will be marked as sensitive and hidden behind a click-through warning.
suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed, but we will retain some data to prevent you from evading the suspension. silence: You can still use your account but only people who are already following you will see your posts on this server, and you may be excluded from various discovery features. However, others may still manually follow you.
get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}. suspend: You can no longer use your account, and your profile and other data are no longer accessible. You can still login to request a backup of your data until the data is fully removed in about 30 days, but we will retain some basic data to prevent you from evading the suspension.
get_in_touch: If you believe this is an error, you can reply to this e-mail to get in touch with the staff of %{instance}.
reason: 'Reason:'
review_server_policies: Review server policies review_server_policies: Review server policies
statuses: 'Specifically, for:' statuses: 'Posts that have been found in violation:'
subject: subject:
delete_statuses: Your posts on %{acct} have been removed
disable: Your account %{acct} has been frozen disable: Your account %{acct} has been frozen
none: Warning for %{acct} none: Warning for %{acct}
sensitive: Your account %{acct} posting media has been marked as sensitive sensitive: Your media files on %{acct} will be marked as sensitive from now on
silence: Your account %{acct} has been limited silence: Your account %{acct} has been limited
suspend: Your account %{acct} has been suspended suspend: Your account %{acct} has been suspended
title: title:
delete_statuses: Posts removed
disable: Account frozen disable: Account frozen
none: Warning none: Warning
sensitive: Your media has been marked as sensitive sensitive: Media hidden
silence: Account limited silence: Account limited
suspend: Account suspended suspend: Account suspended
welcome: welcome:

View File

@ -231,8 +231,6 @@ Rails.application.routes.draw do
post :reopen post :reopen
post :resolve post :resolve
end end
resources :reported_statuses, only: [:create]
end end
resources :report_notes, only: [:create, :destroy] resources :report_notes, only: [:create, :destroy]
@ -259,7 +257,13 @@ Rails.application.routes.draw do
resource :change_email, only: [:show, :update] resource :change_email, only: [:show, :update]
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, :show, :create, :update, :destroy]
resources :statuses, only: [:index] do
collection do
post :batch
end
end
resources :relationships, only: [:index] resources :relationships, only: [:index]
resource :confirmation, only: [:create] do resource :confirmation, only: [:create] do
@ -514,7 +518,7 @@ Rails.application.routes.draw do
resource :action, only: [:create], controller: 'account_actions' resource :action, only: [:create], controller: 'account_actions'
end end
resources :reports, only: [:index, :show] do resources :reports, only: [:index, :update, :show] do
member do member do
post :assign_to_self post :assign_to_self
post :unassign post :unassign

View File

@ -0,0 +1,21 @@
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddCategoryToReports < ActiveRecord::Migration[6.1]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured { add_column_with_default :reports, :category, :int, default: 0, allow_null: false }
add_column :reports, :action_taken_at, :datetime
add_column :reports, :rule_ids, :bigint, array: true
safety_assured { execute 'UPDATE reports SET action_taken_at = updated_at WHERE action_taken = TRUE' }
end
def down
safety_assured { execute 'UPDATE reports SET action_taken = TRUE WHERE action_taken_at IS NOT NULL' }
remove_column :reports, :category
remove_column :reports, :action_taken_at
remove_column :reports, :rule_ids
end
end

View File

@ -0,0 +1,6 @@
class AddReportIdToAccountWarnings < ActiveRecord::Migration[6.1]
def change
safety_assured { add_reference :account_warnings, :report, foreign_key: { on_delete: :cascade }, index: false }
add_column :account_warnings, :status_ids, :string, array: true
end
end

View File

@ -0,0 +1,21 @@
class FixAccountWarningActions < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def up
safety_assured do
execute 'UPDATE account_warnings SET action = 1000 WHERE action = 1'
execute 'UPDATE account_warnings SET action = 2000 WHERE action = 2'
execute 'UPDATE account_warnings SET action = 3000 WHERE action = 3'
execute 'UPDATE account_warnings SET action = 4000 WHERE action = 4'
end
end
def down
safety_assured do
execute 'UPDATE account_warnings SET action = 1 WHERE action = 1000'
execute 'UPDATE account_warnings SET action = 2 WHERE action = 2000'
execute 'UPDATE account_warnings SET action = 3 WHERE action = 3000'
execute 'UPDATE account_warnings SET action = 4 WHERE action = 4000'
end
end
end

View File

@ -0,0 +1,7 @@
class AddDeletedAtIndexOnStatuses < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def change
add_index :statuses, :deleted_at, where: 'deleted_at IS NOT NULL', algorithm: :concurrently
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class RemoveActionTakenFromReports < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def change
safety_assured { remove_column :reports, :action_taken, :boolean, default: false, null: false }
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_12_13_040746) do ActiveRecord::Schema.define(version: 2022_01_16_202951) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -133,6 +133,8 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
t.text "text", default: "", null: false t.text "text", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "report_id"
t.string "status_ids", array: true
t.index ["account_id"], name: "index_account_warnings_on_account_id" t.index ["account_id"], name: "index_account_warnings_on_account_id"
t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id" t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
end end
@ -747,7 +749,6 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
create_table "reports", force: :cascade do |t| create_table "reports", force: :cascade do |t|
t.bigint "status_ids", default: [], null: false, array: true t.bigint "status_ids", default: [], null: false, array: true
t.text "comment", default: "", null: false t.text "comment", default: "", null: false
t.boolean "action_taken", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.bigint "account_id", null: false t.bigint "account_id", null: false
@ -756,6 +757,9 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
t.bigint "assigned_account_id" t.bigint "assigned_account_id"
t.string "uri" t.string "uri"
t.boolean "forwarded" t.boolean "forwarded"
t.integer "category", default: 0, null: false
t.datetime "action_taken_at"
t.bigint "rule_ids", array: true
t.index ["account_id"], name: "index_reports_on_account_id" t.index ["account_id"], name: "index_reports_on_account_id"
t.index ["target_account_id"], name: "index_reports_on_target_account_id" t.index ["target_account_id"], name: "index_reports_on_target_account_id"
end end
@ -851,6 +855,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
t.bigint "poll_id" t.bigint "poll_id"
t.datetime "deleted_at" t.datetime "deleted_at"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
@ -1008,6 +1013,7 @@ ActiveRecord::Schema.define(version: 2021_12_13_040746) do
add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade add_foreign_key "account_statuses_cleanup_policies", "accounts", on_delete: :cascade
add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade add_foreign_key "account_warnings", "accounts", column: "target_account_id", on_delete: :cascade
add_foreign_key "account_warnings", "accounts", on_delete: :nullify add_foreign_key "account_warnings", "accounts", on_delete: :nullify
add_foreign_key "account_warnings", "reports", on_delete: :cascade
add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify add_foreign_key "accounts", "accounts", column: "moved_to_account_id", on_delete: :nullify
add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade add_foreign_key "admin_action_logs", "accounts", on_delete: :cascade
add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade add_foreign_key "announcement_mutes", "accounts", on_delete: :cascade

View File

@ -12,11 +12,11 @@ describe Admin::ReportNotesController do
describe 'POST #create' do describe 'POST #create' do
subject { post :create, params: params } subject { post :create, params: params }
let(:report) { Fabricate(:report, action_taken: action_taken, action_taken_by_account_id: account_id) } let(:report) { Fabricate(:report, action_taken_at: action_taken, action_taken_by_account_id: account_id) }
context 'when parameter is valid' do context 'when parameter is valid' do
context 'when report is unsolved' do context 'when report is unsolved' do
let(:action_taken) { false } let(:action_taken) { nil }
let(:account_id) { nil } let(:account_id) { nil }
context 'when create_and_resolve flag is on' do context 'when create_and_resolve flag is on' do
@ -41,7 +41,7 @@ describe Admin::ReportNotesController do
end end
context 'when report is resolved' do context 'when report is resolved' do
let(:action_taken) { true } let(:action_taken) { Time.now.utc }
let(:account_id) { user.account.id } let(:account_id) { user.account.id }
context 'when create_and_unresolve flag is on' do context 'when create_and_unresolve flag is on' do
@ -68,7 +68,7 @@ describe Admin::ReportNotesController do
context 'when parameter is invalid' do context 'when parameter is invalid' do
let(:params) { { report_note: { content: '', report_id: report.id } } } let(:params) { { report_note: { content: '', report_id: report.id } } }
let(:action_taken) { false } let(:action_taken) { nil }
let(:account_id) { nil } let(:account_id) { nil }
it 'renders admin/reports/show' do it 'renders admin/reports/show' do

View File

@ -1,59 +0,0 @@
require 'rails_helper'
describe Admin::ReportedStatusesController do
render_views
let(:user) { Fabricate(:user, admin: true) }
let(:report) { Fabricate(:report, status_ids: [status.id]) }
let(:status) { Fabricate(:status) }
before do
sign_in user, scope: :user
end
describe 'POST #create' do
subject do
-> { post :create, params: { :report_id => report, action => '', :form_status_batch => { status_ids: status_ids } } }
end
let(:action) { 'nsfw_on' }
let(:status_ids) { [status.id] }
let(:status) { Fabricate(:status, sensitive: !sensitive) }
let(:sensitive) { true }
let!(:media_attachment) { Fabricate(:media_attachment, status: status) }
context 'when action is nsfw_on' do
it 'updates sensitive column' do
is_expected.to change {
status.reload.sensitive
}.from(false).to(true)
end
end
context 'when action is nsfw_off' do
let(:action) { 'nsfw_off' }
let(:sensitive) { false }
it 'updates sensitive column' do
is_expected.to change {
status.reload.sensitive
}.from(true).to(false)
end
end
context 'when action is delete' do
let(:action) { 'delete' }
it 'removes a status' do
allow(RemovalWorker).to receive(:perform_async)
subject.call
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true)
end
end
it 'redirects to report page' do
subject.call
expect(response).to redirect_to(admin_report_path(report))
end
end
end

View File

@ -10,8 +10,8 @@ describe Admin::ReportsController do
describe 'GET #index' do describe 'GET #index' do
it 'returns http success with no filters' do it 'returns http success with no filters' do
specified = Fabricate(:report, action_taken: false) specified = Fabricate(:report, action_taken_at: nil)
Fabricate(:report, action_taken: true) Fabricate(:report, action_taken_at: Time.now.utc)
get :index get :index
@ -22,10 +22,10 @@ describe Admin::ReportsController do
end end
it 'returns http success with resolved filter' do it 'returns http success with resolved filter' do
specified = Fabricate(:report, action_taken: true) specified = Fabricate(:report, action_taken_at: Time.now.utc)
Fabricate(:report, action_taken: false) Fabricate(:report, action_taken_at: nil)
get :index, params: { resolved: 1 } get :index, params: { resolved: '1' }
reports = assigns(:reports).to_a reports = assigns(:reports).to_a
expect(reports.size).to eq 1 expect(reports.size).to eq 1
@ -54,15 +54,7 @@ describe Admin::ReportsController do
expect(response).to redirect_to(admin_reports_path) expect(response).to redirect_to(admin_reports_path)
report.reload report.reload
expect(report.action_taken_by_account).to eq user.account expect(report.action_taken_by_account).to eq user.account
expect(report.action_taken).to eq true expect(report.action_taken?).to eq true
end
it 'sets trust level when the report is an antispam one' do
report = Fabricate(:report, account: Account.representative)
put :resolve, params: { id: report }
report.reload
expect(report.target_account.trust_level).to eq Account::TRUST_LEVELS[:trusted]
end end
end end
@ -74,7 +66,7 @@ describe Admin::ReportsController do
expect(response).to redirect_to(admin_report_path(report)) expect(response).to redirect_to(admin_report_path(report))
report.reload report.reload
expect(report.action_taken_by_account).to eq nil expect(report.action_taken_by_account).to eq nil
expect(report.action_taken).to eq false expect(report.action_taken?).to eq false
end end
end end

View File

@ -18,65 +18,46 @@ describe Admin::StatusesController do
end end
describe 'GET #index' do describe 'GET #index' do
it 'returns http success with no media' do context do
get :index, params: { account_id: account.id } before do
get :index, params: { account_id: account.id }
end
statuses = assigns(:statuses).to_a it 'returns http success' do
expect(statuses.size).to eq 4 expect(response).to have_http_status(200)
expect(statuses.first.id).to eq last_status.id end
expect(response).to have_http_status(200)
end end
it 'returns http success with media' do context 'filtering by media' do
get :index, params: { account_id: account.id, media: true } before do
get :index, params: { account_id: account.id, media: '1' }
end
statuses = assigns(:statuses).to_a it 'returns http success' do
expect(statuses.size).to eq 2 expect(response).to have_http_status(200)
expect(statuses.first.id).to eq last_media_attached_status.id end
expect(response).to have_http_status(200)
end end
end end
describe 'POST #create' do describe 'POST #batch' do
subject do before do
-> { post :create, params: { :account_id => account.id, action => '', :form_status_batch => { status_ids: status_ids } } } post :batch, params: { :account_id => account.id, action => '', :admin_status_batch_action => { status_ids: status_ids } }
end end
let(:action) { 'nsfw_on' }
let(:status_ids) { [media_attached_status.id] } let(:status_ids) { [media_attached_status.id] }
context 'when action is nsfw_on' do context 'when action is report' do
it 'updates sensitive column' do let(:action) { 'report' }
is_expected.to change {
media_attached_status.reload.sensitive it 'creates a report' do
}.from(false).to(true) report = Report.last
expect(report.target_account_id).to eq account.id
expect(report.status_ids).to eq status_ids
end end
end
context 'when action is nsfw_off' do it 'redirects to report page' do
let(:action) { 'nsfw_off' } expect(response).to redirect_to(admin_report_path(Report.last.id))
let(:sensitive) { false }
it 'updates sensitive column' do
is_expected.to change {
media_attached_status.reload.sensitive
}.from(true).to(false)
end end
end end
context 'when action is delete' do
let(:action) { 'delete' }
it 'removes a status' do
allow(RemovalWorker).to receive(:perform_async)
subject.call
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, immediate: true)
end
end
it 'redirects to account statuses page' do
subject.call
expect(response).to redirect_to(admin_account_statuses_path(account.id))
end
end end
end end

View File

@ -1,6 +1,6 @@
Fabricator(:report) do Fabricator(:report) do
account account
target_account { Fabricate(:account) } target_account { Fabricate(:account) }
comment "You nasty" comment "You nasty"
action_taken false action_taken_at nil
end end

View File

@ -79,7 +79,7 @@ class UserMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
def warning def warning
UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id]) UserMailer.warning(User.first, AccountWarning.last)
end end
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token # Preview this email at http://localhost:3000/rails/mailers/user_mailer/sign_in_token

View File

@ -1,52 +0,0 @@
require 'rails_helper'
describe Form::StatusBatch do
let(:form) { Form::StatusBatch.new(action: action, status_ids: status_ids) }
let(:status) { Fabricate(:status) }
describe 'with nsfw action' do
let(:status_ids) { [status.id, nonsensitive_status.id, sensitive_status.id] }
let(:nonsensitive_status) { Fabricate(:status, sensitive: false) }
let(:sensitive_status) { Fabricate(:status, sensitive: true) }
let!(:shown_media_attachment) { Fabricate(:media_attachment, status: nonsensitive_status) }
let!(:hidden_media_attachment) { Fabricate(:media_attachment, status: sensitive_status) }
context 'nsfw_on' do
let(:action) { 'nsfw_on' }
it { expect(form.save).to be true }
it { expect { form.save }.to change { nonsensitive_status.reload.sensitive }.from(false).to(true) }
it { expect { form.save }.not_to change { sensitive_status.reload.sensitive } }
it { expect { form.save }.not_to change { status.reload.sensitive } }
end
context 'nsfw_off' do
let(:action) { 'nsfw_off' }
it { expect(form.save).to be true }
it { expect { form.save }.to change { sensitive_status.reload.sensitive }.from(true).to(false) }
it { expect { form.save }.not_to change { nonsensitive_status.reload.sensitive } }
it { expect { form.save }.not_to change { status.reload.sensitive } }
end
end
describe 'with delete action' do
let(:status_ids) { [status.id] }
let(:action) { 'delete' }
let!(:another_status) { Fabricate(:status) }
before do
allow(RemovalWorker).to receive(:perform_async)
end
it 'call RemovalWorker' do
form.save
expect(RemovalWorker).to have_received(:perform_async).with(status.id, immediate: true)
end
it 'do not call RemovalWorker' do
form.save
expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, immediate: true)
end
end
end

View File

@ -54,7 +54,7 @@ describe Report do
end end
describe 'resolve!' do describe 'resolve!' do
subject(:report) { Fabricate(:report, action_taken: false, action_taken_by_account_id: nil) } subject(:report) { Fabricate(:report, action_taken_at: nil, action_taken_by_account_id: nil) }
let(:acting_account) { Fabricate(:account) } let(:acting_account) { Fabricate(:account) }
@ -63,12 +63,13 @@ describe Report do
end end
it 'records action taken' do it 'records action taken' do
expect(report).to have_attributes(action_taken: true, action_taken_by_account_id: acting_account.id) expect(report.action_taken?).to be true
expect(report.action_taken_by_account_id).to eq acting_account.id
end end
end end
describe 'unresolve!' do describe 'unresolve!' do
subject(:report) { Fabricate(:report, action_taken: true, action_taken_by_account_id: acting_account.id) } subject(:report) { Fabricate(:report, action_taken_at: Time.now.utc, action_taken_by_account_id: acting_account.id) }
let(:acting_account) { Fabricate(:account) } let(:acting_account) { Fabricate(:account) }
@ -77,23 +78,24 @@ describe Report do
end end
it 'unresolves' do it 'unresolves' do
expect(report).to have_attributes(action_taken: false, action_taken_by_account_id: nil) expect(report.action_taken?).to be false
expect(report.action_taken_by_account_id).to be_nil
end end
end end
describe 'unresolved?' do describe 'unresolved?' do
subject { report.unresolved? } subject { report.unresolved? }
let(:report) { Fabricate(:report, action_taken: action_taken) } let(:report) { Fabricate(:report, action_taken_at: action_taken) }
context 'if action is taken' do context 'if action is taken' do
let(:action_taken) { true } let(:action_taken) { Time.now.utc }
it { is_expected.to be false } it { is_expected.to be false }
end end
context 'if action not is taken' do context 'if action not is taken' do
let(:action_taken) { false } let(:action_taken) { nil }
it { is_expected.to be true } it { is_expected.to be true }
end end