about summary refs log tree commit diff
path: root/app/services
diff options
context:
space:
mode:
authorFire Demon <firedemon@creature.cafe>2020-06-30 17:33:55 -0500
committerFire Demon <firedemon@creature.cafe>2020-08-30 05:41:03 -0500
commiteaf9bc1a428b338ee666f1da1e32eed7e3b6b25e (patch)
treeaeec5fdde79d6e4fa354da326a540811b5576907 /app/services
parent5d5d88e4f65df4c190afeb407167c153584be108 (diff)
[Feature] Add in-place post editing
Diffstat (limited to 'app/services')
-rw-r--r--app/services/post_status_service.rb48
-rw-r--r--app/services/process_hashtags_service.rb8
-rw-r--r--app/services/process_mentions_service.rb47
-rw-r--r--app/services/remove_media_attachments_service.rb11
-rw-r--r--app/services/resolve_mentions_service.rb73
-rw-r--r--app/services/revoke_status_service.rb104
-rw-r--r--app/services/update_status_service.rb140
7 files changed, 377 insertions, 54 deletions
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 250d0e8ed..c52ca4a9b 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -20,6 +20,9 @@ class PostStatusService < BaseService
   # @option [Doorkeeper::Application] :application
   # @option [String] :idempotency Optional idempotency key
   # @option [Boolean] :with_rate_limit
+  # @option [Status] :status Edit an existing status
+  # @option [Enumerable] :mentions Optional array of Mentions to include
+  # @option [Enumerable] :tags Option array of tag names to include
   # @return [Status]
   def call(account, options = {})
     @account     = account
@@ -27,6 +30,11 @@ class PostStatusService < BaseService
     @text        = @options[:text] || ''
     @in_reply_to = @options[:thread]
 
+    raise Mastodon::NotPermittedError if different_author?
+
+    @tag_names   = (@options[:tags] || []).select { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+    @mentions    = @options[:mentions] || []
+
     return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
 
     validate_media!
@@ -34,6 +42,8 @@ class PostStatusService < BaseService
 
     if scheduled?
       schedule_status!
+    elsif @options[:status].present? && status_exists?
+      update_status!
     else
       process_status!
       postprocess_status!
@@ -49,14 +59,14 @@ class PostStatusService < BaseService
 
   def preprocess_attributes!
     if @text.blank? && @options[:spoiler_text].present?
-     @text = '.'
-     if @media&.find(&:video?) || @media&.find(&:gifv?)
-       @text = '📹'
-     elsif @media&.find(&:audio?)
-       @text = '🎵'
-     elsif @media&.find(&:image?)
-       @text = '🖼'
-     end
+      @text = '.'
+      if @media&.find(&:video?) || @media&.find(&:gifv?)
+        @text = '📹'
+      elsif @media&.find(&:audio?)
+        @text = '🎵'
+      elsif @media&.find(&:image?)
+        @text = '🖼'
+      end
     end
     @sensitive    = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?
     @visibility   = @options[:visibility] || @account.user&.setting_default_privacy
@@ -75,8 +85,8 @@ class PostStatusService < BaseService
       @status = @account.statuses.create!(status_attributes)
     end
 
-    process_hashtags_service.call(@status)
-    process_mentions_service.call(@status)
+    process_hashtags_service.call(@status, nil, @tag_names)
+    process_mentions_service.call(@status, mentions: @mentions)
   end
 
   def schedule_status!
@@ -103,12 +113,18 @@ class PostStatusService < BaseService
     PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
   end
 
+  def update_status!
+    tags = Tag.find_or_create_by_names(@tag_names)
+    @status = UpdateStatusService.new.call(@options[:status], status_attributes, @mentions, tags)
+  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 = @account.media_attachments.where(status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i))
+    @media = @options[:status].present? ? @account.media_attachments.where(status_id: [nil, @options[:status].id]) : @account.media_attachments.where(status_id: nil)
+    @media = @media.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(&:audio_or_video?)
     raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if @media.any?(&:not_processed?)
