about summary refs log tree commit diff
path: root/app/services
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
committerStarfall <us@starfall.systems>2022-02-13 22:15:26 -0600
commitc0341f06be5310a00b85a5d48fa80891d47c6710 (patch)
tree907ef7f787f8bd446a6d9be1448a8bcff74e5a08 /app/services
parent169688aa9f2a69ac3d36332c833e9cad43b5f7a5 (diff)
parent6f78c66fe01921a4e7e01aa6e2386a5fce7f3afd (diff)
Merge remote-tracking branch 'glitch/main'
Not at all sure where the admin UI is going to display English language
names now but OK.
Diffstat (limited to 'app/services')
-rw-r--r--app/services/activitypub/process_status_update_service.rb26
-rw-r--r--app/services/concerns/payloadable.rb18
-rw-r--r--app/services/delete_account_service.rb2
-rw-r--r--app/services/fan_out_on_write_service.rb9
-rw-r--r--app/services/notify_service.rb4
-rw-r--r--app/services/post_status_service.rb7
-rw-r--r--app/services/process_hashtags_service.rb42
-rw-r--r--app/services/remove_status_service.rb2
-rw-r--r--app/services/report_service.rb12
-rw-r--r--app/services/update_status_service.rb153
10 files changed, 231 insertions, 44 deletions
diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb
index 977928127..7438a7c53 100644
--- a/app/services/activitypub/process_status_update_service.rb
+++ b/app/services/activitypub/process_status_update_service.rb
@@ -95,10 +95,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 
       # If for some reasons the options were changed, it invalidates all previous
       # votes, so we need to remove them
-      if poll_parser.significantly_changes?(poll)
-        @poll_changed = true
-        poll.votes.delete_all unless poll.new_record?
-      end
+      @poll_changed = true if poll_parser.significantly_changes?(poll)
 
       poll.last_fetched_at = Time.now.utc
       poll.options         = poll_parser.options
@@ -106,6 +103,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
       poll.expires_at      = poll_parser.expires_at
       poll.voters_count    = poll_parser.voters_count
       poll.cached_tallies  = poll_parser.cached_tallies
+      poll.reset_votes! if @poll_changed
       poll.save!
 
       @status.poll_id = poll.id
@@ -120,7 +118,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
     @status.text         = @status_parser.text || ''
     @status.spoiler_text = @status_parser.spoiler_text || ''
     @status.sensitive    = @account.sensitized? || @status_parser.sensitive || false
-    @status.language     = @status_parser.language || detected_language
+    @status.language     = @status_parser.language
     @status.edited_at    = @status_parser.edited_at || Time.now.utc if significant_changes?
 
     @status.save!
@@ -210,10 +208,6 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
     { redis: Redis.current, key: "create:#{@uri}", autorelease: 15.minutes.seconds }
   end
 
-  def detected_language
-    LanguageDetector.instance.detect(@status_parser.text, @account)
-  end
-
   def create_previous_edit!
     # We only need to create a previous edit when no previous edits exist, e.g.
     # when the status has never been edited. For other cases, we always create
@@ -221,24 +215,18 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 
     return if @status.edits.any?
 
