# frozen_string_literal: true class User < ActiveRecord::Base self.ignored_columns = [ :old_seen_notification_id, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written. :salt, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written. :password_hash, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written. :password_algorithm, # TODO: Remove when column is dropped. At this point, the migration to drop the column has not been written. :old_seen_notification_id, # TODO: Remove once 20240829140226_drop_old_notification_id_columns has been promoted to pre-deploy ] include Searchable include Roleable include HasCustomFields include SecondFactorManager include HasDestroyedWebHook include HasDeprecatedColumns DEFAULT_FEATURED_BADGE_COUNT = 3 MAX_SIMILAR_USERS = 10 deprecate_column :flag_level, drop_from: "3.2" # not deleted on user delete has_many :posts has_many :topics has_many :uploads has_many :category_users, dependent: :destroy has_many :tag_users, dependent: :destroy has_many :user_api_keys, dependent: :destroy has_many :topic_allowed_users, dependent: :destroy has_many :user_archived_messages, dependent: :destroy has_many :email_change_requests, dependent: :destroy has_many :email_tokens, dependent: :destroy has_many :topic_links, dependent: :destroy has_many :user_uploads, dependent: :destroy has_many :upload_references, as: :target, dependent: :destroy has_many :user_emails, dependent: :destroy, autosave: true has_many :user_associated_accounts, dependent: :destroy has_many :oauth2_user_infos, dependent: :destroy has_many :user_second_factors, dependent: :destroy has_many :user_badges, -> { for_enabled_badges }, dependent: :destroy has_many :user_auth_tokens, dependent: :destroy has_many :group_users, dependent: :destroy has_many :user_warnings, dependent: :destroy has_many :api_keys, dependent: :destroy has_many :push_subscriptions, dependent: :destroy has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: "GroupHistory" has_many :targeted_group_histories, dependent: :destroy, foreign_key: :target_user_id, class_name: "GroupHistory" has_many :reviewable_scores, dependent: :destroy has_many :invites, foreign_key: :invited_by_id, dependent: :destroy has_many :user_custom_fields, dependent: :destroy has_many :user_associated_groups, dependent: :destroy has_many :pending_posts, -> { merge(Reviewable.pending) }, class_name: "ReviewableQueuedPost", foreign_key: :target_created_by_id has_one :user_option, dependent: :destroy has_one :user_avatar, dependent: :destroy has_one :primary_email, -> { where(primary: true) }, class_name: "UserEmail", dependent: :destroy, autosave: true, validate: false has_one :user_stat, dependent: :destroy has_one :user_profile, dependent: :destroy, inverse_of: :user has_one :single_sign_on_record, dependent: :destroy has_one :anonymous_user_master, class_name: "AnonymousUser", dependent: :destroy has_one :anonymous_user_shadow, ->(record) { where(active: true) }, foreign_key: :master_user_id, class_name: "AnonymousUser", dependent: :destroy has_one :invited_user, dependent: :destroy has_one :user_notification_schedule, dependent: :destroy has_one :user_password, class_name: "UserPassword", dependent: :destroy, autosave: true # delete all is faster but bypasses callbacks has_many :bookmarks, dependent: :delete_all has_many :notifications, dependent: :delete_all has_many :topic_users, dependent: :delete_all has_many :incoming_emails, dependent: :delete_all has_many :user_visits, dependent: :delete_all has_many :user_auth_token_logs, dependent: :delete_all has_many :group_requests, dependent: :delete_all has_many :muted_user_records, class_name: "MutedUser", dependent: :delete_all has_many :ignored_user_records, class_name: "IgnoredUser", dependent: :delete_all has_many :do_not_disturb_timings, dependent: :delete_all has_many :sidebar_sections, dependent: :destroy has_one :user_status, dependent: :destroy # dependent deleting handled via before_destroy (special cases) has_many :user_actions has_many :post_actions has_many :post_timings has_many :directory_items has_many :email_logs has_many :security_keys, -> { where(enabled: true) }, class_name: "UserSecurityKey" has_many :all_security_keys, class_name: "UserSecurityKey" has_many :badges, through: :user_badges has_many :default_featured_user_badges, -> do max_featured_rank = ( if SiteSetting.max_favorite_badges > 0 SiteSetting.max_favorite_badges + 1 else DEFAULT_FEATURED_BADGE_COUNT end ) for_enabled_badges.grouped_with_count.where("featured_rank <= ?", max_featured_rank) end, class_name: "UserBadge" has_many :topics_allowed, through: :topic_allowed_users, source: :topic has_many :groups, through: :group_users has_many :secure_categories, -> { distinct }, through: :groups, source: :categories has_many :associated_groups, through: :user_associated_groups, dependent: :destroy # deleted in user_second_factors relationship has_many :totps, -> { where(method: UserSecondFactor.methods[:totp], enabled: true) }, class_name: "UserSecondFactor" has_one :master_user, through: :anonymous_user_master has_one :shadow_user, through: :anonymous_user_shadow, source: :user has_one :profile_background_upload, through: :user_profile has_one :card_background_upload, through: :user_profile belongs_to :approved_by, class_name: "User" belongs_to :primary_group, class_name: "Group" belongs_to :flair_group, class_name: "Group" has_many :muted_users, through: :muted_user_records has_many :ignored_users, through: :ignored_user_records belongs_to :uploaded_avatar, class_name: "Upload" has_many :sidebar_section_links, dependent: :delete_all has_many :embeddable_hosts delegate :last_sent_email_address, to: :email_logs validates_presence_of :username validate :username_validator, if: :will_save_change_to_username? validate :password_validator validate :name_validator, if: :will_save_change_to_name? validates :name, user_full_name: true, if: :will_save_change_to_name?, length: { maximum: 255 } validates :ip_address, allowed_ip_address: { on: :create } validates :primary_email, presence: true, unless: :skip_email_validation validates :validatable_user_fields_values, watched_words: true, unless: :should_skip_user_fields_validation? validates_associated :primary_email, message: ->(_, user_email) do user_email[:value]&.errors&.[](:email)&.first.to_s end after_initialize :add_trust_level before_validation :set_skip_validate_email after_create :create_email_token after_create :create_user_stat after_create :create_user_option after_create :create_user_profile after_create :set_random_avatar after_create :ensure_in_trust_level_group after_create :set_default_categories_preferences after_create :set_default_tags_preferences after_create :set_default_sidebar_section_links after_update :set_default_sidebar_section_links, if: Proc.new { self.saved_change_to_staged? } after_update :trigger_user_updated_event, if: Proc.new { self.human? && self.saved_change_to_uploaded_avatar_id? } after_update :trigger_user_automatic_group_refresh, if: :saved_change_to_staged? after_update :change_display_name, if: :saved_change_to_name? before_save :update_usernames before_save :match_primary_group_changes before_save :check_if_title_is_badged_granted before_save :apply_watched_words, unless: :should_skip_user_fields_validation? before_save :check_qualification_for_users_directory, if: Proc.new { SiteSetting.bootstrap_mode_enabled } after_save :expire_tokens_if_password_changed after_save :clear_global_notice_if_needed after_save :refresh_avatar after_save :badge_grant after_save :index_search after_save :check_site_contact_username after_save :add_to_user_directory, if: Proc.new { SiteSetting.bootstrap_mode_enabled && @qualified_for_users_directory } after_save do if saved_change_to_uploaded_avatar_id? UploadReference.ensure_exist!(upload_ids: [self.uploaded_avatar_id], target: self) end end after_commit :trigger_user_created_event, on: :create after_commit :trigger_user_destroyed_event, on: :destroy before_destroy do # These tables don't have primary keys, so destroying them with activerecord is tricky: PostTiming.where(user_id: self.id).delete_all TopicViewItem.where(user_id: self.id).delete_all UserAction.where( "user_id = :user_id OR target_user_id = :user_id OR acting_user_id = :user_id", user_id: self.id, ).delete_all # we need to bypass the default scope here, which appears not bypassed for :delete_all # however :destroy it is bypassed PostAction.with_deleted.where(user_id: self.id).delete_all # This is a perf optimisation to ensure we hit the index # without this we need to scan a much larger number of rows DirectoryItem .where(user_id: self.id) .where("period_type in (?)", DirectoryItem.period_types.values) .delete_all # our relationship filters on enabled, this makes sure everything is deleted UserSecurityKey.where(user_id: self.id).delete_all Developer.where(user_id: self.id).delete_all DraftSequence.where(user_id: self.id).delete_all GivenDailyLike.where(user_id: self.id).delete_all MutedUser.where(user_id: self.id).or(MutedUser.where(muted_user_id: self.id)).delete_all IgnoredUser.where(user_id: self.id).or(IgnoredUser.where(ignored_user_id: self.id)).delete_all UserAvatar.where(user_id: self.id).delete_all end # Skip validating email, for example from a particular auth provider plugin attr_accessor :skip_email_validation # Whether we need to be sending a system message after creation attr_accessor :send_welcome_message # This is just used to pass some information into the serializer attr_accessor :notification_channel_position # set to true to optimize creation and save for imports attr_accessor :import_mode # Cache for user custom fields. Currently it is used to display quick search results attr_accessor :custom_data # Information if user was authenticated with OAuth attr_accessor :authenticated_with_oauth scope :with_email, ->(email) { joins(:user_emails).where("lower(user_emails.email) IN (?)", email) } scope :with_primary_email, ->(email) do joins(:user_emails).where( "lower(user_emails.email) IN (?) AND user_emails.primary", email, ) end scope :human_users, ->(allowed_bot_user_ids: nil) do if allowed_bot_user_ids.present? where("users.id > 0 OR users.id IN (?)", allowed_bot_user_ids) else where("users.id > 0") end end # excluding fake users like the system user or anonymous users scope :real, ->(allowed_bot_user_ids: nil) do human_users(allowed_bot_user_ids: allowed_bot_user_ids).where( "NOT EXISTS( SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id )", ) end # TODO-PERF: There is no indexes on any of these # and NotifyMailingListSubscribers does a select-all-and-loop # may want to create an index on (active, silence, suspended_till)? scope :silenced, -> { where("silenced_till IS NOT NULL AND silenced_till > ?", Time.zone.now) } scope :not_silenced, -> { where("silenced_till IS NULL OR silenced_till <= ?", Time.zone.now) } scope :suspended, -> { where("suspended_till IS NOT NULL AND suspended_till > ?", Time.zone.now) } scope :not_suspended, -> { where("suspended_till IS NULL OR suspended_till <= ?", Time.zone.now) } scope :activated, -> { where(active: true) } scope :not_staged, -> { where(staged: false) } scope :filter_by_username, ->(filter) do if filter.is_a?(Array) where("username_lower ~* ?", "(#{filter.join("|")})") else where("username_lower ILIKE ?", "%#{filter}%") end end scope :filter_by_username_or_email, ->(filter) do if filter.is_a?(String) && filter =~ /.+@.+/ # probably an email so try the bypass if user_id = UserEmail.where("lower(email) = ?", filter.downcase).pick(:user_id) return where("users.id = ?", user_id) end end users = joins(:primary_email) if filter.is_a?(Array) users.where( "username_lower ~* :filter OR lower(user_emails.email) SIMILAR TO :filter", filter: "(#{filter.join("|")})", ) else users.where( "username_lower ILIKE :filter OR lower(user_emails.email) ILIKE :filter", filter: "%#{filter}%", ) end end scope :watching_topic, ->(topic) do joins( DB.sql_fragment( "LEFT JOIN category_users ON category_users.user_id = users.id AND category_users.category_id = :category_id", category_id: topic.category_id, ), ) .joins( DB.sql_fragment( "LEFT JOIN topic_users ON topic_users.user_id = users.id AND topic_users.topic_id = :topic_id", topic_id: topic.id, ), ) .joins( "LEFT JOIN tag_users ON tag_users.user_id = users.id AND tag_users.tag_id IN (#{topic.tag_ids.join(",").presence || "NULL"})", ) .where( "category_users.notification_level > 0 OR topic_users.notification_level > 0 OR tag_users.notification_level > 0", ) end module NewTopicDuration ALWAYS = -1 LAST_VISIT = -2 end MAX_STAFF_DELETE_POST_COUNT ||= 5 def self.user_tips @user_tips ||= Enum.new( first_notification: 1, topic_timeline: 2, post_menu: 3, topic_notification_levels: 4, suggested_topics: 5, ) end def should_skip_user_fields_validation? custom_fields_clean? || SiteSetting.disable_watched_word_checking_in_user_fields end def all_sidebar_sections sidebar_sections .or(SidebarSection.public_sections) .includes(:sidebar_urls) .order("(section_type IS NOT NULL) DESC, (public IS TRUE) DESC") end def secured_sidebar_category_ids(user_guardian = nil) user_guardian ||= guardian SidebarSectionLink.where(user_id: self.id, linkable_type: "Category").pluck(:linkable_id) & user_guardian.allowed_category_ids end def visible_sidebar_tags(user_guardian = nil) user_guardian ||= guardian DiscourseTagging.filter_visible( Tag.where( id: SidebarSectionLink.where(user_id: self.id, linkable_type: "Tag").select(:linkable_id), ), user_guardian, ) end def self.max_password_length UserPassword::MAX_PASSWORD_LENGTH end def self.username_length SiteSetting.min_username_length.to_i..SiteSetting.max_username_length.to_i end def self.normalize_username(username) username.to_s.unicode_normalize.downcase if username.present? end def self.username_available?(username, email = nil, allow_reserved_username: false) lower = normalize_username(username) return false if !allow_reserved_username && reserved_username?(lower) return true if !username_exists?(lower) # staged users can use the same username since they will take over the account email.present? && User.joins(:user_emails).exists?( staged: true, username_lower: lower, user_emails: { primary: true, email: email, }, ) end def self.reserved_username?(username) username = normalize_username(username) return true if SiteSetting.here_mention == username SiteSetting .reserved_usernames .unicode_normalize .split("|") .any? { |reserved| username.match?(/\A#{Regexp.escape(reserved).gsub('\*', ".*")}\z/) } end def self.editable_user_custom_fields(by_staff: false) fields = [] fields.push(*DiscoursePluginRegistry.self_editable_user_custom_fields) fields.push(*DiscoursePluginRegistry.staff_editable_user_custom_fields) if by_staff fields.uniq end def self.allowed_user_custom_fields(guardian) fields = [] fields.push(*DiscoursePluginRegistry.public_user_custom_fields) if SiteSetting.public_user_custom_fields.present? fields.push(*SiteSetting.public_user_custom_fields.split("|")) end if guardian.is_staff? if SiteSetting.staff_user_custom_fields.present? fields.push(*SiteSetting.staff_user_custom_fields.split("|")) end fields.push(*DiscoursePluginRegistry.staff_user_custom_fields) end fields.uniq end def self.human_user_id?(user_id) user_id > 0 end def human? User.human_user_id?(self.id) end def bot? !self.human? end def effective_locale if SiteSetting.allow_user_locale && self.locale.present? self.locale else SiteSetting.default_locale end end def bookmarks_of_type(type) bookmarks.where(bookmarkable_type: type) end EMAIL = /([^@]+)@([^\.]+)/ FROM_STAGED = "from_staged" def self.new_from_params(params) user = User.new user.name = params[:name] user.email = params[:email] user.password = params[:password] user.username = params[:username] user end def unstage! if self.staged ActiveRecord::Base.transaction do self.staged = false self.custom_fields[FROM_STAGED] = true self.notifications.destroy_all save! end DiscourseEvent.trigger(:user_unstaged, self) end end def self.suggest_name(string) return "" if string.blank? (string[/\A[^@]+/].presence || string[/[^@]+\z/]).tr(".", " ").titleize end def self.find_by_username_or_email(username_or_email) if username_or_email.include?("@") find_by_email(username_or_email) else find_by_username(username_or_email) end end def self.find_by_email(email, primary: false) if primary self.with_primary_email(Email.downcase(email)).first else self.with_email(Email.downcase(email)).first end end def self.find_by_username(username) find_by(username_lower: normalize_username(username)) end def in_any_groups?(group_ids) group_ids.include?(Group::AUTO_GROUPS[:everyone]) || (is_system_user? && (Group.auto_groups_between(:admins, :trust_level_4) & group_ids).any?) || (group_ids & belonging_to_group_ids).any? end def belonging_to_group_ids @belonging_to_group_ids ||= group_users.pluck(:group_id) end def group_granted_trust_level GroupUser.where(user_id: id).includes(:group).maximum("groups.grant_trust_level") end def visible_groups groups.visible_groups(self) end def enqueue_welcome_message(message_type) return unless SiteSetting.send_welcome_message? Jobs.enqueue(:send_system_message, user_id: id, message_type: message_type) end def enqueue_member_welcome_message return unless SiteSetting.send_tl1_welcome_message? Jobs.enqueue(:send_system_message, user_id: id, message_type: "welcome_tl1_user") end def enqueue_tl2_promotion_message return unless SiteSetting.send_tl2_promotion_message Jobs.enqueue(:send_system_message, user_id: id, message_type: "tl2_promotion_message") end def enqueue_staff_welcome_message(role) return unless staff? return if is_singular_admin? Jobs.enqueue( :send_system_message, user_id: id, message_type: "welcome_staff", message_options: { role: role.to_s, }, ) end def change_username(new_username, actor = nil) UsernameChanger.change(self, new_username, actor) end def created_topic_count stat.topic_count end alias_method :topic_count, :created_topic_count # tricky, we need our bus to be subscribed from the right spot def sync_notification_channel_position @unread_notifications_by_type = nil self.notification_channel_position = MessageBus.last_id("/notification/#{id}") end def invited_by # this is unfortunate, but when an invite is redeemed, # any user created by the invite is created *after* # the invite's redeemed_at invite_redemption_delay = 5.seconds used_invite = Invite .with_deleted .joins(:invited_users) .where( "invited_users.user_id = ? AND invited_users.redeemed_at <= ?", self.id, self.created_at + invite_redemption_delay, ) .first used_invite.try(:invited_by) end def should_validate_email_address? !skip_email_validation && !staged? end def self.email_hash(email) Digest::MD5.hexdigest(email.strip.downcase) end def email_hash User.email_hash(email) end def reload @unread_notifications = nil @all_unread_notifications_count = nil @unread_total_notifications = nil @unread_pms = nil @unread_bookmarks = nil @unread_high_prios = nil @ignored_user_ids = nil @muted_user_ids = nil @belonging_to_group_ids = nil super end def ignored_user_ids @ignored_user_ids ||= ignored_users.pluck(:id) end def muted_user_ids @muted_user_ids ||= muted_users.pluck(:id) end def unread_notifications_of_type(notification_type, since: nil) # perf critical, much more efficient than AR sql = <<~SQL SELECT COUNT(*) FROM notifications n LEFT JOIN topics t ON t.id = n.topic_id WHERE t.deleted_at IS NULL AND n.notification_type = :notification_type AND n.user_id = :user_id AND NOT read #{since ? "AND n.created_at > :since" : ""} SQL # to avoid coalesce we do to_i DB.query_single(sql, user_id: id, notification_type: notification_type, since: since)[0].to_i end def unread_notifications_of_priority(high_priority:) # perf critical, much more efficient than AR sql = <<~SQL SELECT COUNT(*) FROM notifications n LEFT JOIN topics t ON t.id = n.topic_id WHERE t.deleted_at IS NULL AND n.high_priority = :high_priority AND n.user_id = :user_id AND NOT read SQL # to avoid coalesce we do to_i DB.query_single(sql, user_id: id, high_priority: high_priority)[0].to_i end MAX_UNREAD_BACKLOG = 400 def grouped_unread_notifications results = DB.query(<<~SQL, user_id: self.id, limit: MAX_UNREAD_BACKLOG) SELECT X.notification_type AS type, COUNT(*) FROM ( SELECT n.notification_type FROM notifications n LEFT JOIN topics t ON t.id = n.topic_id WHERE t.deleted_at IS NULL AND n.user_id = :user_id AND NOT n.read LIMIT :limit ) AS X GROUP BY X.notification_type SQL results.map! { |row| [row.type, row.count] } results.to_h end def unread_high_priority_notifications @unread_high_prios ||= unread_notifications_of_priority(high_priority: true) end def new_personal_messages_notifications_count args = { user_id: self.id, seen_notification_id: self.seen_notification_id, private_message: Notification.types[:private_message], } DB.query_single(<<~SQL, args).first SELECT COUNT(*) FROM notifications WHERE user_id = :user_id AND id > :seen_notification_id AND NOT read AND notification_type = :private_message SQL end # PERF: This safeguard is in place to avoid situations where # a user with enormous amounts of unread data can issue extremely # expensive queries MAX_UNREAD_NOTIFICATIONS = 99 def self.max_unread_notifications @max_unread_notifications ||= MAX_UNREAD_NOTIFICATIONS end def self.max_unread_notifications=(val) @max_unread_notifications = val end def unread_notifications @unread_notifications ||= begin # perf critical, much more efficient than AR sql = <<~SQL SELECT COUNT(*) FROM ( SELECT 1 FROM notifications n LEFT JOIN topics t ON t.id = n.topic_id WHERE t.deleted_at IS NULL AND n.high_priority = FALSE AND n.user_id = :user_id AND n.id > :seen_notification_id AND NOT read LIMIT :limit ) AS X SQL DB.query_single( sql, user_id: id, seen_notification_id: seen_notification_id, limit: User.max_unread_notifications, )[ 0 ].to_i end end def all_unread_notifications_count @all_unread_notifications_count ||= begin sql = <<~SQL SELECT COUNT(*) FROM ( SELECT 1 FROM notifications n LEFT JOIN topics t ON t.id = n.topic_id WHERE t.deleted_at IS NULL AND n.user_id = :user_id AND n.id > :seen_notification_id AND NOT read LIMIT :limit ) AS X SQL DB.query_single( sql, user_id: id, seen_notification_id: seen_notification_id, limit: User.max_unread_notifications, )[ 0 ].to_i end end def total_unread_notifications @unread_total_notifications ||= notifications.where("read = false").count end def reviewable_count Reviewable.list_for(self, include_claimed_by_others: false).count end def bump_last_seen_notification! query = self.notifications.visible query = query.where("notifications.id > ?", seen_notification_id) if seen_notification_id if max_notification_id = query.maximum(:id) update!(seen_notification_id: max_notification_id) true else false end end def bump_last_seen_reviewable! query = Reviewable.unseen_list_for(self, preload: false) query = query.where("reviewables.id > ?", last_seen_reviewable_id) if last_seen_reviewable_id max_reviewable_id = query.maximum(:id) if max_reviewable_id update!(last_seen_reviewable_id: max_reviewable_id) publish_reviewable_counts end end def publish_reviewable_counts(extra_data = nil) data = { reviewable_count: self.reviewable_count, unseen_reviewable_count: Reviewable.unseen_reviewable_count(self), } data.merge!(extra_data) if extra_data.present? MessageBus.publish("/reviewable_counts/#{self.id}", data, user_ids: [self.id]) end def read_first_notification? self.seen_notification_id != 0 || user_option.skip_new_user_tips end def publish_notifications_state return if !self.allow_live_notifications? # publish last notification json with the message so we can apply an update notification = notifications.visible.order("notifications.created_at desc").first json = NotificationSerializer.new(notification).as_json if notification sql = (<<~SQL) SELECT * FROM ( SELECT n.id, n.read FROM notifications n LEFT JOIN topics t ON n.topic_id = t.id WHERE t.deleted_at IS NULL AND n.high_priority AND n.user_id = :user_id AND NOT read ORDER BY n.id DESC LIMIT 20 ) AS x UNION ALL SELECT * FROM ( SELECT n.id, n.read FROM notifications n LEFT JOIN topics t ON n.topic_id = t.id WHERE t.deleted_at IS NULL AND (n.high_priority = FALSE OR read) AND n.user_id = :user_id ORDER BY n.id DESC LIMIT 20 ) AS y SQL recent = DB.query(sql, user_id: id).map! { |r| [r.id, r.read] } payload = { unread_notifications: unread_notifications, unread_high_priority_notifications: unread_high_priority_notifications, read_first_notification: read_first_notification?, last_notification: json, recent: recent, seen_notification_id: seen_notification_id, } payload[:all_unread_notifications_count] = all_unread_notifications_count payload[:grouped_unread_notifications] = grouped_unread_notifications payload[:new_personal_messages_notifications_count] = new_personal_messages_notifications_count MessageBus.publish("/notification/#{id}", payload, user_ids: [id]) end def publish_do_not_disturb(ends_at: nil) MessageBus.publish("/do-not-disturb/#{id}", { ends_at: ends_at&.httpdate }, user_ids: [id]) end def publish_user_status(status) if status payload = { description: status.description, emoji: status.emoji, ends_at: status.ends_at&.iso8601, } else payload = nil end MessageBus.publish( "/user-status", { id => payload }, group_ids: [Group::AUTO_GROUPS[:trust_level_0]], ) end def password=(pw) # special case for passwordless accounts return if pw.blank? if user_password user_password.password = pw else build_user_password(password: pw) end @raw_password = pw # still required to maintain compatibility with usage of password-related User interface end def password "" # so that validator doesn't complain that a password attribute doesn't exist end def password_hash Discourse.deprecate( "User#password_hash is deprecated, use UserPassword#password_hash instead.", drop_from: "3.3", raise_error: false, ) user_password&.password_hash end def password_algorithm Discourse.deprecate( "User#password_algorithm is deprecated, use UserPassword#password_algorithm instead.", drop_from: "3.3", raise_error: false, ) user_password&.password_algorithm end def salt Discourse.deprecate( "User#password_salt is deprecated, use UserPassword#password_salt instead.", drop_from: "3.3", raise_error: false, ) user_password&.password_salt end # Indicate that this is NOT a passwordless account for the purposes of validation def password_required! @password_required = true end def password_required? !!@password_required end def password_validation_required? password_required? || @raw_password.present? end def has_password? user_password ? true : false end def password_validator PasswordValidator.new(attributes: :password).validate_each(self, :password, @raw_password) end def password_expired?(password) return false if user_password.nil? || user_password.password_expired_at.nil? user_password.password_hash == hash_password(password, user_password.password_salt, user_password.password_algorithm) end def confirm_password?(password) return false if !user_password user_password.confirm_password?(password) end def new_user_posting_on_first_day? !staff? && trust_level < TrustLevel[2] && ( trust_level == TrustLevel[0] || self.first_post_created_at.nil? || self.first_post_created_at >= 24.hours.ago ) end def new_user? (created_at >= 24.hours.ago || trust_level == TrustLevel[0]) && trust_level < TrustLevel[2] && !staff? end def seen_before? last_seen_at.present? end def seen_since?(datetime) seen_before? && last_seen_at >= datetime end def create_visit_record!(date, opts = {}) user_stat.update_column(:days_visited, user_stat.days_visited + 1) user_visits.create!( visited_at: date, posts_read: opts[:posts_read] || 0, mobile: opts[:mobile] || false, ) end def visit_record_for(date) user_visits.find_by(visited_at: date) end def update_visit_record!(date) create_visit_record!(date) unless visit_record_for(date) end def update_timezone_if_missing(timezone) return if timezone.blank? || !TimezoneValidator.valid?(timezone) # we only want to update the user's timezone if they have not set it themselves UserOption.where(user_id: self.id, timezone: nil).update_all(timezone: timezone) end def update_posts_read!(num_posts, opts = {}) now = opts[:at] || Time.zone.now _retry = opts[:retry] || false if user_visit = visit_record_for(now.to_date) user_visit.posts_read += num_posts user_visit.mobile = true if opts[:mobile] user_visit.save user_visit else begin create_visit_record!(now.to_date, posts_read: num_posts, mobile: opts.fetch(:mobile, false)) rescue ActiveRecord::RecordNotUnique if !_retry update_posts_read!(num_posts, opts.merge(retry: true)) else raise end end end end def self.update_ip_address!(user_id, new_ip:, old_ip:) can_update_ip_address = DiscoursePluginRegistry.apply_modifier(:user_can_update_ip_address, user_id: user_id) return if !can_update_ip_address unless old_ip == new_ip || new_ip.blank? DB.exec(<<~SQL, user_id: user_id, ip_address: new_ip) UPDATE users SET ip_address = :ip_address WHERE id = :user_id SQL if SiteSetting.keep_old_ip_address_count > 0 DB.exec(<<~SQL, user_id: user_id, ip_address: new_ip, current_timestamp: Time.zone.now) INSERT INTO user_ip_address_histories (user_id, ip_address, created_at, updated_at) VALUES (:user_id, :ip_address, :current_timestamp, :current_timestamp) ON CONFLICT (user_id, ip_address) DO UPDATE SET updated_at = :current_timestamp SQL DB.exec(<<~SQL, user_id: user_id, offset: SiteSetting.keep_old_ip_address_count) DELETE FROM user_ip_address_histories WHERE id IN ( SELECT id FROM user_ip_address_histories WHERE user_id = :user_id ORDER BY updated_at DESC OFFSET :offset ) SQL end end end def update_ip_address!(new_ip_address) User.update_ip_address!(id, new_ip: new_ip_address, old_ip: ip_address) end def self.last_seen_redis_key(user_id, now) now_date = now.to_date "user:#{user_id}:#{now_date}" end def last_seen_redis_key(now) User.last_seen_redis_key(id, now) end def clear_last_seen_cache!(now = Time.zone.now) Discourse.redis.del(last_seen_redis_key(now)) end def self.should_update_last_seen?(user_id, now = Time.zone.now) return true if SiteSetting.active_user_rate_limit_secs <= 0 Discourse.redis.set( last_seen_redis_key(user_id, now), "1", nx: true, ex: SiteSetting.active_user_rate_limit_secs, ) end def update_last_seen!(now = Time.zone.now, force: false) if !force return if !User.should_update_last_seen?(self.id, now) end update_previous_visit(now) # using update_column to avoid the AR transaction update_column(:last_seen_at, now) update_column(:first_seen_at, now) unless self.first_seen_at DiscourseEvent.trigger(:user_seen, self) end def self.gravatar_template(email) "//#{SiteSetting.gravatar_base_url}/avatar/#{self.email_hash(email)}.png?s={size}&r=pg&d=identicon" end # Don't pass this up to the client - it's meant for server side use # This is used in # - self oneboxes in open graph data # - emails def small_avatar_url avatar_template_url.gsub("{size}", "45") end def avatar_template_url UrlHelper.schemaless UrlHelper.absolute avatar_template end def self.username_hash(username) username .each_char .reduce(0) do |result, char| [((result << 5) - result) + char.ord].pack("L").unpack("l").first end .abs end def self.default_template(username) if SiteSetting.default_avatars.present? urls = SiteSetting.default_avatars.split("\n") return urls[username_hash(username) % urls.size] if urls.present? end system_avatar_template(username) end def self.avatar_template(username, uploaded_avatar_id) username ||= "" return default_template(username) if !uploaded_avatar_id hostname = RailsMultisite::ConnectionManagement.current_hostname UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id) end def self.system_avatar_template(username) normalized_username = normalize_username(username) # TODO it may be worth caching this in a distributed cache, should be benched if SiteSetting.external_system_avatars_enabled url = SiteSetting.external_system_avatars_url.dup url = +"#{Discourse.base_path}#{url}" unless url =~ %r{\Ahttps?://} url.gsub! "{color}", letter_avatar_color(normalized_username) url.gsub! "{username}", UrlHelper.encode_component(username) url.gsub! "{first_letter}", UrlHelper.encode_component(normalized_username.grapheme_clusters.first) url.gsub! "{hostname}", Discourse.current_hostname url else "#{Discourse.base_path}/letter_avatar/#{normalized_username}/{size}/#{LetterAvatar.version}.png" end end def self.letter_avatar_color(username) username ||= "" if SiteSetting.restrict_letter_avatar_colors.present? hex_length = 6 colors = SiteSetting.restrict_letter_avatar_colors length = colors.count("|") + 1 num = color_index(username, length) index = (num * hex_length) + num colors[index, hex_length] else color = LetterAvatar::COLORS[color_index(username, LetterAvatar::COLORS.length)] color.map { |c| c.to_s(16).rjust(2, "0") }.join end end def self.color_index(username, length) Digest::MD5.hexdigest(username)[0...15].to_i(16) % length end def is_system_user? id == Discourse::SYSTEM_USER_ID end def avatar_template use_small_logo = is_system_user? && SiteSetting.logo_small && SiteSetting.use_site_small_logo_as_system_avatar if use_small_logo Discourse.store.cdn_url(SiteSetting.logo_small.url) else self.class.avatar_template(username, uploaded_avatar_id) end end # The following count methods are somewhat slow - definitely don't use them in a loop. # They might need to be denormalized def like_count UserAction.where(user_id: id, action_type: UserAction::WAS_LIKED).count end def like_given_count UserAction.where(user_id: id, action_type: UserAction::LIKE).count end def post_count stat.post_count end def post_edits_count stat.post_edits_count end def increment_post_edits_count stat.increment!(:post_edits_count) end def post_action_type_view @post_action_type_view ||= PostActionTypeView.new end def flags_given_count PostAction.where( user_id: id, post_action_type_id: post_action_type_view.flag_types_without_additional_message.values, ).count end def warnings_received_count user_warnings.count end def flags_received_count posts .includes(:post_actions) .where( "post_actions.post_action_type_id" => post_action_type_view.flag_types_without_additional_message.values, ) .count end def private_topics_count topics_allowed.where(archetype: Archetype.private_message).count end def posted_too_much_in_topic?(topic_id) # Does not apply to staff and non-new members... return false if staff? || (trust_level != TrustLevel[0]) # ... your own topics or in private messages topic = Topic.where(id: topic_id).first return false if topic.try(:private_message?) || (topic.try(:user_id) == self.id) last_action_in_topic = UserAction.last_action_in_topic(id, topic_id) since_reply = Post.where(user_id: id, topic_id: topic_id) since_reply = since_reply.where("id > ?", last_action_in_topic) if last_action_in_topic (since_reply.count >= SiteSetting.newuser_max_replies_per_topic) end def delete_posts_in_batches(guardian, batch_size = 20) raise Discourse::InvalidAccess unless guardian.can_delete_all_posts? self Reviewable.where(created_by_id: id).delete_all posts .order("post_number desc") .limit(batch_size) .each { |p| PostDestroyer.new(guardian.user, p).destroy } end def suspended? !!(suspended_till && suspended_till > Time.zone.now) end def silenced? !!(silenced_till && silenced_till > Time.zone.now) end def silenced_record UserHistory.for(self, :silence_user).order("id DESC").first end def silence_reason silenced_record.try(:details) if silenced? end def silenced_at silenced_record.try(:created_at) if silenced? end def silenced_forever? silenced_till > 100.years.from_now end def suspend_record UserHistory.for(self, :suspend_user).order("id DESC").first end def full_suspend_reason suspend_record.try(:details) if suspended? end def suspend_reason if details = full_suspend_reason return details.split("\n")[0] end nil end def suspended_message return nil unless suspended? message = "login.suspended" if suspend_reason if suspended_forever? message = "login.suspended_with_reason_forever" else message = "login.suspended_with_reason" end end I18n.t( message, date: I18n.l(suspended_till, format: :date_only), reason: Rack::Utils.escape_html(suspend_reason), ) end def suspended_forever? suspended_till > 100.years.from_now end # Use this helper to determine if the user has a particular trust level. # Takes into account admin, etc. def has_trust_level?(level) raise InvalidTrustLevel.new("Invalid trust level #{level}") unless TrustLevel.valid?(level) admin? || moderator? || staged? || TrustLevel.compare(trust_level, level) end def has_trust_level_or_staff?(level) return admin? if level.to_s == "admin" return staff? if level.to_s == "staff" has_trust_level?(level.to_i) end # a touch faster than automatic def admin? admin end def guardian Guardian.new(self) end def username_format_validator UsernameValidator.perform_validation(self, "username") end def email_confirmed? email_tokens.where(email: email, confirmed: true).present? || email_tokens.empty? || single_sign_on_record&.external_email&.downcase == email end def activate email_token = self.email_tokens.create!(email: self.email, scope: EmailToken.scopes[:signup]) EmailToken.confirm(email_token.token, scope: EmailToken.scopes[:signup]) reload end def deactivate(performed_by) self.update!(active: false) if reviewable = ReviewableUser.pending.find_by(target: self) reviewable.perform(performed_by, :delete_user) end end def change_trust_level!(level, opts = nil) Promotion.new(self).change_trust_level!(level, opts) end def readable_name name.present? && name != username ? "#{name} (#{username})" : username end def badge_count user_stat&.distinct_badge_count end def featured_user_badges(limit = nil) if limit.nil? default_featured_user_badges else user_badges.grouped_with_count.where("featured_rank <= ?", limit) end end def self.count_by_signup_date(start_date = nil, end_date = nil, group_id = nil) result = self if start_date && end_date result = result.group("date(users.created_at)") result = result.where("users.created_at >= ? AND users.created_at <= ?", start_date, end_date) result = result.order("date(users.created_at)") end if group_id result = result.joins("INNER JOIN group_users ON group_users.user_id = users.id") result = result.where("group_users.group_id = ?", group_id) end result.count end def self.count_by_first_post(start_date = nil, end_date = nil) result = joins("INNER JOIN user_stats AS us ON us.user_id = users.id") if start_date && end_date result = result.group("date(us.first_post_created_at)") result = result.where( "us.first_post_created_at > ? AND us.first_post_created_at < ?", start_date, end_date, ) result = result.order("date(us.first_post_created_at)") end result.count end def secure_category_ids cats = if self.admin? && !SiteSetting.suppress_secured_categories_from_admin Category.unscoped.where(read_restricted: true) else secure_categories.references(:categories) end cats.pluck("categories.id").sort end # Flag all posts from a user as spam def flag_linked_posts_as_spam results = [] disagreed_flag_post_ids = PostAction .where(post_action_type_id: post_action_type_view.types[:spam]) .where.not(disagreed_at: nil) .pluck(:post_id) topic_links .includes(:post) .where.not(post_id: disagreed_flag_post_ids) .each do |tl| message = I18n.t( "flag_reason.spam_hosts", base_path: Discourse.base_path, locale: SiteSetting.default_locale, ) results << PostActionCreator.create(Discourse.system_user, tl.post, :spam, message: message) end results end def has_uploaded_avatar uploaded_avatar.present? end def find_email if last_sent_email_address.present? && EmailAddressValidator.valid_value?(last_sent_email_address) last_sent_email_address else email end end def tl3_requirements @lq ||= TrustLevel3Requirements.new(self) end def on_tl3_grace_period? return true if SiteSetting.tl3_promotion_min_duration.to_i.days.ago.year < 2013 UserHistory .for(self, :auto_trust_level_change) .where("created_at >= ?", SiteSetting.tl3_promotion_min_duration.to_i.days.ago) .where(previous_value: TrustLevel[2].to_s) .where(new_value: TrustLevel[3].to_s) .exists? end def refresh_avatar return if @import_mode avatar = user_avatar || create_user_avatar if self.primary_email.present? && SiteSetting.automatically_download_gravatars? && !avatar.last_gravatar_download_attempt Jobs.cancel_scheduled_job(:update_gravatar, user_id: self.id, avatar_id: avatar.id) Jobs.enqueue_in(1.second, :update_gravatar, user_id: self.id, avatar_id: avatar.id) end # mark all the user's quoted posts as "needing a rebake" Post.rebake_all_quoted_posts(self.id) if saved_change_to_uploaded_avatar_id? end def first_post_created_at user_stat.try(:first_post_created_at) end def associated_accounts result = [] Discourse.authenticators.each do |authenticator| account_description = authenticator.description_for_user(self) unless account_description.empty? result << { name: authenticator.name, description: account_description } end end result end USER_FIELD_PREFIX ||= "user_field_" def user_fields(field_ids = nil) field_ids = (@all_user_field_ids ||= UserField.pluck(:id)) if field_ids.nil? field_ids.map { |fid| [fid.to_s, custom_fields["#{USER_FIELD_PREFIX}#{fid}"]] }.to_h end def validatable_user_fields_values validatable_user_fields.values.join(" ") end def set_user_field(field_id, value) custom_fields["#{USER_FIELD_PREFIX}#{field_id}"] = value end def apply_watched_words validatable_user_fields.each do |id, value| field = WordWatcher.censor_text(value) field = WordWatcher.replace_text(field) set_user_field(id, field) end end def validatable_user_fields # ignore multiselect fields since they are admin-set and thus not user generated content @public_user_field_ids ||= UserField.public_fields.where.not(field_type: "multiselect").pluck(:id) user_fields(@public_user_field_ids) end def number_of_deleted_posts Post.with_deleted.where(user_id: self.id).where.not(deleted_at: nil).count end def number_of_flagged_posts ReviewableFlaggedPost.where(target_created_by: self.id).count end def number_of_rejected_posts ReviewableQueuedPost.rejected.where(target_created_by_id: self.id).count end def number_of_flags_given PostAction .where(user_id: self.id) .where(disagreed_at: nil) .where(post_action_type_id: post_action_type_view.notify_flag_type_ids) .count end def number_of_suspensions UserHistory.for(self, :suspend_user).count end def create_user_profile UserProfile.create!(user_id: id) end def set_random_avatar if SiteSetting.selectable_avatars_mode != "disabled" if upload = SiteSetting.selectable_avatars.sample update_column(:uploaded_avatar_id, upload.id) UserAvatar.create!(user_id: id, custom_upload_id: upload.id) end end end def anonymous? SiteSetting.allow_anonymous_posting && trust_level >= 1 && !!anonymous_user_master end def is_singular_admin? User.where(admin: true).where.not(id: id).human_users.blank? end def logged_out MessageBus.publish "/logout/#{self.id}", self.id, user_ids: [self.id] DiscourseEvent.trigger(:user_logged_out, self) end def logged_in DiscourseEvent.trigger(:user_logged_in, self) DiscourseEvent.trigger(:user_first_logged_in, self) if !self.seen_before? end def set_automatic_groups return if !active || staged || !email_confirmed? Group .where(automatic: false) .where("LENGTH(COALESCE(automatic_membership_email_domains, '')) > 0") .each do |group| domains = group.automatic_membership_email_domains.gsub(".", '\.') if email =~ Regexp.new("@(#{domains})$", true) && !group.users.include?(self) group.add(self) GroupActionLogger.new(Discourse.system_user, group).log_add_user_to_group(self) end end @belonging_to_group_ids = nil end def email primary_email&.email end # Shortcut to set the primary email of the user. # Automatically removes any identical secondary emails. def email=(new_email) if primary_email primary_email.email = new_email else build_primary_email email: new_email, skip_validate_email: !should_validate_email_address? end if secondary_match = user_emails.detect { |ue| !ue.primary && Email.downcase(ue.email) == Email.downcase(new_email) } secondary_match.mark_for_destruction primary_email.skip_validate_unique_email = true end end def emails self.user_emails.order("user_emails.primary DESC NULLS LAST").pluck(:email) end def secondary_emails self.user_emails.secondary.pluck(:email) end def unconfirmed_emails self .email_change_requests .where.not(change_state: EmailChangeRequest.states[:complete]) .pluck(:new_email) end RECENT_TIME_READ_THRESHOLD ||= 60.days def self.preload_recent_time_read(users) times = UserVisit .where(user_id: users.map(&:id)) .where("visited_at >= ?", RECENT_TIME_READ_THRESHOLD.ago) .group(:user_id) .sum(:time_read) users.each { |u| u.preload_recent_time_read(times[u.id] || 0) } end def preload_recent_time_read(time) @recent_time_read = time end def recent_time_read @recent_time_read ||= self.user_visits.where("visited_at >= ?", RECENT_TIME_READ_THRESHOLD.ago).sum(:time_read) end def from_staged? custom_fields[User::FROM_STAGED] end def mature_staged? from_staged? && self.created_at && self.created_at < 1.day.ago end def next_best_title group_titles_query = groups.where("groups.title <> ''") group_titles_query = group_titles_query.order("groups.id = #{primary_group_id} DESC") if primary_group_id group_titles_query = group_titles_query.order("groups.primary_group DESC").limit(1) if next_best_group_title = group_titles_query.pick(:title) return next_best_group_title end next_best_badge_title = badges.where(allow_title: true).pick(:name) next_best_badge_title ? Badge.display_name(next_best_badge_title) : nil end def create_reviewable return unless SiteSetting.must_approve_users? || SiteSetting.invite_only? return if approved? Jobs.enqueue(:create_user_reviewable, user_id: self.id) end def has_more_posts_than?(max_post_count) return true if user_stat && (user_stat.topic_count + user_stat.post_count) > max_post_count return true if max_post_count < 0 DB.query_single(<<~SQL, user_id: self.id).first > max_post_count SELECT COUNT(1) FROM ( SELECT 1 FROM posts p JOIN topics t ON (p.topic_id = t.id) WHERE p.user_id = :user_id AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND ( t.archetype <> 'private_message' OR EXISTS( SELECT 1 FROM topic_allowed_users a WHERE a.topic_id = t.id AND a.user_id > 0 AND a.user_id <> :user_id ) OR EXISTS( SELECT 1 FROM topic_allowed_groups g WHERE g.topic_id = p.topic_id ) ) LIMIT #{max_post_count + 1} ) x SQL end def create_or_fetch_secure_identifier return secure_identifier if secure_identifier.present? new_secure_identifier = SecureRandom.hex(20) self.update(secure_identifier: new_secure_identifier) new_secure_identifier end def second_factor_security_keys security_keys.where(factor_type: UserSecurityKey.factor_types[:second_factor]) end def second_factor_security_key_credential_ids second_factor_security_keys.pluck(:credential_id) end def passkey_credential_ids security_keys.where(factor_type: UserSecurityKey.factor_types[:first_factor]).pluck( :credential_id, ) end def encoded_username(lower: false) UrlHelper.encode_component(lower ? username_lower : username) end def do_not_disturb? active_do_not_disturb_timings.exists? end def active_do_not_disturb_timings now = Time.zone.now do_not_disturb_timings.where("starts_at <= ? AND ends_at > ?", now, now) end def do_not_disturb_until active_do_not_disturb_timings.maximum(:ends_at) end def shelved_notifications ShelvedNotification.joins(:notification).where("notifications.user_id = ?", self.id) end def allow_live_notifications? seen_since?(30.days.ago) end def username_equals_to?(another_username) username_lower == User.normalize_username(another_username) end def relative_url "#{Discourse.base_path}/u/#{encoded_username}" end def full_url "#{Discourse.base_url}/u/#{encoded_username}" end def display_name if SiteSetting.prioritize_username_in_ux? username else name.presence || username end end def clear_status! user_status.destroy! if user_status publish_user_status(nil) end def set_status!(description, emoji, ends_at = nil) status = { description: description, emoji: emoji, set_at: Time.zone.now, ends_at: ends_at, user_id: id, } validate_status!(status) UserStatus.upsert(status) reload_user_status publish_user_status(user_status) end def has_status? user_status && !user_status.expired? end def new_new_view_enabled? in_any_groups?(SiteSetting.experimental_new_new_view_groups_map) end def watched_precedence_over_muted if user_option.watched_precedence_over_muted.nil? SiteSetting.watched_precedence_over_muted else user_option.watched_precedence_over_muted end end def populated_required_custom_fields? UserField .for_all_users .pluck(:id) .all? { |field_id| custom_fields["#{User::USER_FIELD_PREFIX}#{field_id}"].present? } end def needs_required_fields_check? (required_fields_version || 0) < UserRequiredFieldsVersion.current end def bump_required_fields_version update(required_fields_version: UserRequiredFieldsVersion.current) end def similar_users User .real .where.not(id: self.id) .where(ip_address: self.ip_address, admin: false, moderator: false) end protected def badge_grant BadgeGranter.queue_badge_grant(Badge::Trigger::UserChange, user: self) end def index_search # force is needed as user custom fields are updated using SQL and after_save callback is not triggered SearchIndexer.index(self, force: true) end def clear_global_notice_if_needed return if id < 0 if admin && SiteSetting.has_login_hint SiteSetting.has_login_hint = false SiteSetting.global_notice = "" end end def ensure_in_trust_level_group Group.user_trust_level_change!(id, trust_level) end def create_user_stat UserStat.create!(new_since: Time.zone.now, user_id: id) end def create_user_option UserOption.create!(user_id: id) end def create_email_token email_tokens.create!(email: email, scope: EmailToken.scopes[:signup]) end def expire_tokens_if_password_changed # NOTE: setting raw password is the only valid way of changing a password # the password field in the DB is actually hashed, nobody should be amending direct if @raw_password # Association in model may be out-of-sync UserAuthToken.where(user_id: id).destroy_all email_tokens.where("not expired").update_all(expired: true) if !saved_change_to_id? # We should not carry this around after save @raw_password = nil @password_required = false end end def hash_password(password, salt, algorithm) raise StandardError.new("password is too long") if password.size > User.max_password_length PasswordHasher.hash_password(password: password, salt: salt, algorithm: algorithm) end def add_trust_level # there is a possibility we did not load trust level column, skip it return unless has_attribute? :trust_level self.trust_level ||= SiteSetting.default_trust_level end def update_usernames self.username.unicode_normalize! self.username_lower = username.downcase end USERNAME_EXISTS_SQL = <<~SQL (SELECT users.id AS id, true as is_user FROM users WHERE users.username_lower = :username) UNION ALL (SELECT groups.id, false as is_user FROM groups WHERE lower(groups.name) = :username) SQL def self.username_exists?(username) username = normalize_username(username) DB.exec(User::USERNAME_EXISTS_SQL, username: username) > 0 end def username_validator username_format_validator || begin if will_save_change_to_username? existing = DB.query(USERNAME_EXISTS_SQL, username: self.class.normalize_username(username)) user_id = existing.select { |u| u.is_user }.first&.id same_user = user_id && user_id == self.id errors.add(:username, I18n.t(:"user.username.unique")) if existing.present? && !same_user if confirm_password?(username) || confirm_password?(username.downcase) errors.add(:username, :same_as_password) end end end end def name_validator if name.present? name_pw = name[0...User.max_password_length] if confirm_password?(name_pw) || confirm_password?(name_pw.downcase) errors.add(:name, :same_as_password) end end end def set_default_categories_preferences return if self.staged? values = [] # The following site settings are used to pre-populate default category # tracking settings for a user: # # * default_categories_watching # * default_categories_tracking # * default_categories_watching_first_post # * default_categories_normal # * default_categories_muted %w[watching watching_first_post tracking normal muted].each do |setting| category_ids = SiteSetting.get("default_categories_#{setting}").split("|").map(&:to_i) category_ids.each do |category_id| next if category_id == 0 values << { user_id: self.id, category_id: category_id, notification_level: CategoryUser.notification_levels[setting.to_sym], } end end CategoryUser.insert_all(values) if values.present? end def set_default_tags_preferences return if self.staged? values = [] # The following site settings are used to pre-populate default tag # tracking settings for a user: # # * default_tags_watching # * default_tags_tracking # * default_tags_watching_first_post # * default_tags_muted %w[watching watching_first_post tracking muted].each do |setting| tag_names = SiteSetting.get("default_tags_#{setting}").split("|") now = Time.zone.now Tag .where(name: tag_names) .pluck(:id) .each do |tag_id| values << { user_id: self.id, tag_id: tag_id, notification_level: TagUser.notification_levels[setting.to_sym], created_at: now, updated_at: now, } end end TagUser.insert_all(values) if values.present? end def self.purge_unactivated return [] if SiteSetting.purge_unactivated_users_grace_period_days <= 0 destroyer = UserDestroyer.new(Discourse.system_user) User .joins( "LEFT JOIN user_histories ON user_histories.target_user_id = users.id AND action = #{UserHistory.actions[:deactivate_user]} AND acting_user_id IS NOT NULL", ) .where(active: false) .where("users.created_at < ?", SiteSetting.purge_unactivated_users_grace_period_days.days.ago) .where("NOT admin AND NOT moderator") .where( "NOT EXISTS (SELECT 1 FROM topic_allowed_users tu JOIN topics t ON t.id = tu.topic_id AND t.user_id > 0 WHERE tu.user_id = users.id LIMIT 1) ", ) .where( "NOT EXISTS (SELECT 1 FROM posts p WHERE p.user_id = users.id LIMIT 1) ", ) .where("user_histories.id IS NULL") .limit(200) .find_each do |user| begin destroyer.destroy(user, context: I18n.t(:purge_reason)) rescue Discourse::InvalidAccess # keep going end end end def match_primary_group_changes return unless primary_group_id_changed? self.title = primary_group&.title if Group.exists?(id: primary_group_id_was, title: title) self.flair_group_id = primary_group&.id if flair_group_id == primary_group_id_was end def self.first_login_admin_id User .where(admin: true) .human_users .joins(:user_auth_tokens) .order("user_auth_tokens.created_at") .pick(:id) end private def set_default_sidebar_section_links(update: false) return if staged? || bot? if SiteSetting.default_navigation_menu_categories.present? categories_to_update = SiteSetting.default_navigation_menu_categories.split("|") SidebarSectionLinksUpdater.update_category_section_links( self, category_ids: categories_to_update, ) end if SiteSetting.tagging_enabled && SiteSetting.default_navigation_menu_tags.present? SidebarSectionLinksUpdater.update_tag_section_links( self, tag_ids: Tag.where(name: SiteSetting.default_navigation_menu_tags.split("|")).pluck(:id), ) end end def stat user_stat || create_user_stat end def trigger_user_automatic_group_refresh Group.user_trust_level_change!(id, trust_level) if !staged true end def trigger_user_updated_event DiscourseEvent.trigger(:user_updated, self) true end def check_if_title_is_badged_granted if title_changed? && !new_record? && user_profile badge_matching_title = title && badges.find do |badge| badge.allow_title? && (badge.display_name == title || badge.name == title) end user_profile.update!(granted_title_badge_id: badge_matching_title&.id) end end def previous_visit_at_update_required?(timestamp) seen_before? && (last_seen_at < (timestamp - SiteSetting.previous_visit_timeout_hours.hours)) end def update_previous_visit(timestamp) update_visit_record!(timestamp.to_date) update_column(:previous_visit_at, last_seen_at) if previous_visit_at_update_required?(timestamp) end def change_display_name Jobs.enqueue(:change_display_name, user_id: id, old_name: name_before_last_save, new_name: name) end def trigger_user_created_event DiscourseEvent.trigger(:user_created, self) true end def trigger_user_destroyed_event DiscourseEvent.trigger(:user_destroyed, self) true end def set_skip_validate_email self.primary_email.skip_validate_email = !should_validate_email_address? if self.primary_email true end def check_site_contact_username if (saved_change_to_admin? || saved_change_to_moderator?) && self.username == SiteSetting.site_contact_username && !staff? SiteSetting.set_and_log(:site_contact_username, SiteSetting.defaults[:site_contact_username]) end end def self.ensure_consistency! DB.exec <<~SQL UPDATE users SET uploaded_avatar_id = NULL WHERE uploaded_avatar_id IN ( SELECT u1.uploaded_avatar_id FROM users u1 LEFT JOIN uploads up ON u1.uploaded_avatar_id = up.id WHERE u1.uploaded_avatar_id IS NOT NULL AND up.id IS NULL ) SQL end def validate_status!(status) UserStatus.new(status).validate! end def check_qualification_for_users_directory if (!self.active_was && self.active) || (!self.approved_was && self.approved) || (self.id_was.nil? && self.id.present?) @qualified_for_users_directory = true end end def add_to_user_directory DirectoryItem.add_missing_users_all_periods @qualified_for_users_directory = false end end # == Schema Information # # Table name: users # # id :integer not null, primary key # username :string(60) not null # created_at :datetime not null # updated_at :datetime not null # name :string # last_posted_at :datetime # active :boolean default(FALSE), not null # username_lower :string(60) not null # last_seen_at :datetime # admin :boolean default(FALSE), not null # last_emailed_at :datetime # trust_level :integer not null # approved :boolean default(FALSE), not null # approved_by_id :integer # approved_at :datetime # previous_visit_at :datetime # suspended_at :datetime # suspended_till :datetime # date_of_birth :date # views :integer default(0), not null # flag_level :integer default(0), not null # ip_address :inet # moderator :boolean default(FALSE) # title :string # uploaded_avatar_id :integer # locale :string(10) # primary_group_id :integer # registration_ip_address :inet # staged :boolean default(FALSE), not null # first_seen_at :datetime # silenced_till :datetime # group_locked_trust_level :integer # manual_locked_trust_level :integer # secure_identifier :string # flair_group_id :integer # last_seen_reviewable_id :integer # required_fields_version :integer # seen_notification_id :bigint default(0), not null # # Indexes # # idx_users_admin (id) WHERE admin # idx_users_moderator (id) WHERE moderator # index_users_on_last_posted_at (last_posted_at) # index_users_on_last_seen_at (last_seen_at) # index_users_on_secure_identifier (secure_identifier) UNIQUE # index_users_on_uploaded_avatar_id (uploaded_avatar_id) # index_users_on_username (username) UNIQUE # index_users_on_username_lower (username_lower) UNIQUE #