account.rb 15 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
yhirano's avatar
yhirano committed
47
#
48

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

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

63
64
  enum protocol: [:ostatus, :activitypub]

alpaca-tc's avatar
alpaca-tc committed
65
  validates :username, presence: true
66
67

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

  # Local user validations
72
  validates :username, format: { with: /\A[a-z0-9_]+\z/i }, length: { maximum: 30 }, if: -> { local? && will_save_change_to_username? }
73
  validates_with UniqueUsernameValidator, if: -> { local? && will_save_change_to_username? }
74
75
76
  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? }
77
  validates :fields, length: { maximum: 4 }, if: -> { local? && will_save_change_to_fields? }
Eugen Rochko's avatar
Eugen Rochko committed
78

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

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

113
  delegate :chosen_languages, to: :user, prefix: false, allow_nil: true
114

Eugen Rochko's avatar
Eugen Rochko committed
115
  def local?
Eugen Rochko's avatar
Eugen Rochko committed
116
    domain.nil?
Eugen Rochko's avatar
Eugen Rochko committed
117
118
  end

Eugen Rochko's avatar
Eugen Rochko committed
119
120
121
122
  def moved?
    moved_to_account_id.present?
  end

Eugen Rochko's avatar
Eugen Rochko committed
123
124
125
126
127
128
129
130
131
132
  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
133
  def acct
Eugen Rochko's avatar
Eugen Rochko committed
134
    local? ? username : "#{username}@#{domain}"
Eugen Rochko's avatar
Eugen Rochko committed
135
136
  end

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

  def to_webfinger_s
    "acct:#{local_username_and_domain}"
  end

Eugen Rochko's avatar
Eugen Rochko committed
145
  def subscribed?
146
    subscription_expires_at.present?
Eugen Rochko's avatar
Eugen Rochko committed
147
148
  end

149
150
151
152
153
154
  def possibly_stale?
    last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
  end

  def refresh!
    return if local?
155
    ResolveAccountService.new.call(acct)
156
157
  end

158
159
160
161
162
163
164
  def suspend!
    transaction do
      user&.disable! if local?
      update!(suspended: true)
    end
  end

165
166
167
168
169
170
171
172
173
174
175
176
177
178
  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
179
  def keypair
180
    @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
Eugen Rochko's avatar
Eugen Rochko committed
181
182
  end

Eugen Rochko's avatar
Eugen Rochko committed
183
  def tags_as_strings=(tag_names)
184
185
    tag_names.map! { |name| name.mb_chars.downcase.to_s }
    tag_names.uniq!
Eugen Rochko's avatar
Eugen Rochko committed
186
187
188
189
190
191
192

    # 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)
193
      hashtags_map[name] = Tag.new(name: name)
Eugen Rochko's avatar
Eugen Rochko committed
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
    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

Eugen Rochko's avatar
Eugen Rochko committed
217
218
219
220
221
  def fields
    (self[:fields] || []).map { |f| Field.new(self, f) }
  end

  def fields_attributes=(attributes)
222
223
    fields     = []
    old_fields = self[:fields] || []
Eugen Rochko's avatar
Eugen Rochko committed
224

225
226
227
    if attributes.is_a?(Hash)
      attributes.each_value do |attr|
        next if attr[:name].blank?
228
229
230
231
232
233
234

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

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

235
236
        fields << attr
      end
Eugen Rochko's avatar
Eugen Rochko committed
237
238
239
240
241
    end

    self[:fields] = fields
  end

242
243
  DEFAULT_FIELDS_SIZE = 4

Eugen Rochko's avatar
Eugen Rochko committed
244
  def build_fields
245
246
247
248
249
250
251
    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
252

253
    self.fields = tmp
Eugen Rochko's avatar
Eugen Rochko committed
254
255
  end

Eugen Rochko's avatar
Eugen Rochko committed
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
  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
271
  def subscription(webhook_url)
272
    @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
Eugen Rochko's avatar
Eugen Rochko committed
273
  end
Eugen Rochko's avatar
Eugen Rochko committed
274

275
  def save_with_optional_media!
276
    save!
Eugen's avatar
Eugen committed
277
  rescue ActiveRecord::RecordInvalid
278
    self.avatar              = nil
279
    self.header              = nil
280
    self[:avatar_remote_url] = ''
281
    self[:header_remote_url] = ''
Eugen's avatar
Eugen committed
282
    save!
283
284
  end

285
286
287
288
  def object_type
    :person
  end

289
  def to_param
Eugen Rochko's avatar
Eugen Rochko committed
290
    username
291
292
  end

Matt Jankowski's avatar
Matt Jankowski committed
293
294
295
296
  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
297
298
299
300
  def excluded_from_timeline_domains
    Rails.cache.fetch("exclude_domains_for:#{id}") { domain_blocks.pluck(:domain) }
  end