-    @status.edits.create(
-      text: @status.text,
-      spoiler_text: @status.spoiler_text,
+    @status.snapshot!(
       media_attachments_changed: false,
-      account_id: @account.id,
-      created_at: @status.created_at
+      at_time: @status.created_at
     )
   end
 
   def create_edit!
     return unless significant_changes?
 
-    @status_edit = @status.edits.create(
-      text: @status.text,
-      spoiler_text: @status.spoiler_text,
+    @status.snapshot!(
       media_attachments_changed: @media_attachments_changed || @poll_changed,
-      account_id: @account.id,
-      created_at: @status.edited_at
+      account_id: @account.id
     )
   end
 
diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb
index 3e45570c3..04c3798fe 100644
--- a/app/services/concerns/payloadable.rb
+++ b/app/services/concerns/payloadable.rb
@@ -1,13 +1,21 @@
 # frozen_string_literal: true
 
 module Payloadable
+  # @param [ActiveModelSerializers::Model] record
+  # @param [ActiveModelSerializers::Serializer] serializer
+  # @param [Hash] options
+  # @option options [Account] :signer
+  # @option options [String] :sign_with
+  # @option options [Boolean] :always_sign
+  # @return [Hash]
   def serialize_payload(record, serializer, options = {})
-    signer    = options.delete(:signer)
-    sign_with = options.delete(:sign_with)
-    payload   = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
-    object    = record.respond_to?(:virtual_object) ? record.virtual_object : record
+    signer      = options.delete(:signer)
+    sign_with   = options.delete(:sign_with)
+    always_sign = options.delete(:always_sign)
+    payload     = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json
+    object      = record.respond_to?(:virtual_object) ? record.virtual_object : record
 
-    if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled?
+    if (object.respond_to?(:sign?) && object.sign?) && signer && (always_sign || signing_enabled?)
       ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with)
     else
       payload
diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb
index 0e3fedfe7..a572a7c59 100644
--- a/app/services/delete_account_service.rb
+++ b/app/services/delete_account_service.rb
@@ -265,7 +265,7 @@ class DeleteAccountService < BaseService
   end
 
   def delete_actor_json
-    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account))
+    @delete_actor_json ||= Oj.dump(serialize_payload(@account, ActivityPub::DeleteActorSerializer, signer: @account, always_sign: true))
   end
 
   def delivery_inboxes
diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb
index 46feec5aa..08fae7935 100644
--- a/app/services/fan_out_on_write_service.rb
+++ b/app/services/fan_out_on_write_service.rb
@@ -34,6 +34,7 @@ class FanOutOnWriteService < BaseService
   def fan_out_to_local_recipients!
     deliver_to_self!
     notify_mentioned_accounts!
+    notify_about_update! if update?
 
     case @status.visibility.to_sym
     when :public, :unlisted, :private
@@ -66,6 +67,14 @@ class FanOutOnWriteService < BaseService
     end
   end
 
+  def notify_about_update!
+    @status.reblogged_by_accounts.merge(Account.local).select(:id).reorder(nil).find_in_batches do |accounts|
+      LocalNotificationWorker.push_bulk(accounts) do |account|
+        [account.id, @status.id, 'Status', 'update']
+      end
+    end
+  end
+
   def deliver_to_all_followers!
     @account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
       FeedInsertWorker.push_bulk(followers) do |follower|
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 0f3516d28..039e007f5 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -46,6 +46,10 @@ class NotifyService < BaseService
     false
   end
 
+  def blocked_update?
+    false
+  end
+
   def following_sender?
     return @following_sender if defined?(@following_sender)
     @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 9d26e0f5b..c5061dd63 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -2,6 +2,7 @@
 
 class PostStatusService < BaseService
   include Redisable
+  include LanguagesHelper
 
   MIN_SCHEDULE_OFFSET = 5.minutes.freeze
 
@@ -118,10 +119,6 @@ class PostStatusService < BaseService
     raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?)
   end
 
-  def language_from_option(str)
-    ISO_639.find(str)&.alpha2
-  end
-
   def process_mentions_service
     ProcessMentionsService.new
   end
@@ -174,7 +171,7 @@ class PostStatusService < BaseService
       sensitive: @sensitive,
       spoiler_text: @options[:spoiler_text] || '',
       visibility: @visibility,
-      language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
+      language: valid_locale_or_nil(@options[:language].presence || @account.user&.preferred_posting_language || I18n.default_locale),
       application: @options[:application],
       content_type: @options[:content_type] || @account.user&.setting_default_content_type,
       rate_limit: @options[:with_rate_limit],
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index 47277c56c..43d7bcca6 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,20 +1,40 @@
 # frozen_string_literal: true
 
 class ProcessHashtagsService < BaseService
