diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2018-02-28 06:54:55 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2018-02-28 06:54:55 +0100 |
commit | 41a01bec2337e7021634f2e9c78d86a1c3002fcf (patch) | |
tree | 44ab6f2f922d5d9adb01f0978ce79a8d301533ca | |
parent | 4072b686862048c86674bd6de16d7e20ddc52b29 (diff) |
Federated reports (#6570)
* Fix #2176: Federated reports * UI for federated reports * Add spec for ActivityPub Flag handler * Add spec for ReportService
-rw-r--r-- | app/controllers/api/v1/reports_controller.rb | 12 | ||||
-rw-r--r-- | app/javascript/mastodon/actions/reports.js | 9 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/components/report_modal.js | 44 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/reports.js | 4 | ||||
-rw-r--r-- | app/javascript/styles/mastodon/components.scss | 84 | ||||
-rw-r--r-- | app/lib/activitypub/activity.rb | 2 | ||||
-rw-r--r-- | app/lib/activitypub/activity/flag.rb | 25 | ||||
-rw-r--r-- | app/models/report.rb | 4 | ||||
-rw-r--r-- | app/serializers/activitypub/flag_serializer.rb | 27 | ||||
-rw-r--r-- | app/services/report_service.rb | 54 | ||||
-rw-r--r-- | db/schema.rb | 1 | ||||
-rw-r--r-- | spec/lib/activitypub/activity/flag_spec.rb | 37 | ||||
-rw-r--r-- | spec/services/report_service_spec.rb | 25 |
13 files changed, 306 insertions, 22 deletions
diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index 22828217d..f5095e073 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -13,14 +13,14 @@ class Api::V1::ReportsController < Api::BaseController end def create - @report = current_account.reports.create!( - target_account: reported_account, + @report = ReportService.new.call( + current_account, + reported_account, status_ids: reported_status_ids, - comment: report_params[:comment] + comment: report_params[:comment], + forward: report_params[:forward] ) - User.staff.includes(:account).each { |u| AdminMailer.new_report(u.account, @report).deliver_later } - render json: @report, serializer: REST::ReportSerializer end @@ -39,6 +39,6 @@ class Api::V1::ReportsController < Api::BaseController end def report_params - params.permit(:account_id, :comment, status_ids: []) + params.permit(:account_id, :comment, :forward, status_ids: []) end end diff --git a/app/javascript/mastodon/actions/reports.js b/app/javascript/mastodon/actions/reports.js index b19a07285..afa0c3412 100644 --- a/app/javascript/mastodon/actions/reports.js +++ b/app/javascript/mastodon/actions/reports.js @@ -10,6 +10,7 @@ export const REPORT_SUBMIT_FAIL = 'REPORT_SUBMIT_FAIL'; export const REPORT_STATUS_TOGGLE = 'REPORT_STATUS_TOGGLE'; export const REPORT_COMMENT_CHANGE = 'REPORT_COMMENT_CHANGE'; +export const REPORT_FORWARD_CHANGE = 'REPORT_FORWARD_CHANGE'; export function initReport(account, status) { return dispatch => { @@ -45,6 +46,7 @@ export function submitReport() { account_id: getState().getIn(['reports', 'new', 'account_id']), status_ids: getState().getIn(['reports', 'new', 'status_ids']), comment: getState().getIn(['reports', 'new', 'comment']), + forward: getState().getIn(['reports', 'new', 'forward']), }).then(response => { dispatch(closeModal()); dispatch(submitReportSuccess(response.data)); @@ -78,3 +80,10 @@ export function changeReportComment(comment) { comment, }; }; + +export function changeReportForward(forward) { + return { + type: REPORT_FORWARD_CHANGE, + forward, + }; +}; diff --git a/app/javascript/mastodon/features/ui/components/report_modal.js b/app/javascript/mastodon/features/ui/components/report_modal.js index b5dfa422e..3a7e4df76 100644 --- a/app/javascript/mastodon/features/ui/components/report_modal.js +++ b/app/javascript/mastodon/features/ui/components/report_modal.js @@ -1,6 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; -import { changeReportComment, submitReport } from '../../../actions/reports'; +import { changeReportComment, changeReportForward, submitReport } from '../../../actions/reports'; import { refreshAccountTimeline } from '../../../actions/timelines'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; @@ -10,6 +10,7 @@ import StatusCheckBox from '../../report/containers/status_check_box_container'; import { OrderedSet } from 'immutable'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Button from '../../../components/button'; +import Toggle from 'react-toggle'; const messages = defineMessages({ placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' }, @@ -26,6 +27,7 @@ const makeMapStateToProps = () => { isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']), account: getAccount(state, accountId), comment: state.getIn(['reports', 'new', 'comment']), + forward: state.getIn(['reports', 'new', 'forward']), statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])), }; }; @@ -42,14 +44,19 @@ export default class ReportModal extends ImmutablePureComponent { account: ImmutablePropTypes.map, statusIds: ImmutablePropTypes.orderedSet.isRequired, comment: PropTypes.string.isRequired, + forward: PropTypes.bool, dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; - handleCommentChange = (e) => { + handleCommentChange = e => { this.props.dispatch(changeReportComment(e.target.value)); } + handleForwardChange = e => { + this.props.dispatch(changeReportForward(e.target.checked)); + } + handleSubmit = () => { this.props.dispatch(submitReport()); } @@ -65,12 +72,14 @@ export default class ReportModal extends ImmutablePureComponent { } render () { - const { account, comment, intl, statusIds, isSubmitting } = this.props; + const { account, comment, intl, statusIds, isSubmitting, forward } = this.props; if (!account) { return null; } + const domain = account.get('acct').split('@')[1]; + return ( <div className='modal-root__modal report-modal'> <div className='report-modal__target'> @@ -78,13 +87,9 @@ export default class ReportModal extends ImmutablePureComponent { </div> <div className='report-modal__container'> - <div className='report-modal__statuses'> - <div> - {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} - </div> - </div> - <div className='report-modal__comment'> + <p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:' /></p> + <textarea className='setting-text light' placeholder={intl.formatMessage(messages.placeholder)} @@ -92,11 +97,26 @@ export default class ReportModal extends ImmutablePureComponent { onChange={this.handleCommentChange} disabled={isSubmitting} /> + + {domain && ( + <div> + <p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p> + + <div className='setting-toggle'> + <Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} /> + <label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label> + </div> + </div> + )} + + <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> </div> - </div> - <div className='report-modal__action-bar'> - <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /> + <div className='report-modal__statuses'> + <div> + {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)} + </div> + </div> </div> </div> ); diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js index a08bbec38..21ae6f93f 100644 --- a/app/javascript/mastodon/reducers/reports.js +++ b/app/javascript/mastodon/reducers/reports.js @@ -6,6 +6,7 @@ import { REPORT_CANCEL, REPORT_STATUS_TOGGLE, REPORT_COMMENT_CHANGE, + REPORT_FORWARD_CHANGE, } from '../actions/reports'; import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable'; @@ -15,6 +16,7 @@ const initialState = ImmutableMap({ account_id: null, status_ids: ImmutableSet(), comment: '', + forward: false, }), }); @@ -42,6 +44,8 @@ export default function reports(state = initialState, action) { }); case REPORT_COMMENT_CHANGE: return state.setIn(['new', 'comment'], action.comment); + case REPORT_FORWARD_CHANGE: + return state.setIn(['new', 'forward'], action.forward); case REPORT_SUBMIT_REQUEST: return state.setIn(['new', 'isSubmitting'], true); case REPORT_SUBMIT_FAIL: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0224009ee..213f4df47 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -3891,8 +3891,7 @@ a.status-card { .boost-modal__action-bar, .confirmation-modal__action-bar, -.mute-modal__action-bar, -.report-modal__action-bar { +.mute-modal__action-bar { display: flex; justify-content: space-between; background: $ui-secondary-color; @@ -3936,21 +3935,94 @@ a.status-card { vertical-align: middle; } +.report-modal { + width: 90vw; + max-width: 700px; +} + +.report-modal__container { + display: flex; + border-top: 1px solid $ui-secondary-color; + + @media screen and (max-width: 480px) { + flex-wrap: wrap; + overflow-y: auto; + } +} + .report-modal__statuses, .report-modal__comment { - padding: 10px; + box-sizing: border-box; + width: 50%; + + @media screen and (max-width: 480px) { + width: 100%; + } } .report-modal__statuses { + flex: 1 1 auto; min-height: 20vh; max-height: 40vh; overflow-y: auto; overflow-x: hidden; + + @media screen and (max-width: 480px) { + max-height: 10vh; + } } .report-modal__comment { + padding: 20px; + border-right: 1px solid $ui-secondary-color; + max-width: 320px; + + p { + font-size: 14px; + line-height: 20px; + margin-bottom: 20px; + } + .setting-text { - margin-top: 10px; + display: block; + box-sizing: border-box; + width: 100%; + margin: 0; + color: $ui-base-color; + background: $white; + padding: 10px; + font-family: inherit; + font-size: 14px; + resize: vertical; + border: 0; + outline: 0; + border-radius: 4px; + border: 1px solid $ui-secondary-color; + margin-bottom: 20px; + + &:focus { + border: 1px solid darken($ui-secondary-color, 8%); + } + } + + .setting-toggle { + margin-top: 20px; + margin-bottom: 24px; + + &__label { + color: $ui-base-color; + font-size: 14px; + } + } + + @media screen and (max-width: 480px) { + padding: 10px; + max-width: 100%; + order: 2; + + .setting-toggle { + margin-bottom: 4px; + } } } @@ -4043,6 +4115,10 @@ a.status-card { } } +.report-modal__target { + padding: 20px; +} + .loading-bar { background-color: $ui-highlight-color; height: 3px; diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 4617905c6..6f4a3b491 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -44,6 +44,8 @@ class ActivityPub::Activity ActivityPub::Activity::Accept when 'Reject' ActivityPub::Activity::Reject + when 'Flag' + ActivityPub::Activity::Flag end end end diff --git a/app/lib/activitypub/activity/flag.rb b/app/lib/activitypub/activity/flag.rb new file mode 100644 index 000000000..36d3c5730 --- /dev/null +++ b/app/lib/activitypub/activity/flag.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub::Activity::Flag < ActivityPub::Activity + def perform + target_accounts = object_uris.map { |uri| account_from_uri(uri) }.compact.select(&:local?) + target_statuses_by_account = object_uris.map { |uri| status_from_uri(uri) }.compact.select(&:local?).group_by(&:account_id) + + target_accounts.each do |target_account| + next if Report.where(account: @account, target_account: target_account).exists? + + target_statuses = target_statuses_by_account[target_account.id] + + ReportService.new.call( + @account, + target_account, + status_ids: target_statuses.nil? ? [] : target_statuses.map(&:id), + comment: @json['content'] || '' + ) + end + end + + def object_uris + @object_uris ||= Array(@object.is_a?(Array) ? @object.map { |item| value_or_id(item) } : value_or_id(@object)) + end +end diff --git a/app/models/report.rb b/app/models/report.rb index f55fb6d3e..dd123fc15 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -24,6 +24,10 @@ class Report < ApplicationRecord validates :comment, length: { maximum: 1000 } + def object_type + :flag + end + def statuses Status.where(id: status_ids).includes(:account, :media_attachments, :mentions) end diff --git a/app/serializers/activitypub/flag_serializer.rb b/app/serializers/activitypub/flag_serializer.rb new file mode 100644 index 000000000..53e8f726d --- /dev/null +++ b/app/serializers/activitypub/flag_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::FlagSerializer < ActiveModel::Serializer + attributes :id, :type, :actor, :content + attribute :virtual_object, key: :object + + def id + # This is nil for now + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Flag' + end + + def actor + ActivityPub::TagManager.instance.uri_for(instance_options[:account] || object.account) + end + + def virtual_object + [ActivityPub::TagManager.instance.uri_for(object.target_account)] + object.statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } + end + + def content + object.comment + end +end diff --git a/app/services/report_service.rb b/app/services/report_service.rb new file mode 100644 index 000000000..c06488a6d --- /dev/null +++ b/app/services/report_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class ReportService < BaseService + def call(source_account, target_account, options = {}) + @source_account = source_account + @target_account = target_account + @status_ids = options.delete(:status_ids) || [] + @comment = options.delete(:comment) || '' + @options = options + + create_report! + notify_staff! + forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward]) + + @report + end + + private + + def create_report! + @report = @source_account.reports.create!( + target_account: @target_account, + status_ids: @status_ids, + comment: @comment + ) + end + + def notify_staff! + User.staff.includes(:account).each do |u| + AdminMailer.new_report(u.account, @report).deliver_later + end + end + + def forward_to_origin! + ActivityPub::DeliveryWorker.perform_async( + payload, + some_local_account.id, + @target_account.inbox_url + ) + end + + def payload + Oj.dump(ActiveModelSerializers::SerializableResource.new( + @report, + serializer: ActivityPub::FlagSerializer, + adapter: ActivityPub::Adapter, + account: some_local_account + ).as_json) + end + + def some_local_account + @some_local_account ||= Account.local.where(suspended: false).first + end +end diff --git a/db/schema.rb b/db/schema.rb index 213fbc8d9..c6b81df34 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -537,6 +537,7 @@ ActiveRecord::Schema.define(version: 20180211015820) do add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id" 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 "backups", "users", on_delete: :nullify add_foreign_key "blocks", "accounts", column: "target_account_id", name: "fk_9571bfabc1", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade diff --git a/spec/lib/activitypub/activity/flag_spec.rb b/spec/lib/activitypub/activity/flag_spec.rb new file mode 100644 index 000000000..3f082a813 --- /dev/null +++ b/spec/lib/activitypub/activity/flag_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe ActivityPub::Activity::Flag do + let(:sender) { Fabricate(:account, domain: 'example.com') } + let(:flagged) { Fabricate(:account) } + let(:status) { Fabricate(:status, account: flagged, uri: 'foobar') } + + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: nil, + type: 'Flag', + content: 'Boo!!', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: [ + ActivityPub::TagManager.instance.uri_for(flagged), + ActivityPub::TagManager.instance.uri_for(status), + ], + }.with_indifferent_access + end + + describe '#perform' do + subject { described_class.new(json, sender) } + + before do + subject.perform + end + + it 'creates a report' do + report = Report.find_by(account: sender, target_account: flagged) + + expect(report).to_not be_nil + expect(report.comment).to eq 'Boo!!' + expect(report.status_ids).to eq [status.id] + end + end +end diff --git a/spec/services/report_service_spec.rb b/spec/services/report_service_spec.rb new file mode 100644 index 000000000..2f926ef00 --- /dev/null +++ b/spec/services/report_service_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe ReportService do + subject { described_class.new } + + let(:source_account) { Fabricate(:account) } + + context 'for a remote account' do + let(:remote_account) { Fabricate(:account, domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox') } + + before do + stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + end + + it 'sends ActivityPub payload when forward is true' do + subject.call(source_account, remote_account, forward: true) + expect(a_request(:post, 'http://example.com/inbox')).to have_been_made + end + + it 'does not send anything when forward is false' do + subject.call(source_account, remote_account, forward: false) + expect(a_request(:post, 'http://example.com/inbox')).to_not have_been_made + end + end +end |