account.rb 15.3 KB
Newer Older
1
# frozen_string_literal: true
yhirano's avatar
yhirano committed
2
3
4
5
# == Schema Information
#
# Table name: accounts
#
6
#  id                      :bigint(8)        not null, primary key
yhirano's avatar
yhirano committed
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#  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
#  last_webfingered_at     :datetime
36
37
38
39
40
#  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
41
#  memorial                :boolean          default(FALSE), not null
42
#  moved_to_account_id     :bigint(8)
43
#  featured_collection_url :string
Eugen Rochko's avatar
Eugen Rochko committed
44
#  fields                  :jsonb
Eugen Rochko's avatar
Eugen Rochko committed
45
#  actor_type              :string
Eugen Rochko's avatar
Eugen Rochko committed
46
#  discoverable            :boolean
47
#  also_known_as           :string           is an Array
yhirano's avatar
yhirano committed
48
#
49

Eugen Rochko's avatar
Eugen Rochko committed
50
class Account < ApplicationRecord
51
  USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
52
  MENTION_RE  = /(?<=^|[^\/[:word:]])@((#{USERNAME_RE})(?:@[a-z0-9\.\-]+[a-z0-9]+)?)/i
Eugen Rochko's avatar
Eugen Rochko committed
53
  MIN_FOLLOWERS_DISCOVERY = 10
54

55
  include AccountAssociations
56
  include AccountAvatar
57
  include AccountFinderConcern
58
  include AccountHeader
Eugen Rochko's avatar
Eugen Rochko committed
59
  include AccountInteractions
60
  include Attachmentable
Eugen Rochko's avatar
Eugen Rochko committed
61
  include Paginable
62
  include AccountCounters
63
  include DomainNormalizable
64

65
66
  enum protocol: [:ostatus, :activitypub]

alpaca-tc's avatar
alpaca-tc committed
67
  validates :username, presence: true
68
69

  # Remote user validations
70
  validates :username, uniqueness: { scope: :domain, case_sensitive: true }, if: -> { !local? && will_save_change_to_username? }
71
  validates :username, format: { with: /\A#{USERNAME_RE}\z/i }, if: -> { !local? && will_save_change_to_username? }
alpaca-tc's avatar
alpaca-tc committed
72
73

  # Local user validations
74
  validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
75
  validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
76
77
  validates_with UnreservedUsernameValidator, if: -> { local? && will_save_change_to_username? }
  validates :display_name, length: { maximum: 30 }, if: -> { local? && will_save_change_to_display_name? }
78
  validates :note, note_length: { maximum: 160 }, if: -> { local? && will_save_change_to_note? }
79
  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
Eugen Rochko's avatar
Eugen Rochko committed
80

81
82
  scope :remote, -> { where.not(domain: nil) }
  scope :local, -> { where(domain: nil) }
83
  scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
84
  scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
85
86
  scope :silenced, -> { where(silenced: true) }
  scope :suspended, -> { where(suspended: true) }
87
  scope :without_suspended, -> { where(suspended: false) }
Eugen Rochko's avatar
Eugen Rochko committed
88
  scope :without_silenced, -> { where(silenced: false) }
89
  scope :recent, -> { reorder(id: :desc) }
90
  scope :bots, -> { where(actor_type: %w(Application Service)) }
91
  scope :alphabetic, -> { order(domain: :asc, username: :asc) }
92
  scope :by_domain_accounts, -> { group(:domain).select(:domain, 'COUNT(*) AS accounts_count').order('accounts_count desc') }
93
94
  scope :matches_username, ->(value) { where(arel_table[:username].matches("#{value}%")) }
  scope :matches_display_name, ->(value) { where(arel_table[:display_name].matches("#{value}%")) }
nullkal's avatar
nullkal committed
95
  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
Eugen Rochko's avatar
Eugen Rochko committed
96
97
  scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
  scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).joins(:account_stat).where(AccountStat.arel_table[:followers_count].gteq(MIN_FOLLOWERS_DISCOVERY)).by_recent_status }
Eugen Rochko's avatar
Eugen Rochko committed
98
  scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
99
  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc')) }
Eugen Rochko's avatar
Eugen Rochko committed
100
  scope :popular, -> { order('account_stats.followers_count desc') }
101

Matt Jankowski's avatar
Matt Jankowski committed
102
  delegate :email,
103
           :unconfirmed_email,
yhirano's avatar
yhirano committed
104
105
106
           :current_sign_in_ip,
           :current_sign_in_at,
           :confirmed?,
107
           :admin?,
108
109
           :moderator?,
           :staff?,
110
           :locale,
111
           :hides_network?,
yhirano's avatar
yhirano committed
112
113
114
           to: :user,
           prefix: true,
           allow_nil: true
Matt Jankowski's avatar
Matt Jankowski committed
115

116
  delegate :chosen_languages, to: :user, prefix: false, allow_nil: true
117

Eugen Rochko's avatar
Eugen Rochko committed
118
  def local?
Eugen Rochko's avatar
Eugen Rochko committed
119
    domain.nil?
Eugen Rochko's avatar
Eugen Rochko committed
120
121
  end

Eugen Rochko's avatar
Eugen Rochko committed
122
123
124
125
  def moved?
    moved_to_account_id.present?
  end

Eugen Rochko's avatar
Eugen Rochko committed
126
127
128
129
130
131
132
133
134
135
  def bot?
    %w(Application Service).include? actor_type
  end

  alias bot bot?

  def bot=(val)
    self.actor_type = ActiveModel::Type::Boolean.new.cast(val) ? 'Service' : 'Person'
  end

Eugen Rochko's avatar
Eugen Rochko committed
136
  def acct
Eugen Rochko's avatar
Eugen Rochko committed
137
    local? ? username : "#{username}@#{domain}"
Eugen Rochko's avatar
Eugen Rochko committed
138
139
  end

140
141
142
143
  def local_username_and_domain
    "#{username}@#{Rails.configuration.x.local_domain}"
  end

144
145
146
147
  def local_followers_count
    Follow.where(target_account_id: id).count
  end

148
149
150
151
  def to_webfinger_s
    "acct:#{local_username_and_domain}"
  end

Eugen Rochko's avatar
Eugen Rochko committed
152
  def subscribed?
153
    subscription_expires_at.present?
Eugen Rochko's avatar
Eugen Rochko committed
154
155
  end

156
157
158
159
160
161
  def possibly_stale?
    last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
  end

  def refresh!
    return if local?
162
    ResolveAccountService.new.call(acct)
163
164
  end

Eugen Rochko's avatar
Eugen Rochko committed
165
166
167
168
169
170
171
172
  def silence!
    update!(silenced: true)
  end

  def unsilence!
    update!(silenced: false)
  end

173
174
175
176
177
178
179
  def suspend!
    transaction do
      user&.disable! if local?
      update!(suspended: true)
    end
  end

180
181
182
183
184
185
186
187
188
189
190
191
192
193
  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

Eugen Rochko's avatar
Eugen Rochko committed
194
  def keypair
195
    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
Eugen Rochko's avatar
Eugen Rochko committed
196
197
  end

Eugen Rochko's avatar
Eugen Rochko committed
198
  def tags_as_strings=(tag_names)
199
200
    tag_names.map! { |name| name.mb_chars.downcase.to_s }
    tag_names.uniq!
Eugen Rochko's avatar
Eugen Rochko committed
201
202
203
204
205
206
207

    # Existing hashtags
    hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag }

    # Initialize not yet existing hashtags
    tag_names.each do |name|
      next if hashtags_map.key?(name)
