Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `app/lib/activitypub/activity/create.rb`: Upstream refactored how `Create` activities are handled and how values are extracted from `Create`d objects. This conflicted with how glitch-soc supported the `directMessage` flag to explicitly distinguish between limited and direct messages. Ported glitch-soc's changes to latest upstream changes. - `app/services/fan_out_on_write_service.rb`: Upstream largely refactored that file and changed some of the logic. This conflicted with glitch-soc's handling of the direct timeline and the options to allow replies and boosts in public feeds. Ported those glitch-soc changes on top of latest upstream changes. - `app/services/process_mentions_service.rb`: Upstream refactored to move mention-related ActivityPub deliveries to `ActivityPub::DeliveryWorker`, while glitch-soc contained an extra check to not send local-only toots to remote mentioned users. Took upstream's version, as the check is not needed anymore, since it is performed at the `ActivityPub::DeliveryWorker` call site already. - `app/workers/feed_insert_worker.rb`: Upstream added support for `update` toot events, while glitch-soc had support for an extra timeline support, `direct`. Ported upstream changes and extended them to the `direct` timeline. Additional changes: - `app/lib/activitypub/parser/status_parser.rb`: Added code to handle the `directMessage` flag and take it into account to compute visibility. - `app/lib/feed_manager.rb`: Extended upstream's support of `update` toot events to glitch-soc's `direct` timeline.
This commit is contained in:
		@@ -69,9 +69,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_status
 | 
			
		||||
    @tags     = []
 | 
			
		||||
    @mentions = []
 | 
			
		||||
    @params   = {}
 | 
			
		||||
    @tags                 = []
 | 
			
		||||
    @mentions             = []
 | 
			
		||||
    @silenced_account_ids = []
 | 
			
		||||
    @params               = {}
 | 
			
		||||
 | 
			
		||||
    process_status_params
 | 
			
		||||
    process_tags
 | 
			
		||||
@@ -84,10 +85,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
 | 
			
		||||
    resolve_thread(@status)
 | 
			
		||||
    fetch_replies(@status)
 | 
			
		||||
    distribute(@status)
 | 
			
		||||
    distribute
 | 
			
		||||
    forward_for_reply
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def distribute
 | 
			
		||||
    # Spread out crawling randomly to avoid DDoSing the link
 | 
			
		||||
    LinkCrawlWorker.perform_in(rand(1..59).seconds, @status.id)
 | 
			
		||||
 | 
			
		||||
    # Distribute into home and list feeds and notify mentioned accounts
 | 
			
		||||
    ::DistributionWorker.perform_async(@status.id, silenced_account_ids: @silenced_account_ids) if @options[:override_timestamps] || @status.within_realtime_window?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def find_existing_status
 | 
			
		||||
    status   = status_from_uri(object_uri)
 | 
			
		||||
    status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
 | 
			
		||||
