about summary refs log tree commit diff
path: root/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/services')
-rw-r--r--app/services/activitypub/fetch_remote_poll_service.rb57
-rw-r--r--app/services/activitypub/fetch_replies_service.rb58
-rw-r--r--app/services/post_status_service.rb11
-rw-r--r--app/services/resolve_url_service.rb2
-rw-r--r--app/services/suspend_account_service.rb19
-rw-r--r--app/services/vote_service.rb42
6 files changed, 186 insertions, 3 deletions
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..4f9814fcd
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_poll_service.rb
@@ -0,0 +1,57 @@
+# 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'].nil? && !@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
+
+    begin
+      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 }
+      )
+    rescue ActiveRecord::StaleObjectError
+      poll.reload
+      retry
+    end
+  end
+
+  private
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    equals_or_includes_any?(@json['type'], %w(Question))
+  end
+end
diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb
new file mode 100644
index 000000000..569d0d7c1
--- /dev/null
+++ b/app/services/activitypub/fetch_replies_service.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRepliesService < BaseService
+  include JsonLdHelper
+
+  def call(parent_status, collection_or_uri, allow_synchronous_requests = true)
+    @account = parent_status.account
+    @allow_synchronous_requests = allow_synchronous_requests
+
+    @items = collection_items(collection_or_uri)
+    return if @items.nil?
+
+    FetchReplyWorker.push_bulk(filtered_replies)
+
+    @items
+  end
+
+  private
+
+  def collection_items(collection_or_uri)
+    collection = fetch_collection(collection_or_uri)
+    return unless collection.is_a?(Hash)
+
+    collection = fetch_collection(collection['first']) if collection['first'].present?
+    return unless collection.is_a?(Hash)
+
+    case collection['type']
+    when 'Collection', 'CollectionPage'
+      collection['items']
+    when 'OrderedCollection', 'OrderedCollectionPage'
+      collection['orderedItems']
+    end
+  end
+
+  def fetch_collection(collection_or_uri)
+    return collection_or_uri if collection_or_uri.is_a?(Hash)
+    return unless @allow_synchronous_requests
+    return if invalid_origin?(collection_or_uri)
+    fetch_resource_without_id_validation(collection_or_uri, nil, true)
+  end
+
+  def filtered_replies
+    # Only fetch replies to the same server as the original status to avoid
+    # amplification attacks.
+
+    # Also limit to 5 fetched replies to limit potential for DoS.
+    @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5)
+  end
+
+  def invalid_origin?(url)
+    return true if unsupported_uri_scheme?(url)
+
+    needle   = Addressable::URI.parse(url).host
+    haystack = Addressable::URI.parse(@account.uri).host
+
+    !haystack.casecmp(needle).zero?
+  end
+end
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index cfb266fbb..8a9d26c56 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?
@@ -98,13 +100,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].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
@@ -157,6 +165,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/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
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index fc3bc03a5..24fa1be69 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -41,6 +41,7 @@ class SuspendAccountService < BaseService
     @account = account
     @options = options
 
+    reject_follows!
     purge_user!
     purge_profile!
     purge_content!
@@ -48,6 +49,14 @@ class SuspendAccountService < BaseService
 
   private
 
+  def reject_follows!
+    return if @account.local? || !@account.activitypub?
+
+    ActivityPub::DeliveryWorker.push_bulk(Follow.where(account: @account)) do |follow|
+      [build_reject_json(follow), follow.target_account_id, follow.account.inbox_url]
+    end
+  end
+
   def purge_user!
     return if !@account.local? || @account.user.nil?
 
@@ -84,7 +93,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
@@ -120,6 +129,14 @@ class SuspendAccountService < BaseService
     @delete_actor_json = Oj.dump(ActivityPub::LinkedDataSignature.new(payload).sign!(@account))
   end
 
+  def build_reject_json(follow)
+    ActiveModelSerializers::SerializableResource.new(
+      follow,
+      serializer: ActivityPub::RejectFollowSerializer,
+      adapter: ActivityPub::Adapter
+    ).to_json
+  end
+
   def delivery_inboxes
     @delivery_inboxes ||= @account.followers.inboxes + Relay.enabled.pluck(:inbox_url)
   end
diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb
new file mode 100644
index 000000000..5b80da03a
--- /dev/null
+++ b/app/services/vote_service.rb
@@ -0,0 +1,42 @@
+# 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   = []
+
+    return if @poll.expired?
+
+    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