208
      hashtags_map[name] = Tag.new(name: name)
Eugen Rochko's avatar
Eugen Rochko committed
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
    end

    # Remove hashtags that are to be deleted
    tags.each do |tag|
      if hashtags_map.key?(tag.name)
        hashtags_map.delete(tag.name)
      else
        transaction do
          tags.delete(tag)
          tag.decrement_count!(:accounts_count)
        end
      end
    end

    # Add hashtags that were so far missing
    hashtags_map.each_value do |tag|
      transaction do
        tags << tag
        tag.increment_count!(:accounts_count)
      end
    end
  end

232
233
234
235
  def also_known_as
    self[:also_known_as] || []
  end

Eugen Rochko's avatar
Eugen Rochko committed
236
237
238
239
240
  def fields
    (self[:fields] || []).map { |f| Field.new(self, f) }
  end

  def fields_attributes=(attributes)
241
242
    fields     = []
    old_fields = self[:fields] || []
243
    old_fields = [] if old_fields.is_a?(Hash)
Eugen Rochko's avatar
Eugen Rochko committed
244

245
246
247
    if attributes.is_a?(Hash)
      attributes.each_value do |attr|
        next if attr[:name].blank?
248
249
250
251
252
253
254

        previous = old_fields.find { |item| item['value'] == attr[:value] }

        if previous && previous['verified_at'].present?
          attr[:verified_at] = previous['verified_at']
        end