@@ -95,19 +104,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_status_params
 | 
			
		||||
    @status_parser = ActivityPub::Parser::StatusParser.new(@json, followers_collection: @account.followers_url)
 | 
			
		||||
 | 
			
		||||
    @params = begin
 | 
			
		||||
      {
 | 
			
		||||
        uri: object_uri,
 | 
			
		||||
        url: object_url || object_uri,
 | 
			
		||||
        uri: @status_parser.uri,
 | 
			
		||||
        url: @status_parser.url || @status_parser.uri,
 | 
			
		||||
        account: @account,
 | 
			
		||||
        text: text_from_content || '',
 | 
			
		||||
        language: detected_language,
 | 
			
		||||
        spoiler_text: converted_object_type? ? '' : (text_from_summary || ''),
 | 
			
		||||
        created_at: @object['published'],
 | 
			
		||||
        text: converted_object_type? ? converted_text : (@status_parser.text || ''),
 | 
			
		||||
        language: @status_parser.language || detected_language,
 | 
			
		||||
        spoiler_text: converted_object_type? ? '' : (@status_parser.spoiler_text || ''),
 | 
			
		||||
        created_at: @status_parser.created_at,
 | 
			
		||||
        edited_at: @status_parser.edited_at,
 | 
			
		||||
        override_timestamps: @options[:override_timestamps],
 | 
			
		||||
        reply: @object['inReplyTo'].present?,
 | 
			
		||||
        sensitive: @account.sensitized? || @object['sensitive'] || false,
 | 
			
		||||
        visibility: visibility_from_audience,
 | 
			
		||||
        reply: @status_parser.reply,
 | 
			
		||||
        sensitive: @account.sensitized? || @status_parser.sensitive || false,
 | 
			
		||||
        visibility: @status_parser.visibility,
 | 
			
		||||
        thread: replied_to_status,
 | 
			
		||||
        conversation: conversation_from_uri(@object['conversation']),
 | 
			
		||||
        media_attachment_ids: process_attachments.take(4).map(&:id),
 | 
			
		||||
@@ -117,50 +129,52 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_audience
 | 
			
		||||
    (audience_to + audience_cc).uniq.each do |audience|
 | 
			
		||||
      next if ActivityPub::TagManager.instance.public_collection?(audience)
 | 
			
		||||
    # Unlike with tags, there is no point in resolving accounts we don't already
 | 
			
		||||
    # know here, because silent mentions would only be used for local access control anyway
 | 
			
		||||
    accounts_in_audience = (audience_to + audience_cc).uniq.filter_map do |audience|
 | 
			
		||||
      account_from_uri(audience) unless ActivityPub::TagManager.instance.public_collection?(audience)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
      # Unlike with tags, there is no point in resolving accounts we don't already
 | 
			
		||||
      # know here, because silent mentions would only be used for local access
 | 
			
		||||
      # control anyway
 | 
			
		||||
      account = account_from_uri(audience)
 | 
			
		||||
    # If the payload was delivered to a specific inbox, the inbox owner must have
 | 
			
		||||
    # access to it, unless they already have access to it anyway
 | 
			
		||||
    if @options[:delivered_to_account_id]
 | 
			
		||||
      accounts_in_audience << delivered_to_account
 | 
			
		||||
      accounts_in_audience.uniq!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
      next if account.nil? || @mentions.any? { |mention| mention.account_id == account.id }
 | 
			
		||||
    accounts_in_audience.each do |account|
 | 
			
		||||
      # This runs after tags are processed, and those translate into non-silent
 | 
			
		||||
      # mentions, which take precedence
 | 
			
		||||
      next if @mentions.any? { |mention| mention.account_id == account.id }
 | 
			
		||||
 | 
			
		||||
      @mentions << Mention.new(account: account, silent: true)
 | 
			
		||||
 | 
			
		||||
      # If there is at least one silent mention, then the status can be considered
 | 
			
		||||
      # as a limited-audience status, and not strictly a direct message, but only
 | 
			
		||||
      # if we considered a direct message in the first place
 | 
			
		||||
      next unless @params[:visibility] == :direct && direct_message.nil?
 | 
			
		||||
 | 
			
		||||
      @params[:visibility] = :limited
 | 
			
		||||
      @params[:visibility] = :limited if @params[:visibility] == :direct && !@object['directMessage']
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # If the payload was delivered to a specific inbox, the inbox owner must have
 | 
			
		||||
    # access to it, unless they already have access to it anyway
 | 
			
		||||
    return if @options[:delivered_to_account_id].nil? || @mentions.any? { |mention| mention.account_id == @options[:delivered_to_account_id] }
 | 
			
		||||
 | 
			
		||||
    @mentions << Mention.new(account_id: @options[:delivered_to_account_id], silent: true)
 | 
			
		||||
 | 
			
		||||
    return unless @params[:visibility] == :direct && direct_message.nil?
 | 
			
		||||
 | 
			
		||||
    @params[:visibility] = :limited
 | 
			
		||||
    # Accounts that are tagged but are not in the audience are not
 | 
			
		||||
    # supposed to be notified explicitly
 | 
			
		||||
    @silenced_account_ids = @mentions.map(&:account_id) - accounts_in_audience.map(&:id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def postprocess_audience_and_deliver
 | 
			
		||||
    return if @status.mentions.find_by(account_id: @options[:delivered_to_account_id])
 | 
			
		||||
 | 
			
		||||
    delivered_to_account = Account.find(@options[:delivered_to_account_id])
 | 
			
		||||
 | 
			
		||||
    @status.mentions.create(account: delivered_to_account, silent: true)
 | 
			
		||||
    @status.update(visibility: :limited) if @status.direct_visibility? && direct_message.nil?
 | 
			
		||||
    @status.update(visibility: :limited) if @status.direct_visibility? && !@object['directMessage']
 | 
			
		||||
 | 
			
		||||
    return unless delivered_to_account.following?(@account)
 | 
			
		||||
 | 
			
		||||
    FeedInsertWorker.perform_async(@status.id, delivered_to_account.id, :home)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def delivered_to_account
 | 
			
		||||
    @delivered_to_account ||= Account.find(@options[:delivered_to_account_id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def attach_tags(status)
 | 
			
		||||
    @tags.each do |tag|
 | 
			
		||||
      status.tags << tag
 | 
			
		||||
@@ -215,21 +229,22 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
 | 
			
		||||
  def process_emoji(tag)
 | 
			
		||||
    return if skip_download?
 | 
			
		||||
    return if tag['name'].blank? || tag['icon'].blank? || tag['icon']['url'].blank?
 | 
			
		||||
 | 
			
		||||
    shortcode = tag['name'].delete(':')
 | 
			
		||||
    image_url = tag['icon']['url']
 | 
			
		||||
    uri       = tag['id']
 | 
			
		||||
    updated   = tag['updated']
 | 
			
		||||
    emoji     = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain)
 | 
			
		||||
    custom_emoji_parser = ActivityPub::Parser::CustomEmojiParser.new(tag)
 | 
			
		||||
 | 
			
		||||
    return unless emoji.nil? || image_url != emoji.image_remote_url || (updated && updated >= emoji.updated_at)
 | 
			
		||||
    return if custom_emoji_parser.shortcode.blank? || custom_emoji_parser.image_remote_url.blank?
 | 
			
		||||
 | 
			
		||||
    emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: shortcode, uri: uri)
 | 
			
		||||
    emoji.image_remote_url = image_url
 | 
			
		||||
    emoji.save
 | 
			
		||||
  rescue Seahorse::Client::NetworkingError => e
 | 
			
		||||
    Rails.logger.warn "Error storing emoji: #{e}"
 | 
			
		||||
    emoji = CustomEmoji.find_by(shortcode: custom_emoji_parser.shortcode, domain: @account.domain)
 | 
			
		||||
 | 
			
		||||
    return unless emoji.nil? || custom_emoji_parser.image_remote_url != emoji.image_remote_url || (custom_emoji_parser.updated_at && custom_emoji_parser.updated_at >= emoji.updated_at)
 | 
			
		||||
 | 
			
		||||
    begin
 | 
			
		||||
      emoji ||= CustomEmoji.new(domain: @account.domain, shortcode: custom_emoji_parser.shortcode, uri: custom_emoji_parser.uri)
 | 
			
		||||
      emoji.image_remote_url = custom_emoji_parser.image_remote_url
 | 
			
		||||
      emoji.save
 | 
			
		||||
    rescue Seahorse::Client::NetworkingError => e
 | 
			
		||||
      Rails.logger.warn "Error storing emoji: #{e}"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_attachments
 | 
			
		||||
@@ -238,14 +253,23 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
    media_attachments = []
 | 
			
		||||
 | 
			
		||||
    as_array(@object['attachment']).each do |attachment|
 | 
			
		||||
      next if attachment['url'].blank? || media_attachments.size >= 4
 | 
			
		||||
      media_attachment_parser = ActivityPub::Parser::MediaAttachmentParser.new(attachment)
 | 
			
		||||
 | 
			
		||||
      next if media_attachment_parser.remote_url.blank? || media_attachments.size >= 4
 | 
			
		||||
 | 
			
		||||
      begin
 | 
			
		||||
        href             = Addressable::URI.parse(attachment['url']).normalize.to_s
 | 
			
		||||
        media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
 | 
			
		||||
        media_attachment = MediaAttachment.create(
 | 
			
		||||
          account: @account,
 | 
			
		||||
          remote_url: media_attachment_parser.remote_url,
 | 
			
		||||
          thumbnail_remote_url: media_attachment_parser.thumbnail_remote_url,
 | 
			
		||||
          description: media_attachment_parser.description,
 | 
			
		||||
          focus: media_attachment_parser.focus,
 | 
			
		||||
          blurhash: media_attachment_parser.blurhash
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        media_attachments << media_attachment
 | 
			
		||||
 | 
			
		||||
        next if unsupported_media_type?(attachment['mediaType']) || skip_download?
 | 
			
		||||
        next if unsupported_media_type?(media_attachment_parser.file_content_type) || skip_download?
 | 
			
		||||
 | 
			
		||||
        media_attachment.download_file!
 | 
			
		||||
        media_attachment.download_thumbnail!
 | 
			
		||||
@@ -263,42 +287,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
    media_attachments
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def icon_url_from_attachment(attachment)
 | 
			
		||||
    url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
 | 
			
		||||
    Addressable::URI.parse(url).normalize.to_s if url.present?
 | 
			
		||||
  rescue Addressable::URI::InvalidURIError
 | 
			
		||||
    nil
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_poll
 | 
			
		||||
    return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
 | 
			
		||||
    poll_parser = ActivityPub::Parser::PollParser.new(@object)
 | 
			
		||||
 | 
			
		||||
    expires_at = begin
 | 
			
		||||
      if @object['closed'].is_a?(String)
 | 
			
		||||
        @object['closed']
 | 
			
		||||
      elsif !@object['closed'].nil? && !@object['closed'].is_a?(FalseClass)
 | 
			
		||||
        Time.now.utc
 | 
			
		||||
      else
 | 
			
		||||
        @object['endTime']
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    if @object['anyOf'].is_a?(Array)
 | 
			
		||||
      multiple = true
 | 
			
		||||
      items    = @object['anyOf']
 | 
			
		||||
    else
 | 
			
		||||
      multiple = false
 | 
			
		||||
      items    = @object['oneOf']
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    voters_count = @object['votersCount']
 | 
			
		||||
    return unless poll_parser.valid?
 | 
			
		||||
 | 
			
		||||
    @account.polls.new(
 | 
			
		||||
      multiple: multiple,
 | 
			
		||||
      expires_at: expires_at,
 | 
			
		||||
      options: items.map { |item| item['name'].presence || item['content'] }.compact,
 | 
			
		||||
      cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 },
 | 
			
		||||
      voters_count: voters_count
 | 
			
		||||
      multiple: poll_parser.multiple,
 | 
			
		||||
      expires_at: poll_parser.expires_at,
 | 
			
		||||
      options: poll_parser.options,
 | 
			
		||||
      cached_tallies: poll_parser.cached_tallies,
 | 
			
		||||
      voters_count: poll_parser.voters_count
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
@@ -351,29 +350,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def visibility_from_audience
 | 
			
		||||
    if audience_to.any? { |to| ActivityPub::TagManager.instance.public_collection?(to) }
 | 
			
		||||
      :public
 | 
			
		||||
    elsif audience_cc.any? { |cc| ActivityPub::TagManager.instance.public_collection?(cc) }
 | 
			
		||||
      :unlisted
 | 
			
		||||
    elsif audience_to.include?(@account.followers_url)
 | 
			
		||||
      :private
 | 
			
		||||
    elsif direct_message == false
 | 
			
		||||
      :limited
 | 
			
		||||
    else
 | 
			
		||||
      :direct
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def audience_includes?(account)
 | 
			
		||||
    uri = ActivityPub::TagManager.instance.uri_for(account)
 | 
			
		||||
    audience_to.include?(uri) || audience_cc.include?(uri)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def direct_message
 | 
			
		||||
    @object['directMessage']
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def replied_to_status
 | 
			
		||||
    return @replied_to_status if defined?(@replied_to_status)
 | 
			
		||||
 | 
			
		||||
@@ -390,81 +366,18 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		||||
    value_or_id(@object['inReplyTo'])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def text_from_content
 | 
			
		||||
    return Formatter.instance.linkify([[text_from_name, text_from_summary.presence].compact.join("\n\n"), object_url || object_uri].join(' ')) if converted_object_type?
 | 
			
		||||
 | 
			
		||||
    if @object['content'].present?
 | 
			
		||||
      @object['content']
 | 
			
		||||
    elsif content_language_map?
 | 
			
		||||
      @object['contentMap'].values.first
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def text_from_summary
 | 
			
		||||
    if @object['summary'].present?
 | 
			
		||||
      @object['summary']
 | 
			
		||||
    elsif summary_language_map?
 | 
			
		||||
      @object['summaryMap'].values.first
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def text_from_name
 | 
			
		||||
    if @object['name'].present?
 | 
			
		||||
      @object['name']
 | 
			
		||||
    elsif name_language_map?
 | 
			
		||||
      @object['nameMap'].values.first
 | 
			
		||||
    end
 | 
			
		||||
  def converted_text
 | 
			
		||||
    Formatter.instance.linkify([@status_parser.title.presence, @status_parser.spoiler_text.presence, @status_parser.url || @status_parser.uri].compact.join("\n\n"))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def detected_language
 | 
			
		||||
    if content_language_map?
 | 
			
		||||
      @object['contentMap'].keys.first
 | 
			
		||||
    elsif name_language_map?
 | 
			
		||||
      @object['nameMap'].keys.first
 | 
			
		||||
    elsif summary_language_map?
 | 
			
		||||
      @object['summaryMap'].keys.first
 | 
			
		||||
    elsif supported_object_type?
 | 
			
		||||
      LanguageDetector.instance.detect(text_from_content, @account)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def object_url
 | 
			
		||||
    return if @object['url'].blank?
 | 
			
		||||
 | 
			
		||||
    url_candidate = url_to_href(@object['url'], 'text/html')
 | 
			
		||||
 | 
			
		||||
    if invalid_origin?(url_candidate)
 | 
			
		||||
      nil
 | 
			
		||||
    else
 | 
			
		||||
      url_candidate
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def summary_language_map?
 | 
			
		||||
    @object['summaryMap'].is_a?(Hash) && !@object['summaryMap'].empty?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def content_language_map?
 | 
			
		||||
    @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def name_language_map?
 | 
			
		||||
    @object['nameMap'].is_a?(Hash) && !@object['nameMap'].empty?
 | 
			
		||||
    LanguageDetector.instance.detect(@status_parser.text, @account) if supported_object_type?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def unsupported_media_type?(mime_type)
 | 
			
		||||
    mime_type.present? && !MediaAttachment.supported_mime_types.include?(mime_type)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def supported_blurhash?(blurhash)
 | 
			
		||||
    components = blurhash.blank? || !blurhash_valid_chars?(blurhash) ? nil : Blurhash.components(blurhash)
 | 
			
		||||
    components.present? && components.none? { |comp| comp > 5 }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def blurhash_valid_chars?(blurhash)
 | 
			
		||||
    /^[\w#$%*+-.:;=?@\[\]^{|}~]+$/.match?(blurhash)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def skip_download?
 | 
			
		||||
    return @skip_download if defined?(@skip_download)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user