From 230a012f0090c496fc5cdb011bcc8ed732fd0f5c Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 3 Mar 2019 22:18:23 +0100 Subject: Add polls (#10111) * Add polls Fix #1629 * Add tests * Fixes * Change API for creating polls * Use name instead of content for votes * Remove poll validation for remote polls * Add polls to public pages * When updating the poll, update options just in case they were changed * Fix public pages showing both poll and other media --- .../activitypub/fetch_remote_poll_service.rb | 51 ++++++++++++++++++++++ app/services/post_status_service.rb | 11 ++++- app/services/vote_service.rb | 40 +++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 app/services/activitypub/fetch_remote_poll_service.rb create mode 100644 app/services/vote_service.rb (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb new file mode 100644 index 000000000..6f0ac5624 --- /dev/null +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class ActivityPub::FetchRemotePollService < BaseService + include JsonLdHelper + + def call(poll, on_behalf_of = nil) + @json = fetch_resource(poll.status.uri, true, on_behalf_of) + + return unless supported_context? && expected_type? + + expires_at = begin + if @json['closed'].is_a?(String) + @json['closed'] + elsif !@json['closed'].is_a?(FalseClass) + Time.now.utc + else + @json['endTime'] + end + end + + items = begin + if @json['anyOf'].is_a?(Array) + @json['anyOf'] + else + @json['oneOf'] + end + end + + latest_options = items.map { |item| item['name'].presence || item['content'] } + + # If for some reasons the options were changed, it invalidates all previous + # votes, so we need to remove them + poll.votes.delete_all if latest_options != poll.options + + poll.update!( + expires_at: expires_at, + options: latest_options, + cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } + ) + end + + private + + def supported_context? + super(@json) + end + + def expected_type? + equals_or_includes_any?(@json['type'], 'Question') + end +end diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 686b10c58..aed680672 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -15,6 +15,7 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key @@ -28,6 +29,7 @@ class PostStatusService < BaseService return idempotency_duplicate if idempotency_given? && idempotency_duplicate? validate_media! + validate_poll! preprocess_attributes! if scheduled? @@ -93,13 +95,19 @@ class PostStatusService < BaseService def validate_media! return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) - raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll_id].present? @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)) raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if @media.size > 1 && @media.find(&:video?) end + def validate_poll! + return if @options[:poll].blank? + + @poll = @account.polls.new(@options[:poll]) + end + def language_from_option(str) ISO_639.find(str)&.alpha2 end @@ -152,6 +160,7 @@ class PostStatusService < BaseService text: @text, media_attachments: @media || [], thread: @in_reply_to, + owned_poll: @poll, sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?, spoiler_text: @options[:spoiler_text] || '', visibility: @visibility, diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb new file mode 100644 index 000000000..8bab2810e --- /dev/null +++ b/app/services/vote_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class VoteService < BaseService + include Authorization + + def call(account, poll, choices) + authorize_with account, poll, :vote? + + @account = account + @poll = poll + @choices = choices + @votes = [] + + ApplicationRecord.transaction do + @choices.each do |choice| + @votes << @poll.votes.create!(account: @account, choice: choice) + end + end + + return if @poll.account.local? + + @votes.each do |vote| + ActivityPub::DeliveryWorker.perform_async( + build_json(vote), + @account.id, + @poll.account.inbox_url + ) + end + end + + private + + def build_json(vote) + ActiveModelSerializers::SerializableResource.new( + vote, + serializer: ActivityPub::VoteSerializer, + adapter: ActivityPub::Adapter + ).to_json + end +end -- cgit From e13d3792f322c6313fde10d639b13bc31723ec63 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 4 Mar 2019 00:39:06 +0100 Subject: Make sure the poll is created before storing its id (#10142) * Make sure the poll is created before storing its id * Fix updating poll results * Support fetching Question activities from the search bar --- app/models/status.rb | 4 ++-- app/services/activitypub/fetch_remote_poll_service.rb | 2 +- app/services/resolve_url_service.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'app/services') diff --git a/app/models/status.rb b/app/models/status.rb index db3c130de..74deeeb50 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -255,7 +255,7 @@ class Status < ApplicationRecord before_validation :set_conversation before_validation :set_local - before_save :set_poll_id + after_create :set_poll_id class << self def selectable_visibilities @@ -446,7 +446,7 @@ class Status < ApplicationRecord end def set_poll_id - self.poll_id = owned_poll.id unless owned_poll.nil? + update_column(:poll_id, owned_poll.id) unless owned_poll.nil? end def set_visibility diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 6f0ac5624..ea75e8ef9 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -46,6 +46,6 @@ class ActivityPub::FetchRemotePollService < BaseService end def expected_type? - equals_or_includes_any?(@json['type'], 'Question') + equals_or_includes_any?(@json['type'], %w(Question)) end end diff --git a/app/services/resolve_url_service.rb b/app/services/resolve_url_service.rb index ed0c56923..b98759bf6 100644 --- a/app/services/resolve_url_service.rb +++ b/app/services/resolve_url_service.rb @@ -20,7 +20,7 @@ class ResolveURLService < BaseService def process_url if equals_or_includes_any?(type, %w(Application Group Organization Person Service)) FetchRemoteAccountService.new.call(atom_url, body, protocol) - elsif equals_or_includes_any?(type, %w(Note Article Image Video Page)) + elsif equals_or_includes_any?(type, %w(Note Article Image Video Page Question)) FetchRemoteStatusService.new.call(atom_url, body, protocol) end end -- cgit From f821eca3b3ed5f3fe8d1656a3ed6d6d2c0435f96 Mon Sep 17 00:00:00 2001 From: ThibG Date: Mon, 4 Mar 2019 00:40:21 +0100 Subject: Correctly make polls and media mutually exclusive (#10141) --- app/services/post_status_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index aed680672..c045a553e 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -95,7 +95,7 @@ class PostStatusService < BaseService def validate_media! return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable) - raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll_id].present? + raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present? @media = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)) -- cgit From ae1b9cf70a5c7426054947bef8cc836fd402c173 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 4 Mar 2019 00:44:34 +0100 Subject: Fix remote poll expiration time (#10144) --- app/lib/activitypub/activity/create.rb | 2 +- app/services/activitypub/fetch_remote_poll_service.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'app/services') diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 793e20dbe..08c46be44 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -216,7 +216,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity expires_at = begin if @object['closed'].is_a?(String) @object['closed'] - elsif !@object['closed'].is_a?(FalseClass) + elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) Time.now.utc else @object['endTime'] diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index ea75e8ef9..2f40625d6 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -11,7 +11,7 @@ class ActivityPub::FetchRemotePollService < BaseService expires_at = begin if @json['closed'].is_a?(String) @json['closed'] - elsif !@json['closed'].is_a?(FalseClass) + elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) Time.now.utc else @json['endTime'] -- cgit From 878a75ba215e061c91516057f79a5dc96b84f426 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 4 Mar 2019 00:50:56 +0100 Subject: Fix typo in ActivityPub::FetchRemotePollService (#10145) --- app/services/activitypub/fetch_remote_poll_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 2f40625d6..3701f8339 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -11,7 +11,7 @@ class ActivityPub::FetchRemotePollService < BaseService expires_at = begin if @json['closed'].is_a?(String) @json['closed'] - elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass) + elsif !@json['closed'].nil? && !@object['closed'].is_a?(FalseClass) Time.now.utc else @json['endTime'] -- cgit From e6900b167b046c846d1276f5bf86b675542bc09b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 4 Mar 2019 00:52:18 +0100 Subject: Fix another typo in ActivityPub::FetchRemotePollService (#10146) --- app/services/activitypub/fetch_remote_poll_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index 3701f8339..c87a2f84d 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -11,7 +11,7 @@ class ActivityPub::FetchRemotePollService < BaseService expires_at = begin if @json['closed'].is_a?(String) @json['closed'] - elsif !@json['closed'].nil? && !@object['closed'].is_a?(FalseClass) + elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass) Time.now.utc else @json['endTime'] -- cgit From 4037b5eb1eca4858c9f1a93ccafb87a6c849e65b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 5 Mar 2019 04:10:01 +0100 Subject: Fix last_fetched_at not being set on polls (#10170) --- app/services/activitypub/fetch_remote_poll_service.rb | 1 + 1 file changed, 1 insertion(+) (limited to 'app/services') diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb index c87a2f84d..1dd587d73 100644 --- a/app/services/activitypub/fetch_remote_poll_service.rb +++ b/app/services/activitypub/fetch_remote_poll_service.rb @@ -33,6 +33,7 @@ class ActivityPub::FetchRemotePollService < BaseService poll.votes.delete_all if latest_options != poll.options poll.update!( + last_fetched_at: Time.now.utc, expires_at: expires_at, options: latest_options, cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 } -- cgit From d785497ba5ed44e794dd67660b8779380f81ef42 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 5 Mar 2019 15:19:54 +0100 Subject: Fix suspended account's fields being set as empty dict instead of list (#10178) Fixes #10177 --- app/models/account.rb | 1 + app/services/suspend_account_service.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/models/account.rb b/app/models/account.rb index 87ce90178..b1abd8f0d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -241,6 +241,7 @@ class Account < ApplicationRecord def fields_attributes=(attributes) fields = [] old_fields = self[:fields] || [] + old_fields = [] if old_fields.is_a?(Hash) if attributes.is_a?(Hash) attributes.each_value do |attr| diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index fc3bc03a5..b2ae3a47c 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -84,7 +84,7 @@ class SuspendAccountService < BaseService @account.locked = false @account.display_name = '' @account.note = '' - @account.fields = {} + @account.fields = [] @account.statuses_count = 0 @account.followers_count = 0 @account.following_count = 0 -- cgit From df5924a1db08f362fcc8cf873ffaed72a2ce9f19 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 5 Mar 2019 15:21:14 +0100 Subject: Do not error out on unsalvageable errors in FetchRepliesService (#10175) * Do not error out on unsalvageable errors in FetchRepliesService Fixes #10152 * Fix FetchRepliesWorker erroring out on deleted statuses --- app/helpers/jsonld_helper.rb | 16 +++++++++++++++- app/services/activitypub/fetch_replies_service.rb | 4 +--- app/workers/activitypub/fetch_replies_worker.rb | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) (limited to 'app/services') diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 59e4ae685..f0a19e332 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -63,13 +63,19 @@ module JsonLdHelper json.present? && json['id'] == uri ? json : nil end - def fetch_resource_without_id_validation(uri, on_behalf_of = nil) + def fetch_resource_without_id_validation(uri, on_behalf_of = nil, raise_on_temporary_error = false) build_request(uri, on_behalf_of).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end return body_to_json(response.body_with_limit) if response.code == 200 end # If request failed, retry without doing it on behalf of a user return if on_behalf_of.nil? build_request(uri).perform do |response| + unless response_successful?(response) || response_error_unsalvageable?(response) || !raise_on_temporary_error + raise Mastodon::UnexpectedResponseError, response + end response.code == 200 ? body_to_json(response.body_with_limit) : nil end end @@ -92,6 +98,14 @@ module JsonLdHelper private + def response_successful?(response) + (200...300).cover?(response.code) + end + + def response_error_unsalvageable?(response) + (400...500).cover?(response.code) && response.code != 429 + end + def build_request(uri, on_behalf_of = nil) request = Request.new(:get, uri) request.on_behalf_of(on_behalf_of) if on_behalf_of diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb index 95c486a43..569d0d7c1 100644 --- a/app/services/activitypub/fetch_replies_service.rb +++ b/app/services/activitypub/fetch_replies_service.rb @@ -36,9 +36,7 @@ class ActivityPub::FetchRepliesService < BaseService return collection_or_uri if collection_or_uri.is_a?(Hash) return unless @allow_synchronous_requests return if invalid_origin?(collection_or_uri) - collection = fetch_resource_without_id_validation(collection_or_uri) - raise Mastodon::UnexpectedResponseError if collection.nil? - collection + fetch_resource_without_id_validation(collection_or_uri, nil, true) end def filtered_replies diff --git a/app/workers/activitypub/fetch_replies_worker.rb b/app/workers/activitypub/fetch_replies_worker.rb index bf466db54..54d98f228 100644 --- a/app/workers/activitypub/fetch_replies_worker.rb +++ b/app/workers/activitypub/fetch_replies_worker.rb @@ -8,5 +8,7 @@ class ActivityPub::FetchRepliesWorker def perform(parent_status_id, replies_uri) ActivityPub::FetchRepliesService.new.call(Status.find(parent_status_id), replies_uri) + rescue ActiveRecord::RecordNotFound + true end end -- cgit