* Work around Twidere and Tootdon bug Tootdon and Twidere construct @user@domain handles from mentions in toots based solely on the mention text and account URI's domain without performing any webfinger call or retrieving account info from the Mastodon server. As a result, when a remote user has WEB_DOMAIN ≠ LOCAL_DOMAIN, Twidere and Tootdon will construct the mention as @user@WEB_DOMAIN. Now, this will usually resolve to the correct account (since the recommended configuration is to have WEB_DOMAIN perform webfinger redirections to LOCAL_DOMAIN) when processing mentions, but won't do so when displaying them (as it does not go through the whole account resolution at that time). This change rewrites mentions to the resolved account, so that displaying the mentions will work. * Use lookbehind instead of non-capturing group in MENTION_RE Indeed, substitutions with the previous regexp would erroneously eat any preceding whitespace, which would lead to concatenated mentions in the previous commit. Note that users will “lose” up to one character space per mention for their toots, as that regexp is also used to remove the domain-part of mentioned users for character counting purposes, and it also erroneously removed the preceding character if it was a space.
		
			
				
	
	
		
			315 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
			
		
		
	
	
			315 lines
		
	
	
	
		
			10 KiB
		
	
	
	
		
			Ruby
		
	
	
	
	
	
# frozen_string_literal: true
 | 
						|
# == Schema Information
 | 
						|
#
 | 
						|
# Table name: accounts
 | 
						|
#
 | 
						|
#  id                      :integer          not null, primary key
 | 
						|
#  username                :string           default(""), not null
 | 
						|
#  domain                  :string
 | 
						|
#  secret                  :string           default(""), not null
 | 
						|
#  private_key             :text
 | 
						|
#  public_key              :text             default(""), not null
 | 
						|
#  remote_url              :string           default(""), not null
 | 
						|
#  salmon_url              :string           default(""), not null
 | 
						|
#  hub_url                 :string           default(""), not null
 | 
						|
#  created_at              :datetime         not null
 | 
						|
#  updated_at              :datetime         not null
 | 
						|
#  note                    :text             default(""), not null
 | 
						|
#  display_name            :string           default(""), not null
 | 
						|
#  uri                     :string           default(""), not null
 | 
						|
#  url                     :string
 | 
						|
#  avatar_file_name        :string
 | 
						|
#  avatar_content_type     :string
 | 
						|
#  avatar_file_size        :integer
 | 
						|
#  avatar_updated_at       :datetime
 | 
						|
#  header_file_name        :string
 | 
						|
#  header_content_type     :string
 | 
						|
#  header_file_size        :integer
 | 
						|
#  header_updated_at       :datetime
 | 
						|
#  avatar_remote_url       :string
 | 
						|
#  subscription_expires_at :datetime
 | 
						|
#  silenced                :boolean          default(FALSE), not null
 | 
						|
#  suspended               :boolean          default(FALSE), not null
 | 
						|
#  locked                  :boolean          default(FALSE), not null
 | 
						|
#  header_remote_url       :string           default(""), not null
 | 
						|
#  statuses_count          :integer          default(0), not null
 | 
						|
#  followers_count         :integer          default(0), not null
 | 
						|
#  following_count         :integer          default(0), not null
 | 
						|
#  last_webfingered_at     :datetime
 | 
						|
#  inbox_url               :string           default(""), not null
 | 
						|
#  outbox_url              :string           default(""), not null
 | 
						|
#  shared_inbox_url        :string           default(""), not null
 | 
						|
#  followers_url           :string           default(""), not null
 | 
						|
#  protocol                :integer          default("ostatus"), not null
 | 
						|
#  memorial                :boolean          default(FALSE), not null
 | 
						|
#
 | 
						|
 | 
						|
