FEATURE: introduce dedicated storage and DB constraints for anon users
Previously we used custom fields to denote a user was anonymous, this was risky in that custom fields are prone to race conditions and are not properly dedicated, missing constraints and so on. The new table `anonymous_users` is properly protected. There is only one possible shadow account per user, which is enforced using a constraint. Every anonymous user will have a unique row in the new table.
This commit is contained in:
parent
a206da8e18
commit
5c524ea8a4
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AnonymousUser < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :master_user, class_name: 'User'
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: anonymous_users
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# master_user_id :integer not null
|
||||
# active :boolean not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_anonymous_users_on_master_user_id (master_user_id) UNIQUE WHERE active
|
||||
# index_anonymous_users_on_user_id (user_id) UNIQUE
|
||||
#
|
|
@ -79,6 +79,12 @@ class User < ActiveRecord::Base
|
|||
where(method: UserSecondFactor.methods[:totp], enabled: true)
|
||||
}, class_name: "UserSecondFactor"
|
||||
|
||||
has_one :anonymous_user_master, class_name: 'AnonymousUser'
|
||||
has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: 'AnonymousUser'
|
||||
|
||||
has_one :master_user, through: :anonymous_user_master
|
||||
has_one :shadow_user, through: :anonymous_user_shadow, source: :user
|
||||
|
||||
has_one :user_stat, dependent: :destroy
|
||||
has_one :user_profile, dependent: :destroy, inverse_of: :user
|
||||
has_one :profile_background_upload, through: :user_profile
|
||||
|
@ -177,12 +183,9 @@ class User < ActiveRecord::Base
|
|||
# excluding fake users like the system user or anonymous users
|
||||
scope :real, -> { human_users.where('NOT EXISTS(
|
||||
SELECT 1
|
||||
FROM user_custom_fields ucf
|
||||
WHERE
|
||||
ucf.user_id = users.id AND
|
||||
ucf.name = ? AND
|
||||
ucf.value::int > 0
|
||||
)', 'master_id') }
|
||||
FROM anonymous_users a
|
||||
WHERE a.user_id = users.id
|
||||
)') }
|
||||
|
||||
# TODO-PERF: There is no indexes on any of these
|
||||
# and NotifyMailingListSubscribers does a select-all-and-loop
|
||||
|
@ -1149,7 +1152,7 @@ class User < ActiveRecord::Base
|
|||
def anonymous?
|
||||
SiteSetting.allow_anonymous_posting &&
|
||||
trust_level >= 1 &&
|
||||
custom_fields["master_id"].to_i > 0
|
||||
!!anonymous_user_master
|
||||
end
|
||||
|
||||
def is_singular_admin?
|
||||
|
|
|
@ -1,37 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AnonymousShadowCreator
|
||||
attr_reader :user
|
||||
|
||||
def self.get_master(user)
|
||||
return unless user
|
||||
return unless SiteSetting.allow_anonymous_posting
|
||||
|
||||
if (master_id = user.custom_fields["master_id"].to_i) > 0
|
||||
User.find_by(id: master_id)
|
||||
end
|
||||
new(user).get_master
|
||||
end
|
||||
|
||||
def self.get(user)
|
||||
new(user).get
|
||||
end
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def get_master
|
||||
return unless user
|
||||
return unless SiteSetting.allow_anonymous_posting
|
||||
|
||||
user.master_user
|
||||
end
|
||||
|
||||
def get
|
||||
return unless user
|
||||
return unless SiteSetting.allow_anonymous_posting
|
||||
return if user.trust_level < SiteSetting.anonymous_posting_min_trust_level
|
||||
return if SiteSetting.must_approve_users? && !user.approved?
|
||||
|
||||
if (shadow_id = user.custom_fields["shadow_id"].to_i) > 0
|
||||
shadow = User.find_by(id: shadow_id)
|
||||
shadow = user.shadow_user
|
||||
|
||||
if shadow && (shadow.post_count + shadow.topic_count) > 0 &&
|
||||
shadow.last_posted_at < SiteSetting.anonymous_account_duration_minutes.minutes.ago
|
||||
shadow = nil
|
||||
end
|
||||
|
||||
shadow || create_shadow(user)
|
||||
else
|
||||
create_shadow(user)
|
||||
if shadow && (shadow.post_count + shadow.topic_count) > 0 &&
|
||||
shadow.last_posted_at < SiteSetting.anonymous_account_duration_minutes.minutes.ago
|
||||
shadow = nil
|
||||
end
|
||||
|
||||
shadow || create_shadow!
|
||||
end
|
||||
|
||||
def self.create_shadow(user)
|
||||
private
|
||||
|
||||
def create_shadow!
|
||||
username = UserNameSuggester.suggest(I18n.t(:anonymous).downcase)
|
||||
|
||||
User.transaction do
|
||||
|
@ -57,11 +66,8 @@ class AnonymousShadowCreator
|
|||
shadow.email_tokens.update_all(confirmed: true)
|
||||
shadow.activate
|
||||
|
||||
# can not hold dupes
|
||||
UserCustomField.where(user_id: user.id, name: "shadow_id").destroy_all
|
||||
|
||||
UserCustomField.create!(user_id: user.id, name: "shadow_id", value: shadow.id)
|
||||
UserCustomField.create!(user_id: shadow.id, name: "master_id", value: user.id)
|
||||
AnonymousUser.where(master_user_id: user.id, active: true).update_all(active: false)
|
||||
AnonymousUser.create!(user_id: shadow.id, master_user_id: user.id, active: true)
|
||||
|
||||
shadow.reload
|
||||
user.reload
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddUniqueConstraintToShadowAccounts < ActiveRecord::Migration[5.2]
|
||||
|
||||
def up
|
||||
create_table :anonymous_users do |t|
|
||||
t.integer :user_id, null: false
|
||||
t.integer :master_user_id, null: false
|
||||
t.boolean :active, null: false
|
||||
t.timestamps
|
||||
|
||||
t.index [:user_id], unique: true
|
||||
t.index [:master_user_id], unique: true, where: 'active'
|
||||
end
|
||||
|
||||
rows = DB.exec <<~SQL
|
||||
DELETE FROM user_custom_fields
|
||||
WHERE name = 'shadow_id' AND value in (
|
||||
SELECT value
|
||||
FROM user_custom_fields
|
||||
WHERE name = 'shadow_id'
|
||||
GROUP BY value
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
SQL
|
||||
|
||||
if rows > 0
|
||||
STDERR.puts "Removed #{rows} duplicate shadow users"
|
||||
end
|
||||
|
||||
rows = DB.exec <<~SQL
|
||||
INSERT INTO anonymous_users(user_id, master_user_id, created_at, updated_at, active)
|
||||
SELECT value::int, user_id, created_at, updated_at, 't'
|
||||
FROM user_custom_fields
|
||||
WHERE name = 'shadow_id'
|
||||
SQL
|
||||
|
||||
rows += DB.exec <<~SQL
|
||||
INSERT INTO anonymous_users(user_id, master_user_id, created_at, updated_at, active)
|
||||
SELECT f.user_id, value::int, f.created_at, f.updated_at, 'f'
|
||||
FROM user_custom_fields f
|
||||
LEFT JOIN anonymous_users a on a.user_id = f.user_id
|
||||
WHERE name = 'master_id' AND a.user_id IS NULL
|
||||
SQL
|
||||
|
||||
if rows > 0
|
||||
STDERR.puts "Migrated #{rows} anon users to new structure"
|
||||
end
|
||||
|
||||
DB.exec <<~SQL
|
||||
DELETE FROM user_custom_fields
|
||||
WHERE name in ('shadow_id', 'master_id')
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
raise ActiveRecord::IrreversibleMigration
|
||||
end
|
||||
end
|
|
@ -101,9 +101,11 @@ Fabricator(:anonymous, from: :user) do
|
|||
trust_level TrustLevel[1]
|
||||
manual_locked_trust_level TrustLevel[1]
|
||||
|
||||
before_create do |user|
|
||||
user.custom_fields["master_id"] = 1
|
||||
user.save!
|
||||
after_create do
|
||||
# this is not "the perfect" fabricator in that user id -1 is system
|
||||
# but creating a proper account here is real slow and has a huge
|
||||
# impact on the test suite run time
|
||||
create_anonymous_user_master(master_user_id: -1, active: true)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -34,6 +34,9 @@ describe AnonymousShadowCreator do
|
|||
expect(shadow.id).to eq(shadow2.id)
|
||||
create_post(user: shadow)
|
||||
|
||||
user.reload
|
||||
shadow.reload
|
||||
|
||||
freeze_time 4.minutes.from_now
|
||||
shadow3 = AnonymousShadowCreator.get(user)
|
||||
|
||||
|
@ -56,6 +59,7 @@ describe AnonymousShadowCreator do
|
|||
expect(shadow.created_at).not_to eq(user.created_at)
|
||||
|
||||
p = create_post
|
||||
|
||||
expect(Guardian.new(shadow).post_can_act?(p, :like)).to eq(false)
|
||||
expect(Guardian.new(user).post_can_act?(p, :like)).to eq(true)
|
||||
|
||||
|
|
Loading…
Reference in New Issue