diff options
Diffstat (limited to 'app/services')
-rw-r--r-- | app/services/post_status_service.rb | 48 | ||||
-rw-r--r-- | app/services/process_hashtags_service.rb | 8 | ||||
-rw-r--r-- | app/services/process_mentions_service.rb | 47 | ||||
-rw-r--r-- | app/services/remove_media_attachments_service.rb | 11 | ||||
-rw-r--r-- | app/services/resolve_mentions_service.rb | 73 | ||||
-rw-r--r-- | app/services/revoke_status_service.rb | 104 | ||||
-rw-r--r-- | app/services/update_status_service.rb | 140 |
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 |