@@ -198,6 +214,16 @@ class PostStatusService < BaseService
       options_hash[:scheduled_at]    = nil
       options_hash[:idempotency]     = nil
       options_hash[:with_rate_limit] = false
+      options_hash[:mention_ids]     = options_hash.delete(:mentions)&.pluck(:id)
+      options_hash[:status_id]       = options_hash.delete(:status)&.id
     end
   end
+
+  def different_author?
+    @options[:status].present? && @options[:status].account_id != @account.id
+  end
+
+  def status_exists?
+    !(@options[:status].discarded? || @options[:status].destroyed?)
+  end
 end
diff --git a/app/services/process_hashtags_service.rb b/app/services/process_hashtags_service.rb
index e8e139b05..1f0d64323 100644
--- a/app/services/process_hashtags_service.rb
+++ b/app/services/process_hashtags_service.rb
@@ -1,11 +1,15 @@
 # frozen_string_literal: true
 
 class ProcessHashtagsService < BaseService
-  def call(status, tags = [])
-    tags    = Extractor.extract_hashtags(status.text) if status.local?
+  def call(status, tags = nil, extra_tags = [])
+    tags ||= extra_tags | (status.local? ? Extractor.extract_hashtags(status.text) : [])
     records = []
 
+    tag_ids = status.tag_ids.to_set
+
     Tag.find_or_create_by_names(tags) do |tag|
+      next if tag_ids.include?(tag.id)
+
       status.tags << tag
       records << tag
 
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index f45422970..f3ce81ef1 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -7,42 +7,15 @@ class ProcessMentionsService < BaseService
   # local mention pointers, send Salmon notifications to mentioned
   # remote users
   # @param [Status] status
-  def call(status)
+  # @option [Enumerable] :mentions Mentions to include
+  # @option [Boolean] :reveal_implicit_mentions Append implicit mentions to text
+  def call(status, mentions: [], reveal_implicit_mentions: true)
     return unless status.local?
 
-    @status  = status
-    mentions = []
+    @status = status
+    @status.text, mentions = ResolveMentionsService.new.call(@status, mentions: mentions, reveal_implicit_mentions: reveal_implicit_mentions)
+    @status.save!
 
-    status.text = status.text.gsub(Account::MENTION_RE) do |match|
-      username, domain = Regexp.last_match(1).split('@')
-
-      domain = begin
-        if TagManager.instance.local_domain?(domain)
-          nil
-        else
-          TagManager.instance.normalize_domain(domain)
-        end
-      end
-
-      mentioned_account = Account.find_remote(username, domain)
-
-      if mention_undeliverable?(mentioned_account)
-        begin
-          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
-        rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
-          mentioned_account = nil
-        end
-      end
-
-      next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
-
-      mention = mentioned_account.mentions.new(status: status)
-      mentions << mention if mention.save
-
-      "@#{mentioned_account.acct}"
-    end
-
-    status.save!
     check_for_spam(status)
 
     mentions.each { |mention| create_notification(mention) }
@@ -50,10 +23,6 @@ class ProcessMentionsService < BaseService
 
   private
 
-  def mention_undeliverable?(mentioned_account)
-    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
-  end
-
   def create_notification(mention)
     mentioned_account = mention.account
 
@@ -69,10 +38,6 @@ class ProcessMentionsService < BaseService
     @activitypub_json = Oj.dump(serialize_payload(ActivityPub::ActivityPresenter.from_status(@status), ActivityPub::ActivitySerializer, signer: @status.account))
   end
 
-  def resolve_account_service
-    ResolveAccountService.new
-  end
-
   def check_for_spam(status)
     SpamCheck.perform(status)
   end