class Account < ApplicationRecord
 | 
						|
  MENTION_RE = /(?<=^|[^\/[:word:]])@(([a-z0-9_]+)(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
 | 
						|
 | 
						|
  include AccountAvatar
 | 
						|
  include AccountFinderConcern
 | 
						|
  include AccountHeader
 | 
						|
  include AccountInteractions
 | 
						|
  include Attachmentable
 | 
						|
  include Remotable
 | 
						|
 | 
						|
  enum protocol: [:ostatus, :activitypub]
 | 
						|
 | 
						|
  # Local users
 | 
						|
  has_one :user, inverse_of: :account
 | 
						|
 | 
						|
  validates :username, presence: true
 | 
						|
 | 
						|
  # Remote user validations
 | 
						|
  validates :username, uniqueness: { scope: :domain, case_sensitive: true }, if: -> { !local? && will_save_change_to_username? }
 | 
						|
 | 
						|
  # Local user validations
 | 
						|
  validates :username, format: { with: /\A[a-z0-9_]+\z/i }, uniqueness: { scope: :domain, case_sensitive: false }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
 | 
						|
  validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
 | 
						|
  validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
 | 
						|
  validates :note, length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
 | 
						|
 | 
						|
  # Timelines
 | 
						|
  has_many :stream_entries, inverse_of: :account, dependent: :destroy
 | 
						|
  has_many :statuses, inverse_of: :account, dependent: :destroy
 | 
						|
  has_many :favourites, inverse_of: :account, dependent: :destroy
 | 
						|
  has_many :mentions, inverse_of: :account, dependent: :destroy
 | 
						|
  has_many :notifications, inverse_of: :account, dependent: :destroy
 | 
						|
 | 
						|
  # Pinned statuses
 | 
						|
  has_many :status_pins, inverse_of: :account, dependent: :destroy
 | 
						|
  has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
 | 
						|
 | 
						|
  # Media
 | 
						|
  has_many :media_attachments, dependent: :destroy
 | 
						|
 | 
						|
  # PuSH subscriptions
 | 
						|
  has_many :subscriptions, dependent: :destroy
 | 
						|
 | 
						|
  # Report relationships
 | 
						|
  has_many :reports
 | 
						|
  has_many :targeted_reports, class_name: 'Report', foreign_key: :target_account_id
 | 
						|
 | 
						|
  # Moderation notes
 | 
						|
  has_many :account_moderation_notes, dependent: :destroy
 | 
						|
  has_many :targeted_moderation_notes, class_name: 'AccountModerationNote', foreign_key: :target_account_id, dependent: :destroy
 | 
						|
 | 
						|
  scope :remote, -> { where.not(domain: nil) }
 | 
						|
  scope :local, -> { where(domain: nil) }
 | 
						|
  scope :without_followers, -> { where(followers_count: 0) }
 | 
						|
  scope :with_followers, -> { where('followers_count > 0') }
 | 
						|
  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
 | 
						|
  scope :partitioned, -> { order('row_number() over (partition by domain)') }
 | 
						|
  scope :silenced, -> { where(silenced: true) }
 | 
						|
  scope :suspended, -> { where(suspended: true) }
 | 
						|
  scope :recent, -> { reorder(id: :desc) }
 | 
						|
  scope :alphabetic, -> { order(domain: :asc, username: :asc) }
 | 
						|
  scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
 | 
						|
  scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
 | 
						|
  scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
 | 
						|
  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 | 
						|
 | 
						|
  delegate :email,
 | 
						|
           :current_sign_in_ip,
 | 
						|
           :current_sign_in_at,
 | 
						|
           :confirmed?,
 | 
						|
           :admin?,
 | 
						|
           :locale,
 | 
						|
           to: :user,
 | 
						|
           prefix: true,
 | 
						|
           allow_nil: true
 | 
						|
 | 
						|
  delegate :filtered_languages, to: :user, prefix: false, allow_nil: true
 | 
						|
 | 
						|
  def local?
 | 
						|
    domain.nil?
 | 
						|
  end
 | 
						|
 | 
						|
  def acct
 | 
						|
    local? ? username : "#{username}@#{domain}"
 | 
						|
  end
 | 
						|
 | 
						|
  def local_username_and_domain
 | 
						|
    "#{username}@#{Rails.configuration.x.local_domain}"
 | 
						|
  end
 | 
						|
 | 
						|
  def to_webfinger_s
 | 
						|
    "acct:#{local_username_and_domain}"
 | 
						|
  end
 | 
						|
 | 
						|
  def subscribed?
 | 
						|
    subscription_expires_at.present?
 | 
						|
  end
 | 
						|
 | 
						|
  def possibly_stale?
 | 
						|
    last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
 | 
						|
  end
 | 
						|
 | 
						|
  def refresh!
 | 
						|
    return if local?
 | 
						|
    ResolveRemoteAccountService.new.call(acct)
 | 
						|
  end
 | 
						|
 | 
						|
  def unsuspend!
 | 
						|
    transaction do
 | 
						|
      user&.enable! if local?
 | 
						|
      update!(suspended: false)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def memorialize!
 | 
						|
    transaction do
 | 
						|
      user&.disable! if local?
 | 
						|
      update!(memorial: true)
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  def keypair
 | 
						|
    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
 | 
						|
  end
 | 
						|
 | 
						|
  def subscription(webhook_url)
 | 
						|
    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
 | 
						|
  end
 | 
						|
 | 
						|
  def save_with_optional_media!
 | 
						|
    save!
 | 
						|
  rescue ActiveRecord::RecordInvalid
 | 
						|
    self.avatar              = nil
 | 
						|
    self.header              = nil
 | 
						|
    self[:avatar_remote_url] = ''
 | 
						|
    self[:header_remote_url] = ''
 | 
						|
    save!
 | 
						|
  end
 | 
						|
 | 
						|
  def object_type
 | 
						|
    :person
 | 
						|
  end
 | 
						|
 | 
						|
  def to_param
 | 
						|
    username
 | 
						|
  end
 | 
						|
 | 
						|
  def excluded_from_timeline_account_ids
 | 
						|
    Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
 | 
						|
  end
 | 
						|
 | 
						|
  def excluded_from_timeline_domains
 | 
						|
    Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
 | 
						|
  end
 | 
						|
 | 
						|
  class << self
 | 
						|
    def readonly_attributes
 | 
						|
      super - %w(statuses_count following_count followers_count)
 | 
						|
    end
 | 
						|
 | 
						|
    def domains
 | 
						|
      reorder(nil).pluck('distinct accounts.domain')
 | 
						|
    end
 | 
						|
 | 
						|
    def inboxes
 | 
						|
      urls = reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
 | 
						|
      DeliveryFailureTracker.filter(urls)
 | 
						|
    end
 | 
						|
 | 
						|
    def triadic_closures(account, limit: 5, offset: 0)
 | 
						|
      sql = <<-SQL.squish
 | 
						|
        WITH first_degree AS (
 | 
						|
          SELECT target_account_id
 | 
						|
          FROM follows
 | 
						|
          WHERE account_id = :account_id
 | 
						|
        )
 | 
						|
        SELECT accounts.*
 | 
						|
        FROM follows
 | 
						|
        INNER JOIN accounts ON follows.target_account_id = accounts.id
 | 
						|
        WHERE
 | 
						|
          account_id IN (SELECT * FROM first_degree)
 | 
						|
          AND target_account_id NOT IN (SELECT * FROM first_degree)
 | 
						|
          AND target_account_id NOT IN (:excluded_account_ids)
 | 
						|
          AND accounts.suspended = false
 | 
						|
        GROUP BY target_account_id, accounts.id
 | 
						|
        ORDER BY count(account_id) DESC
 | 
						|
        OFFSET :offset
 | 
						|
        LIMIT :limit
 | 
						|
      SQL
 | 
						|
 | 
						|
      excluded_account_ids = account.excluded_from_timeline_account_ids + [account.id]
 | 
						|
 | 
						|
      find_by_sql(
 | 
						|
        [sql, { account_id: account.id, excluded_account_ids: excluded_account_ids, limit: limit, offset: offset }]
 | 
						|
      )
 | 
						|
    end
 | 
						|
 | 
						|
    def search_for(terms, limit = 10)
 | 
						|
      textsearch, query = generate_query_for_search(terms)
 | 
						|
 | 
						|
      sql = <<-SQL.squish
 | 
						|
        SELECT
 | 
						|
          accounts.*,
 | 
						|
          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
 | 
						|
        FROM accounts
 | 
						|
        WHERE #{query} @@ #{textsearch}
 | 
						|
          AND accounts.suspended = false
 | 
						|
        ORDER BY rank DESC
 | 
						|
        LIMIT ?
 | 
						|
      SQL
 | 
						|
 | 
						|
      find_by_sql([sql, limit])
 | 
						|
    end
 | 
						|
 | 
						|
    def advanced_search_for(terms, account, limit = 10)
 | 
						|
      textsearch, query = generate_query_for_search(terms)
 | 
						|
 | 
						|
      sql = <<-SQL.squish
 | 
						|
        SELECT
 | 
						|
          accounts.*,
 | 
						|
          (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
 | 
						|
        FROM accounts
 | 
						|
        LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?)
 | 
						|
        WHERE #{query} @@ #{textsearch}
 | 
						|
          AND accounts.suspended = false
 | 
						|
        GROUP BY accounts.id
 | 
						|
        ORDER BY rank DESC
 | 
						|
        LIMIT ?
 | 
						|
      SQL
 | 
						|
 | 
						|
      find_by_sql([sql, account.id, account.id, limit])
 | 
						|
    end
 | 
						|
 | 
						|
    private
 | 
						|
 | 
						|
    def generate_query_for_search(terms)
 | 
						|
      terms      = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' ')))
 | 
						|
      textsearch = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))"
 | 
						|
      query      = "to_tsquery('simple', ''' ' || #{terms} || ' ''' || ':*')"
 | 
						|
 | 
						|
      [textsearch, query]
 | 
						|
    end
 | 
						|
  end
 | 
						|
 | 
						|
  before_create :generate_keys
 | 
						|
  before_validation :normalize_domain
 | 
						|
  before_validation :prepare_contents, if: :local?
 | 
						|
 | 
						|
  private
 | 
						|
 | 
						|
  def prepare_contents
 | 
						|
    display_name&.strip!
 | 
						|
    note&.strip!
 | 
						|
  end
 | 
						|
 | 
						|
  def generate_keys
 | 
						|
    return unless local?
 | 
						|
 | 
						|
    keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
 | 
						|
    self.private_key = keypair.to_pem
 | 
						|
    self.public_key  = keypair.public_key.to_pem
 | 
						|
  end
 | 
						|
 | 
						|
  def normalize_domain
 | 
						|
    return if local?
 | 
						|
 | 
						|
    self.domain = TagManager.instance.normalize_domain(domain)
 | 
						|
  end
 | 
						|
end
 |