From 1145dbd327ae9b56357cc488801d723051f58e0b Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Sep 2022 23:30:26 +0200 Subject: Improve error reporting and logging when processing remote accounts (#15605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a more descriptive PrivateNetworkAddressError exception class * Remove unnecessary exception class to rescue clause * Remove unnecessary include to JsonLdHelper * Give more neutral error message when too many webfinger redirects * Remove unnecessary guard condition * Rework how “ActivityPub::FetchRemoteAccountService” handles errors Add “suppress_errors” keyword argument to avoid raising errors in ActivityPub::FetchRemoteAccountService#call (default/previous behavior). * Rework how “ActivityPub::FetchRemoteKeyService” handles errors Add “suppress_errors” keyword argument to avoid raising errors in ActivityPub::FetchRemoteKeyService#call (default/previous behavior). * Fix Webfinger::RedirectError not being a subclass of Webfinger::Error * Add suppress_errors option to ResolveAccountService Defaults to true (to preserve previous behavior). If set to false, errors will be raised instead of caught, allowing the caller to be informed of what went wrong. * Return more precise error when failing to fetch account signing AP payloads * Add tests * Fixes * Refactor error handling a bit * Fix various issues * Add specific error when provided Digest is not 256 bits of base64-encoded data * Please CodeClimate * Improve webfinger error reporting --- .../activitypub/fetch_remote_account_service.rb | 38 +++++++++++++++------- .../activitypub/fetch_remote_key_service.rb | 34 +++++++++++++------ .../activitypub/process_account_service.rb | 2 -- app/services/resolve_account_service.rb | 12 +++---- 4 files changed, 56 insertions(+), 30 deletions(-) (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb index 9d01f5386..d7d739c59 100644 --- a/app/services/activitypub/fetch_remote_account_service.rb +++ b/app/services/activitypub/fetch_remote_account_service.rb @@ -5,10 +5,12 @@ class ActivityPub::FetchRemoteAccountService < BaseService include DomainControlHelper include WebfingerHelper + class Error < StandardError; end + SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze # Does a WebFinger roundtrip on each call, unless `only_key` is true - def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false) + def call(uri, id: true, prefetched_body: nil, break_on_redirect: false, only_key: false, suppress_errors: true) return if domain_not_allowed?(uri) return ActivityPub::TagManager.instance.uri_to_resource(uri, Account) if ActivityPub::TagManager.instance.local_uri?(uri) @@ -18,38 +20,50 @@ class ActivityPub::FetchRemoteAccountService < BaseService else body_to_json(prefetched_body, compare_id: id ? uri : nil) end + rescue Oj::ParseError + raise Error, "Error parsing JSON-LD document #{uri}" end - return if !supported_context? || !expected_type? || (break_on_redirect && @json['movedTo'].present?) + raise Error, "Error fetching actor JSON at #{uri}" if @json.nil? + raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context? + raise Error, "Unexpected object type for actor #{uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_type? + raise Error, "Actor #{uri} has moved to #{@json['movedTo']}" if break_on_redirect && @json['movedTo'].present? @uri = @json['id'] @username = @json['preferredUsername'] @domain = Addressable::URI.parse(@uri).normalized_host - return unless only_key || verified_webfinger? + check_webfinger! unless only_key ActivityPub::ProcessAccountService.new.call(@username, @domain, @json, only_key: only_key, verified_webfinger: !only_key) - rescue Oj::ParseError - nil + rescue Error => e + Rails.logger.debug "Fetching account #{uri} failed: #{e.message}" + raise unless suppress_errors end private - def verified_webfinger? + def check_webfinger! webfinger = webfinger!("acct:#{@username}@#{@domain}") confirmed_username, confirmed_domain = split_acct(webfinger.subject) - return webfinger.link('self', 'href') == @uri if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + return + end webfinger = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}") @username, @domain = split_acct(webfinger.subject) - return false unless @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero? - return false if webfinger.link('self', 'href') != @uri + unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? + raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})" + end - true - rescue Webfinger::Error - false + raise Error, "Webfinger response for #{@username}@#{@domain} does not loop back to #{@uri}" if webfinger.link('self', 'href') != @uri + rescue Webfinger::RedirectError => e + raise Error, e.message + rescue Webfinger::Error => e + raise Error, "Webfinger error when resolving #{@username}@#{@domain}: #{e.message}" end def split_acct(acct) diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb index c48288b3b..01008d883 100644 --- a/app/services/activitypub/fetch_remote_key_service.rb +++ b/app/services/activitypub/fetch_remote_key_service.rb @@ -3,9 +3,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService include JsonLdHelper + class Error < StandardError; end + # Returns account that owns the key - def call(uri, id: true, prefetched_body: nil) - return if uri.blank? + def call(uri, id: true, prefetched_body: nil, suppress_errors: true) + raise Error, 'No key URI given' if uri.blank? if prefetched_body.nil? if id @@ -13,7 +15,7 @@ class ActivityPub::FetchRemoteKeyService < BaseService if person? @json = fetch_resource(@json['id'], true) elsif uri != @json['id'] - return + raise Error, "Fetched URI #{uri} has wrong id #{@json['id']}" end else @json = fetch_resource(uri, id) @@ -22,21 +24,29 @@ class ActivityPub::FetchRemoteKeyService < BaseService @json = body_to_json(prefetched_body, compare_id: id ? uri : nil) end - return unless supported_context?(@json) && expected_type? - return find_account(@json['id'], @json) if person? + raise Error, "Unable to fetch key JSON at #{uri}" if @json.nil? + raise Error, "Unsupported JSON-LD context for document #{uri}" unless supported_context?(@json) + raise Error, "Unexpected object type for key #{uri}" unless expected_type? + return find_account(@json['id'], @json, suppress_errors) if person? @owner = fetch_resource(owner_uri, true) - return unless supported_context?(@owner) && confirmed_owner? + raise Error, "Unable to fetch actor JSON #{owner_uri}" if @owner.nil? + raise Error, "Unsupported JSON-LD context for document #{owner_uri}" unless supported_context?(@owner) + raise Error, "Unexpected object type for actor #{owner_uri} (expected any of: #{SUPPORTED_TYPES})" unless expected_owner_type? + raise Error, "publicKey id for #{owner_uri} does not correspond to #{@json['id']}" unless confirmed_owner? - find_account(owner_uri, @owner) + find_account(owner_uri, @owner, suppress_errors) + rescue Error => e + Rails.logger.debug "Fetching key #{uri} failed: #{e.message}" + raise unless suppress_errors end private - def find_account(uri, prefetched_body) + def find_account(uri, prefetched_body, suppress_errors) account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account) - account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body) + account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_body: prefetched_body, suppress_errors: suppress_errors) account end @@ -56,7 +66,11 @@ class ActivityPub::FetchRemoteKeyService < BaseService @owner_uri ||= value_or_id(@json['owner']) end + def expected_owner_type? + equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) + end + def confirmed_owner? - equals_or_includes_any?(@owner['type'], ActivityPub::FetchRemoteAccountService::SUPPORTED_TYPES) && value_or_id(@owner['publicKey']) == @json['id'] + value_or_id(@owner['publicKey']) == @json['id'] end end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 34750dba6..456b3524b 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -32,8 +32,6 @@ class ActivityPub::ProcessAccountService < BaseService process_duplicate_accounts! if @options[:verified_webfinger] end - return if @account.nil? - after_protocol_change! if protocol_changed? after_key_change! if key_changed? && !@options[:signed_with_known_key] clear_tombstones! if key_changed? diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index b55e45409..e3b370968 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true class ResolveAccountService < BaseService - include JsonLdHelper include DomainControlHelper include WebfingerHelper include Redisable @@ -13,6 +12,7 @@ class ResolveAccountService < BaseService # @param [Hash] options # @option options [Boolean] :redirected Do not follow further Webfinger redirects # @option options [Boolean] :skip_webfinger Do not attempt any webfinger query or refreshing account data + # @option options [Boolean] :suppress_errors When failing, return nil instead of raising an error # @return [Account] def call(uri, options = {}) return if uri.blank? @@ -52,15 +52,15 @@ class ResolveAccountService < BaseService # either needs to be created, or updated from fresh data fetch_account! - rescue Webfinger::Error, Oj::ParseError => e + rescue Webfinger::Error => e Rails.logger.debug "Webfinger query for #{@uri} failed: #{e}" - nil + raise unless @options[:suppress_errors] end private def process_options!(uri, options) - @options = options + @options = { suppress_errors: true }.merge(options) if uri.is_a?(Account) @account = uri @@ -96,7 +96,7 @@ class ResolveAccountService < BaseService @username, @domain = split_acct(@webfinger.subject) unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? - raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}" + raise Webfinger::RedirectError, "Too many webfinger redirects for URI #{uri} (stopped at #{@username}@#{@domain})" end rescue Webfinger::GoneError @gone = true @@ -110,7 +110,7 @@ class ResolveAccountService < BaseService return unless activitypub_ready? with_lock("resolve:#{@username}@#{@domain}") do - @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url) + @account = ActivityPub::FetchRemoteAccountService.new.call(actor_url, suppress_errors: @options[:suppress_errors]) end @account -- cgit From 7b38cb88caa46a47eb7b18f2211ef768923568aa Mon Sep 17 00:00:00 2001 From: Claire Date: Tue, 20 Sep 2022 23:49:00 +0200 Subject: Fix ProcessMentionService swallowing unprocessed mentions to unconfirmed/unapproved users (#19191) --- app/services/process_mentions_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 8c63b611d..c9c158af1 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -38,7 +38,7 @@ class ProcessMentionsService < BaseService mentioned_account = Account.find_remote(username, domain) # Unapproved and unconfirmed accounts should not be mentionable - next if mentioned_account&.local? && !(mentioned_account.user_confirmed? && mentioned_account.user_approved?) + next match if mentioned_account&.local? && !(mentioned_account.user_confirmed? && mentioned_account.user_approved?) # If the account cannot be found or isn't the right protocol, # first try to resolve it -- cgit From 50948b46aabc0756d85bc6641f0bd3bcc09bf7d4 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 20 Sep 2022 23:51:21 +0200 Subject: Add ability to filter followed accounts' posts by language (#19095) --- app/controllers/api/v1/accounts_controller.rb | 6 +- .../mastodon/features/account/components/header.js | 5 + .../features/account_timeline/components/header.js | 6 + .../containers/header_container.js | 8 +- .../features/subscribed_languages_modal/index.js | 121 +++++++++++++++++++++ .../mastodon/features/ui/components/modal_root.js | 2 + .../mastodon/locales/defaultMessages.json | 25 +++++ app/javascript/mastodon/locales/en.json | 4 + app/lib/feed_manager.rb | 2 + app/models/concerns/account_interactions.rb | 23 ++-- app/models/export.rb | 4 +- app/models/follow.rb | 4 +- app/models/follow_request.rb | 4 +- app/serializers/rest/relationship_serializer.rb | 7 +- app/services/follow_service.rb | 13 ++- app/services/import_service.rb | 2 +- app/validators/language_validator.rb | 21 ++++ app/workers/refollow_worker.rb | 7 +- app/workers/unfollow_follow_worker.rb | 9 +- .../20220829192633_add_languages_to_follows.rb | 5 + ...20829192658_add_languages_to_follow_requests.rb | 5 + db/schema.rb | 4 +- .../controllers/api/v1/accounts_controller_spec.rb | 11 ++ .../exports/following_accounts_controller_spec.rb | 2 +- spec/lib/feed_manager_spec.rb | 12 ++ spec/models/concerns/account_interactions_spec.rb | 2 +- spec/models/export_spec.rb | 4 +- spec/models/follow_request_spec.rb | 2 +- spec/services/follow_service_spec.rb | 13 +++ spec/workers/refollow_worker_spec.rb | 4 +- 30 files changed, 298 insertions(+), 39 deletions(-) create mode 100644 app/javascript/mastodon/features/subscribed_languages_modal/index.js create mode 100644 app/validators/language_validator.rb create mode 100644 db/migrate/20220829192633_add_languages_to_follows.rb create mode 100644 db/migrate/20220829192658_add_languages_to_follow_requests.rb (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 5537cc9b0..be84720aa 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -30,12 +30,12 @@ class Api::V1::AccountsController < Api::BaseController self.response_body = Oj.dump(response.body) self.status = response.status rescue ActiveRecord::RecordInvalid => e - render json: ValidationErrorFormatter.new(e, :'account.username' => :username, :'invite_request.text' => :reason).as_json, status: :unprocessable_entity + render json: ValidationErrorFormatter.new(e, 'account.username': :username, 'invite_request.text': :reason).as_json, status: :unprocessable_entity end def follow - follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) - options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } + follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true) + options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 1ad9341c7..8f2753c35 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -51,6 +51,7 @@ const messages = defineMessages({ unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, + languages: { id: 'account.languages', defaultMessage: 'Change subscribed languages' }, }); const dateFormatOptions = { @@ -85,6 +86,7 @@ class Header extends ImmutablePureComponent { onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, onEditAccountNote: PropTypes.func.isRequired, + onChangeLanguages: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, @@ -212,6 +214,9 @@ class Header extends ImmutablePureComponent { } else { menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); } + + menu.push({ text: intl.formatMessage(messages.languages), action: this.props.onChangeLanguages }); + menu.push(null); } menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle }); diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index fab0bc597..f9838442f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onChangeLanguages: PropTypes.func.isRequired, hideTabs: PropTypes.bool, domain: PropTypes.string.isRequired, hidden: PropTypes.bool, @@ -91,6 +92,10 @@ export default class Header extends ImmutablePureComponent { this.props.onEditAccountNote(this.props.account); } + handleChangeLanguages = () => { + this.props.onChangeLanguages(this.props.account); + } + render () { const { account, hidden, hideTabs } = this.props; @@ -117,6 +122,7 @@ export default class Header extends ImmutablePureComponent { onEndorseToggle={this.handleEndorseToggle} onAddToList={this.handleAddToList} onEditAccountNote={this.handleEditAccountNote} + onChangeLanguages={this.handleChangeLanguages} domain={this.props.domain} hidden={hidden} /> diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 371794dd7..3d6eb487d 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -121,12 +121,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(unblockDomain(domain)); }, - onAddToList(account){ + onAddToList (account) { dispatch(openModal('LIST_ADDER', { accountId: account.get('id'), })); }, + onChangeLanguages (account) { + dispatch(openModal('SUBSCRIBED_LANGUAGES', { + accountId: account.get('id'), + })); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/subscribed_languages_modal/index.js b/app/javascript/mastodon/features/subscribed_languages_modal/index.js new file mode 100644 index 000000000..6a1bb2c47 --- /dev/null +++ b/app/javascript/mastodon/features/subscribed_languages_modal/index.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { is, List as ImmutableList, Set as ImmutableSet } from 'immutable'; +import { languages as preloadedLanguages } from 'mastodon/initial_state'; +import Option from 'mastodon/features/report/components/option'; +import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; +import IconButton from 'mastodon/components/icon_button'; +import Button from 'mastodon/components/button'; +import { followAccount } from 'mastodon/actions/accounts'; + +const messages = defineMessages({ + close: { id: 'lightbox.close', defaultMessage: 'Close' }, +}); + +const getAccountLanguages = createSelector([ + (state, accountId) => state.getIn(['timelines', `account:${accountId}`, 'items'], ImmutableList()), + state => state.get('statuses'), +], (statusIds, statuses) => + new ImmutableSet(statusIds.map(statusId => statuses.get(statusId)).filter(status => !status.get('reblog')).map(status => status.get('language')))); + +const mapStateToProps = (state, { accountId }) => ({ + acct: state.getIn(['accounts', accountId, 'acct']), + availableLanguages: getAccountLanguages(state, accountId), + selectedLanguages: ImmutableSet(state.getIn(['relationships', accountId, 'languages']) || ImmutableList()), +}); + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + + onSubmit (languages) { + dispatch(followAccount(accountId, { languages })); + }, + +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class SubscribedLanguagesModal extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + acct: PropTypes.string.isRequired, + availableLanguages: ImmutablePropTypes.setOf(PropTypes.string), + selectedLanguages: ImmutablePropTypes.setOf(PropTypes.string), + onClose: PropTypes.func.isRequired, + languages: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + intl: PropTypes.object.isRequired, + submit: PropTypes.func.isRequired, + }; + + static defaultProps = { + languages: preloadedLanguages, + }; + + state = { + selectedLanguages: this.props.selectedLanguages, + }; + + handleLanguageToggle = (value, checked) => { + const { selectedLanguages } = this.state; + + if (checked) { + this.setState({ selectedLanguages: selectedLanguages.add(value) }); + } else { + this.setState({ selectedLanguages: selectedLanguages.delete(value) }); + } + }; + + handleSubmit = () => { + this.props.onSubmit(this.state.selectedLanguages.toArray()); + this.props.onClose(); + } + + renderItem (value) { + const language = this.props.languages.find(language => language[0] === value); + const checked = this.state.selectedLanguages.includes(value); + + return ( +