255
256
        fields << attr
      end
Eugen Rochko's avatar
Eugen Rochko committed
257
258
259
260
261
    end

    self[:fields] = fields
  end

262
263
  DEFAULT_FIELDS_SIZE = 4

Eugen Rochko's avatar
Eugen Rochko committed
264
  def build_fields
265
266
267
268
269
270
271
    return if fields.size >= DEFAULT_FIELDS_SIZE

    tmp = self[:fields] || []

    (DEFAULT_FIELDS_SIZE - tmp.size).times do
      tmp << { name: '', value: '' }
    end
Eugen Rochko's avatar
Eugen Rochko committed
272

273
    self.fields = tmp
Eugen Rochko's avatar
Eugen Rochko committed
274
275
  end

Eugen Rochko's avatar
Eugen Rochko committed
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
  def magic_key
    modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component|
      result = []

      until component.zero?
        result << [component % 256].pack('C')
        component >>= 8
      end

      result.reverse.join
    end

    (['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.')
  end

Eugen Rochko's avatar
Eugen Rochko committed
291
  def subscription(webhook_url)
292
    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
Eugen Rochko's avatar
Eugen Rochko committed
293
  end
Eugen Rochko's avatar
Eugen Rochko committed
294

295
  def save_with_optional_media!
296
    save!
Eugen's avatar
Eugen committed
297
  rescue ActiveRecord::RecordInvalid
298
    self.avatar              = nil
299
    self.header              = nil
300
    self[:avatar_remote_url] = ''
301
    self[:header_remote_url] = ''
Eugen's avatar
Eugen committed
302
    save!
303
304
  end

305
306
307
308
  def object_type
    :person
  end

309
  def to_param
Eugen Rochko's avatar
Eugen Rochko committed
310
    username
311
312
  end

Matt Jankowski's avatar
Matt Jankowski committed
313
314
315
316
  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

Eugen Rochko's avatar
Eugen Rochko committed
317
318
319
320
  def excluded_from_timeline_domains
    Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
  end

Eugen Rochko's avatar
Eugen Rochko committed
321
322
323
324
  def preferred_inbox_url
    shared_inbox_url.presence || inbox_url
  end

Eugen Rochko's avatar
Eugen Rochko committed
325
  class Field < ActiveModelSerializers::Model
326
327
328
329
330
    attributes :name, :value, :verified_at, :account, :errors

    def initialize(account, attributes)
      @account     = account
      @attributes  = attributes
331
332
      @name        = attributes['name'].strip[0, string_limit]
      @value       = attributes['value'].strip[0, string_limit]
333
334
335
336
337
338
339
340
      @verified_at = attributes['verified_at']&.to_datetime
      @errors      = {}
    end

    def verified?
      verified_at.present?
    end

341
342
343
344
345
346
347
348
349
350
    def value_for_verification
      @value_for_verification ||= begin
        if account.local?
          value
        else
          ActionController::Base.helpers.strip_tags(value)
        end
      end
    end

351
    def verifiable?
352
      value_for_verification.present? && value_for_verification.start_with?('http://', 'https://')
353
    end
Eugen Rochko's avatar
Eugen Rochko committed
354

355
356
357
    def mark_verified!
      @verified_at = Time.now.utc
      @attributes['verified_at'] = @verified_at
Eugen Rochko's avatar
Eugen Rochko committed
358
    end
359
360

    def to_h
361
      { name: @name, value: @value, verified_at: @verified_at }
362
    end
363
364
365
366
367
368
369
370
371
372

    private

    def string_limit
      if account.local?
        255
      else
        2047
      end
    end
Eugen Rochko's avatar
Eugen Rochko committed
373
374
  end

375
  class << self
376
377
378
379
    def readonly_attributes
      super - %w(statuses_count following_count followers_count)
    end

380
    def domains
381
      reorder(nil).pluck(Arel.sql('distinct accounts.domain'))
382
383
    end

Eugen Rochko's avatar
Eugen Rochko committed
384
    def inboxes
385
      urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)"))