diff --git a/app/services/remove_media_attachments_service.rb b/app/services/remove_media_attachments_service.rb
new file mode 100644
index 000000000..de3cd9afb
--- /dev/null
+++ b/app/services/remove_media_attachments_service.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class RemoveMediaAttachmentsService < BaseService
+  # Remove a list of media attachments by their IDs
+  # @param [Enumerable] attachment_ids
+  def call(attachment_ids)
+    media_attachments = MediaAttachment.where(id: attachment_ids)
+    media_attachments.map(&:id).each { |id| Rails.cache.delete_matched("statuses/#{id}-*") }
+    media_attachments.destroy_all
+  end
+end
diff --git a/app/services/resolve_mentions_service.rb b/app/services/resolve_mentions_service.rb
new file mode 100644
index 000000000..cb00b5c19
--- /dev/null
+++ b/app/services/resolve_mentions_service.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+class ResolveMentionsService < BaseService
+  # Scan text for mentions and create local mention pointers
+  # @param [Status] status Status to attach to mention pointers
+  # @option [String] :text Text containing mentions to resolve (default: use status text)
+  # @option [Enumerable] :mentions Additional mentions to include
+  # @option [Boolean] :reveal_implicit_mentions Append implicit mentions to text
+  # @return [Array] Array containing text with mentions resolved (String) and mention pointers (Set)
+  def call(status, text: nil, mentions: [], reveal_implicit_mentions: true)
+    mentions                  = Mention.includes(:account).where(id: mentions.pluck(:id), accounts: { suspended_at: nil }).to_set
+    implicit_mention_acct_ids = mentions.pluck(:account_id).to_set
+    text                      = status.text if text.nil?
+
+    text.gsub(Account::MENTION_RE) do |match|
+      username, domain = Regexp.last_match(1).split('@')
+
+      domain = begin
+        if TagManager.instance.local_domain?(domain)
+          nil
+        else
+          TagManager.instance.normalize_domain(domain)
+        end
+      end
+
+      mentioned_account = Account.find_remote(username, domain)
+
+      if mention_undeliverable?(mentioned_account)
+        begin
+          mentioned_account = resolve_account_service.call(Regexp.last_match(1))
+        rescue Goldfinger::Error, HTTP::Error, OpenSSL::SSL::SSLError, Mastodon::UnexpectedResponseError
+          mentioned_account = nil
+        end
+      end
+
+      next match if mention_undeliverable?(mentioned_account) || mentioned_account&.suspended?
+
+      mentions << mentioned_account.mentions.where(status: status).first_or_create(status: status)
+      implicit_mention_acct_ids.delete(mentioned_account.id)
+
+      "@#{mentioned_account.acct}"
+    end
+
+    if reveal_implicit_mentions && implicit_mention_acct_ids.present?
+      implicit_mention_accts = Account.where(id: implicit_mention_acct_ids, suspended_at: nil)
+      formatted_accts = format_mentions(implicit_mention_accts)
+      formatted_accts = Formatter.instance.linkify(formatted_accts, implicit_mention_accts) unless status.local?
+      text << formatted_accts
+    end
+
+    [text, mentions]
+  end
+
+  private
+
+  def mention_undeliverable?(mentioned_account)
+    mentioned_account.nil? || (!mentioned_account.local? && mentioned_account.ostatus?)
+  end
+
+  def resolve_account_service
+    ResolveAccountService.new
+  end
+
+  def format_mentions(accounts)
+    "\n\n#{accounts_to_mentions(accounts).join(' ')}"
+  end
+
+  def accounts_to_mentions(accounts)
+    accounts.reorder(:username, :domain).pluck(:username, :domain).map do |username, domain|
+      domain.blank? ? "@#{username}" : "@#{username}@#{domain}"
+    end
+  end
+end
diff --git a/app/services/revoke_status_service.rb b/app/services/revoke_status_service.rb
new file mode 100644
index 000000000..b7d7a6e18
--- /dev/null
+++ b/app/services/revoke_status_service.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+class RevokeStatusService < BaseService
+  include Redisable
+  include Payloadable
+
+  # Unpublish a status from a given set of local accounts' timelines and public, if visibility changed.
+  # @param   [Status] status
+  # @param   [Enumerable] account_ids
+  def call(status, account_ids)
+    @payload      = Oj.dump(event: :delete, payload: status.id.to_s)
+    @status       = status
+    @account      = status.account
+    @account_ids  = account_ids
+    @mentions     = status.active_mentions.where(account_id: account_ids)
+    @reblogs      = status.reblogs.where(account_id: account_ids)
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        remove_from_followers
+        remove_from_lists
+        remove_from_affected
+        remove_reblogs
+        remove_from_hashtags unless @status.distributable?
+        remove_from_public
+        remove_from_media
+        remove_from_direct if status.direct_visibility?
+      else
+        raise Mastodon::RaceConditionError
+      end
+    end
+  end
+
+  private
+
+  def remove_from_followers
+    @account.followers_for_local_distribution.where(id: @account_ids).reorder(nil).find_each do |follower|
+      FeedManager.instance.unpush_from_home(follower, @status)
+    end
+  end
+
+  def remove_from_lists
+    @account.lists_for_local_distribution.where(account_id: @account_ids).select(:id, :account_id).reorder(nil).find_each do |list|
+      FeedManager.instance.unpush_from_list(list, @status)
+    end
+  end
+
+  def remove_from_affected
+    @mentions.map(&:account).select(&:local?).each do |account|
+      redis.publish("timeline:#{account.id}", @payload)
+    end
+  end
+
+  def remove_reblogs
+    @reblogs.each do |reblog|
+      RemoveStatusService.new.call(reblog)
+    end
+  end
+
+  def remove_from_hashtags
+    @account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
+      featured_tag.decrement(@status.id)
+    end
+
+    return unless @status.public_visibility?
+
+    @tags.each do |hashtag|
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
+      redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
+    end
+  end
+
+  def remove_from_public
+    return if @status.public_visibility?
+
+    redis.publish('timeline:public', @payload)
+    if @status.local?
+      redis.publish('timeline:public:local', @payload)
+    else
+      redis.publish('timeline:public:remote', @payload)
+    end
+  end
+
+  def remove_from_media
+    return if @status.public_visibility?
+
+    redis.publish('timeline:public:media', @payload)
+    if @status.local?
+      redis.publish('timeline:public:local:media', @payload)
+    else
+      redis.publish('timeline:public:remote:media', @payload)
+    end
+  end
+
+  def remove_from_direct
+    @mentions.each do |mention|
+      FeedManager.instance.unpush_from_direct(mention.account, @status) if mention.account.local?
+    end
+  end
+
+  def lock_options
+    { redis: Redis.current, key: "distribute:#{@status.id}" }
+  end
+end
diff --git a/app/services/update_status_service.rb b/app/services/update_status_service.rb
new file mode 100644
index 000000000..b393f13bb
--- /dev/null
+++ b/app/services/update_status_service.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+class UpdateStatusService < BaseService
+  include Redisable
+
+  ALLOWED_ATTRIBUTES = %i(
+    spoiler_text
+    text
+    content_type
+    language
+    sensitive
+    visibility
+    media_attachments
+    media_attachment_ids
+    application
+    rate_limit
+  ).freeze
+
+  # Updates the content of an existing status.
+  # @param [Status] status The status to update.
+  # @param [Hash] params The attributes of the new status.
+  # @param [Enumerable] mentions Additional mentions added to the status.
+  # @param [Enumerable] tags New tags for the status to belong to (implicit tags are preserved).
+  def call(status, params, mentions, tags)
+    raise ActiveRecord::RecordNotFound if status.blank? || status.discarded? || status.destroyed?
+    return status if params.blank?
+
+    @status                 = status
+    @account                = @status.account
+    @params                 = params.with_indifferent_access.slice(*ALLOWED_ATTRIBUTES).compact
+    @mentions               = (@status.mentions | (mentions || [])).to_set
+    @tags                   = (tags.nil? ? @status.tags : (tags || [])).to_set
+
+    @params[:text]        ||= ''
+    @params[:edited]      ||= 1 + @status.edited
+
+    update_tags if @status.local?
+    filter_tags
+    update_mentions
+
+    @delete_payload         = Oj.dump(event: :delete, payload: @status.id.to_s)
+    @deleted_tag_ids        = @status.tag_ids - @tags.pluck(:id)
+    @deleted_tag_names      = @status.tags.pluck(:name) - @tags.pluck(:name)
+    @deleted_attachment_ids = @status.media_attachment_ids - (@params[:media_attachment_ids] || @params[:media_attachments]&.pluck(:id) || [])
+    @new_mention_ids        = @mentions.pluck(:id) - @status.mention_ids
+
+    ApplicationRecord.transaction do
+      @status.update!(@params)
+      detach_deleted_tags
+      attach_updated_tags
+    end
+
+    prune_tags
+    prune_attachments
+    reset_status_caches
+
+    SpamCheck.perform(@status)
+    distribute
+
+    @status
+  end
+
+  private
+
+  def prune_attachments
+    RemoveMediaAttachmentsWorker.perform_async(@deleted_attachment_ids) if @deleted_attachment_ids.present?
+  end
+
+  def detach_deleted_tags
+    @status.tags.where(id: @deleted_tag_ids).destroy_all if @deleted_tag_ids.present?
+  end
+
+  def prune_tags
+    @account.featured_tags.where(tag_id: @deleted_tag_ids).each do |featured_tag|
+      featured_tag.decrement(@status.id)
+    end
+
+    if @status.public_visibility?
+      return if @deleted_tag_names.blank?
+
+      @deleted_tag_names.each do |hashtag|
+        redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @delete_payload)
+        redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @delete_payload) if @status.local?
+      end
+    end
+  end
+
+  def update_tags
+    old_explicit_tags = Tag.matching_name(Extractor.extract_hashtags(@status.text))
+    @tags |= Tag.find_or_create_by_names(Extractor.extract_hashtags(@params[:text]))
+
+    # Preserve implicit tags attached to the original status.
+    # TODO: Let locals remove them from edits.
+    @tags |= @status.tags.where.not(id: old_explicit_tags.select(:id))
+  end
+
+  def filter_tags
+    @tags.select! { |tag| tag =~ /\A(#{Tag::HASHTAG_NAME_RE})\z/i }
+  end
+
+  def update_mentions
+    @params[:text], @mentions = ResolveMentionsService.new.call(@status, text: @params[:text], mentions: @mentions)
+  end
+
+  def attach_updated_tags
+    tag_ids = @status.tag_ids.to_set
+    new_tag_ids = []
+    now = Time.now.utc
+
+    @tags.each do |tag|
+      next if tag_ids.include?(tag.id) || /\A(#{Tag::HASHTAG_NAME_RE})\z/i =~ $LAST_READ_LINE
+
+      @status.tags << tag
+      new_tag_ids << tag.id
+      TrendingTags.record_use!(tag, @account, now) if @status.public_visibility?
+    end
+
+    return unless @status.local? && @status.distributable?
+
+    @account.featured_tags.where(tag_id: new_tag_ids).each do |featured_tag|
+      featured_tag.increment(now)
+    end
+  end
+
+  def reset_status_caches
+    Rails.cache.delete_matched("statuses/#{@status.id}-*")
+    Rails.cache.delete("statuses/#{@status.id}")
+    Rails.cache.delete(@status)
+    redis.zremrangebyscore("spam_check:#{@account.id}", @status.id, @status.id)
+  end
+
+  def distribute
+    LinkCrawlWorker.perform_in(rand(1..30).seconds, @status.id) unless @status.spoiler_text?
+    DistributionWorker.perform_async(@status.id)
+    ActivityPub::DistributionWorker.perform_async(@status.id) if @status.local? && !@status.local_only?
+
+    mentions = @status.active_mentions.includes(:account).where(id: @new_mention_ids, accounts: { domain: nil })
+    mentions.each { |mention| LocalNotificationWorker.perform_async(mention.account.id, mention.id, mention.class.name) }
+  end
+end