-  def call(status, tags = [])
-    tags    = Extractor.extract_hashtags(status.text) if status.local?
-    records = []
-
-    Tag.find_or_create_by_names(tags) do |tag|
-      status.tags << tag
-      records << tag
-      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
+  def call(status, raw_tags = [])
+    @status        = status
+    @account       = status.account
+    @raw_tags      = status.local? ? Extractor.extract_hashtags(status.text) : raw_tags
+    @previous_tags = status.tags.to_a
+    @current_tags  = []
+
+    assign_tags!
+    update_featured_tags!
+  end
+
+  private
+
+  def assign_tags!
+    @status.tags = @current_tags = Tag.find_or_create_by_names(@raw_tags)
+  end
+
+  def update_featured_tags!
+    return unless @status.distributable?
+
+    added_tags = @current_tags - @previous_tags
+
+    unless added_tags.empty?
+      @account.featured_tags.where(tag_id: added_tags.map(&:id)).each do |featured_tag|
+        featured_tag.increment(@status.created_at)
+      end
     end
 
-    return unless status.distributable?
+    removed_tags = @previous_tags - @current_tags
 
-    status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
-      featured_tag.increment(status.created_at)
+    unless removed_tags.empty?
+      @account.featured_tags.where(tag_id: removed_tags.map(&:id)).each do |featured_tag|
+        featured_tag.decrement(@status.id)
+      end
     end
   end
 end
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index e41ad2b0a..361cd4d82 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -97,7 +97,7 @@ class RemoveStatusService < BaseService
   end
 
   def signed_activity_json
-    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
+    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account, always_sign: true))
   end
 
   def remove_reblogs
diff --git a/app/services/report_service.rb b/app/services/report_service.rb
index bc0a8b464..caf99ab6e 100644
--- a/app/services/report_service.rb
+++ b/app/services/report_service.rb
@@ -8,13 +8,15 @@ class ReportService < BaseService
     @target_account = target_account
     @status_ids     = options.delete(:status_ids) || []
     @comment        = options.delete(:comment) || ''
+    @category       = options.delete(:category) || 'other'
+    @rule_ids       = options.delete(:rule_ids)
     @options        = options
 
     raise ActiveRecord::RecordNotFound if @target_account.suspended?
 
     create_report!
     notify_staff!
-    forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
+    forward_to_origin! if forward?
 
     @report
   end
@@ -27,7 +29,9 @@ class ReportService < BaseService
       status_ids: @status_ids,
       comment: @comment,
       uri: @options[:uri],
-      forwarded: ActiveModel::Type::Boolean.new.cast(@options[:forward])
+      forwarded: forward?,
+      category: @category,
+      rule_ids: @rule_ids
     )
   end
 
@@ -48,6 +52,10 @@ class ReportService < BaseService
     )
   end
 