386
      DeliveryFailureTracker.filter(urls)
Eugen Rochko's avatar
Eugen Rochko committed
387
388
    end

389
    def search_for(terms, limit = 10)
alpaca-tc's avatar
alpaca-tc committed
390
      textsearch, query = generate_query_for_search(terms)
391

Matt Jankowski's avatar
Matt Jankowski committed
392
      sql = <<-SQL.squish
393
394
395
396
397
        SELECT
          accounts.*,
          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
        FROM accounts
        WHERE #{query} @@ #{textsearch}
Eugen Rochko's avatar
Eugen Rochko committed
398
          AND accounts.suspended = false
399
          AND accounts.moved_to_account_id IS NULL
400
401
        ORDER BY rank DESC
        LIMIT ?
Matt Jankowski's avatar
Matt Jankowski committed
402
      SQL
403

404
405
406
      records = find_by_sql([sql, limit])
      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
      records
407
408
    end

409
    def advanced_search_for(terms, account, limit = 10, following = false)
alpaca-tc's avatar
alpaca-tc committed
410
      textsearch, query = generate_query_for_search(terms)
411

412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
      if following
        sql = <<-SQL.squish
          WITH first_degree AS (
            SELECT target_account_id
            FROM follows
            WHERE account_id = ?
          )
          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 accounts.id IN (SELECT * FROM first_degree)
            AND #{query} @@ #{textsearch}
            AND accounts.suspended = false
427
            AND accounts.moved_to_account_id IS NULL
428
429
430
431
432
          GROUP BY accounts.id
          ORDER BY rank DESC
          LIMIT ?
        SQL

433
        records = find_by_sql([sql, account.id, account.id, account.id, limit])
434
435
436
437
438
439
440
441
442
      else
        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
443
            AND accounts.moved_to_account_id IS NULL
444
445
446
447
448
          GROUP BY accounts.id
          ORDER BY rank DESC
          LIMIT ?
        SQL

449
        records = find_by_sql([sql, account.id, account.id, limit])
450
      end
451
452
453

      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
      records
454
455
    end

456
457
    private

alpaca-tc's avatar
alpaca-tc committed
458
459
460
461
462
463
464
    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
465
466
  end

467
  def emojis
468
    @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
469
470
  end

Eugen's avatar
Eugen committed
471
  before_create :generate_keys
472
  before_validation :prepare_contents, if: :local?
473
  before_destroy :clean_feed_manager
474
475
476

  private

477
478
479
480
481
  def prepare_contents
    display_name&.strip!
    note&.strip!
  end

Eugen's avatar
Eugen committed
482
  def generate_keys
483
    return unless local? && !Rails.env.test?
Eugen's avatar
Eugen committed
484

485
    keypair = OpenSSL::PKey::RSA.new(2048)
Eugen's avatar
Eugen committed
486
487
488
489
490
491
492
    self.private_key = keypair.to_pem
    self.public_key  = keypair.public_key.to_pem
  end

  def normalize_domain
    return if local?

493
    super
Eugen's avatar
Eugen committed
494
  end
495
496
497
498

  def emojifiable_text
    [note, display_name, fields.map(&:value)].join(' ')
  end
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513

  def clean_feed_manager
    reblog_key       = FeedManager.instance.key(:home, id, 'reblogs')
    reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1)

    Redis.current.pipelined do
      Redis.current.del(FeedManager.instance.key(:home, id))
      Redis.current.del(reblog_key)

      reblogged_id_set.each do |reblogged_id|
        reblog_set_key = FeedManager.instance.key(:home, id, "reblogs:#{reblogged_id}")
        Redis.current.del(reblog_set_key)
      end
    end
  end
Eugen Rochko's avatar
Eugen Rochko committed
514
end