Eugen Rochko's avatar
Eugen Rochko committed
301
302
303
304
  def preferred_inbox_url
    shared_inbox_url.presence || inbox_url
  end

Eugen Rochko's avatar
Eugen Rochko committed
305
  class Field < ActiveModelSerializers::Model
306
307
308
309
310
    attributes :name, :value, :verified_at, :account, :errors

    def initialize(account, attributes)
      @account     = account
      @attributes  = attributes
311
312
      @name        = attributes['name'].strip[0, string_limit]
      @value       = attributes['value'].strip[0, string_limit]
313
314
315
316
317
318
319
320
      @verified_at = attributes['verified_at']&.to_datetime
      @errors      = {}
    end

    def verified?
      verified_at.present?
    end

321
322
323
324
325
326
327
328
329
330
    def value_for_verification
      @value_for_verification ||= begin
        if account.local?
          value
        else
          ActionController::Base.helpers.strip_tags(value)
        end
      end
    end

331
    def verifiable?
332
      value_for_verification.present? && value_for_verification.start_with?('http://', 'https://')
333
    end
Eugen Rochko's avatar
Eugen Rochko committed
334

335
336
337
    def mark_verified!
      @verified_at = Time.now.utc
      @attributes['verified_at'] = @verified_at
Eugen Rochko's avatar
Eugen Rochko committed
338
    end
339
340

    def to_h
341
      { name: @name, value: @value, verified_at: @verified_at }
342
    end
343
344
345
346
347
348
349
350
351
352

    private

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

355
  class << self
356
357
358
359
    def readonly_attributes
      super - %w(statuses_count following_count followers_count)
    end

360
    def domains
361
      reorder(nil).pluck(Arel.sql('distinct accounts.domain'))
362
363
    end

Eugen Rochko's avatar
Eugen Rochko committed
364
    def inboxes
365
      urls = reorder(nil).where(protocol: :activitypub).pluck(Arel.sql("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)"))
366
      DeliveryFailureTracker.filter(urls)
Eugen Rochko's avatar
Eugen Rochko committed
367
368
    end

369
    def search_for(terms, limit = 10)
alpaca-tc's avatar
alpaca-tc committed
370
      textsearch, query = generate_query_for_search(terms)
371

Matt Jankowski's avatar
Matt Jankowski committed
372
      sql = <<-SQL.squish
373
374
375
376
377
        SELECT
          accounts.*,
          ts_rank_cd(#{textsearch}, #{query}, 32) AS rank
        FROM accounts
        WHERE #{query} @@ #{textsearch}
Eugen Rochko's avatar
Eugen Rochko committed
378
          AND accounts.suspended = false
379
          AND accounts.moved_to_account_id IS NULL
380
381
        ORDER BY rank DESC
        LIMIT ?
Matt Jankowski's avatar
Matt Jankowski committed
382
      SQL
383

384
385
386
      records = find_by_sql([sql, limit])
      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
      records
387
388
    end

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

392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
      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
407
            AND accounts.moved_to_account_id IS NULL
408
409
410
411
412
          GROUP BY accounts.id
          ORDER BY rank DESC
          LIMIT ?
        SQL

413
        records = find_by_sql([sql, account.id, account.id, account.id, limit])
414
415
416
417
418
419
420
421
422
      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
423
            AND accounts.moved_to_account_id IS NULL
424
425
426
427
428
          GROUP BY accounts.id
          ORDER BY rank DESC
          LIMIT ?
        SQL

429
        records = find_by_sql([sql, account.id, account.id, limit])
430
      end
431
432
433

      ActiveRecord::Associations::Preloader.new.preload(records, :account_stat)
      records
434
435
    end

436
437
    private

alpaca-tc's avatar
alpaca-tc committed
438
439
440
441
442
443
444
    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
445
446
  end

447
  def emojis
448
    @emojis ||= CustomEmoji.from_text(emojifiable_text, domain)
449
450
  end

Eugen's avatar
Eugen committed
451
452
  before_create :generate_keys
  before_validation :normalize_domain
453
  before_validation :prepare_contents, if: :local?
454
  before_destroy :clean_feed_manager
455
456
457

  private

458
459
460
461
462
  def prepare_contents
    display_name&.strip!
    note&.strip!
  end

Eugen's avatar
Eugen committed
463
  def generate_keys
464
    return unless local? && !Rails.env.test?
Eugen's avatar
Eugen committed
465

466
    keypair = OpenSSL::PKey::RSA.new(2048)
Eugen's avatar
Eugen committed
467
468
469
470
471
472
473
474
475
    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
476
477
478
479

  def emojifiable_text
    [note, display_name, fields.map(&:value)].join(' ')
  end
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494

  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
495
end