+  def forward?
+    !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
+  end
+
   def payload
     Oj.dump(serialize_payload(@report, ActivityPub::FlagSerializer, account: some_local_account))
   end
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
new file mode 100644
index 000000000..76530a54c
--- /dev/null
+++ b/app/services/update_status_service.rb
@@ -0,0 +1,153 @@
+# frozen_string_literal: true
+
+class UpdateStatusService < BaseService
+  include Redisable
+  include LanguagesHelper
+
+  # @param [Status] status
+  # @param [Integer] account_id
+  # @param [Hash] options
+  # @option options [Array<Integer>] :media_ids
+  # @option options [Hash] :poll
+  # @option options [String] :text
+  # @option options [String] :spoiler_text
+  # @option options [Boolean] :sensitive
+  # @option options [String] :language
+  # @option options [String] :content_type
+  def call(status, account_id, options = {})
+    @status                    = status
+    @options                   = options
+    @account_id                = account_id
+    @media_attachments_changed = false
+    @poll_changed              = false
+
+    Status.transaction do
+      create_previous_edit!
+      update_media_attachments!
+      update_poll!
+      update_immediate_attributes!
+      create_edit!
+    end
+
+    queue_poll_notifications!
+    reset_preview_card!
+    update_metadata!
+    broadcast_updates!
+
+    @status
+  end
+
+  private
+
+  def update_media_attachments!
+    previous_media_attachments = @status.media_attachments.to_a
+    next_media_attachments     = validate_media!
+    removed_media_attachments  = previous_media_attachments - next_media_attachments
+    added_media_attachments    = next_media_attachments - previous_media_attachments
+
+    MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
+    MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
+
+    @status.media_attachments.reload
+    @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
+  end
+
+  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].present?
+
+    media_attachments = @status.account.media_attachments.where(status_id: [nil, @status.id]).where(scheduled_status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)).to_a
+
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media_attachments.size > 1 && media_attachments.find(&:audio_or_video?)
+    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if media_attachments.any?(&:not_processed?)
+
+    media_attachments
+  end
+
+  def update_poll!
+    previous_poll        = @status.preloadable_poll
+    @previous_expires_at = previous_poll&.expires_at
+
+    if @options[:poll].present?
+      poll = previous_poll || @status.account.polls.new(status: @status, votes_count: 0)
+
+      # If for some reasons the options were changed, it invalidates all previous
+      # votes, so we need to remove them
+      @poll_changed = true if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
+
+      poll.options     = @options[:poll][:options]
+      poll.hide_totals = @options[:poll][:hide_totals] || false
+      poll.multiple    = @options[:poll][:multiple] || false
+      poll.expires_in  = @options[:poll][:expires_in]
+      poll.reset_votes! if @poll_changed
+      poll.save!
+
+      @status.poll_id = poll.id
+    elsif previous_poll.present?
+      previous_poll.destroy
+      @poll_changed = true
+      @status.poll_id = nil
+    end
+  end
+
+  def update_immediate_attributes!
+    @status.text         = @options[:text].presence || @options.delete(:spoiler_text) || ''
+    @status.spoiler_text = @options[:spoiler_text] || ''
+    @status.sensitive    = @options[:sensitive] || @options[:spoiler_text].present?
+    @status.language     = valid_locale_or_nil(@options[:language] || @status.language || @status.account.user&.preferred_posting_language || I18n.default_locale)
+    @status.content_type = @options[:content_type] || @status.content_type
+    @status.edited_at    = Time.now.utc
+
+    @status.save!
+  end
+
+  def reset_preview_card!
+    return unless @status.text_previously_changed?
+
+    @status.preview_cards.clear
+    LinkCrawlWorker.perform_async(@status.id)
+  end
+
+  def update_metadata!
+    ProcessHashtagsService.new.call(@status)
+    ProcessMentionsService.new.call(@status)
+  end
+
+  def broadcast_updates!
+    DistributionWorker.perform_async(@status.id, { 'update' => true })
+    ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id) unless @status.local_only?
+  end
+
+  def queue_poll_notifications!
+    poll = @status.preloadable_poll
+
+    # If the poll had no expiration date set but now has, or now has a sooner
+    # expiration date, and people have voted, schedule a notification
+
+    return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
+
+    PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
+    PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
+  end
+
+  def create_previous_edit!
+    # We only need to create a previous edit when no previous edits exist, e.g.
+    # when the status has never been edited. For other cases, we always create
+    # an edit, so the step can be skipped
+
+    return if @status.edits.any?
+
+    @status.snapshot!(
+      media_attachments_changed: false,
+      at_time: @status.created_at
+    )
+  end
+
+  def create_edit!
+    @status.snapshot!(
+      media_attachments_changed: @media_attachments_changed || @poll_changed,
+      account_id: @account_id
+    )
+  end
+end