class User < ApplicationRecord audited redacted: [:encrypted_password, :password_salt] include Justifiable include WorksOwner include PasswordResetsLimitable include UserLoggable include Searchable devise :database_authenticatable, :confirmable, :registerable, :rememberable, :trackable, :validatable, :lockable, :recoverable devise :pwned_password unless Rails.env.test? # Must come after Devise modules in order to alias devise_valid_password? # properly include BackwardsCompatiblePasswordDecryptor # Allows other models to get the current user with User.current_user thread_cattr_accessor :current_user # Authorization plugin acts_as_authorized_user acts_as_authorizable has_many :roles_users has_many :roles, through: :roles_users, dependent: :destroy ### BETA INVITATIONS ### has_many :invitations, as: :creator has_one :invitation, as: :invitee has_many :user_invite_requests, dependent: :destroy attr_accessor :invitation_token # attr_accessible :invitation_token after_create :mark_invitation_redeemed, :remove_from_queue has_many :external_authors, dependent: :destroy has_many :external_creatorships, foreign_key: "archivist_id" has_many :fannish_next_of_kins, dependent: :delete_all, inverse_of: :kin, foreign_key: :kin_id has_one :fannish_next_of_kin, dependent: :destroy has_many :favorite_tags, dependent: :destroy has_one :default_pseud, -> { where(is_default: true) }, class_name: "Pseud", inverse_of: :user delegate :id, to: :default_pseud, prefix: true has_many :pseuds, dependent: :destroy validates_associated :pseuds has_one :profile, dependent: :destroy validates_associated :profile has_one :preference, dependent: :destroy validates_associated :preference has_many :statuses, dependent: :destroy has_many :blocks_as_blocked, class_name: "Block", dependent: :delete_all, inverse_of: :blocked, foreign_key: :blocked_id has_many :blocks_as_blocker, class_name: "Block", dependent: :delete_all, inverse_of: :blocker, foreign_key: :blocker_id has_many :blocked_users, through: :blocks_as_blocker, source: :blocked # The block (if it exists) with this user as the blocker and # User.current_user as the blocked: has_one :block_of_current_user, -> { where(blocked: User.current_user) }, class_name: "Block", foreign_key: :blocker_id, inverse_of: :blocker # The block (if it exists) with User.current_user as the blocker and this # user as the blocked: has_one :block_by_current_user, -> { where(blocker: User.current_user) }, class_name: "Block", foreign_key: :blocked_id, inverse_of: :blocked has_many :mutes_as_muted, class_name: "Mute", dependent: :delete_all, inverse_of: :muted, foreign_key: :muted_id has_many :mutes_as_muter, class_name: "Mute", dependent: :delete_all, inverse_of: :muter, foreign_key: :muter_id has_many :muted_users, through: :mutes_as_muter, source: :muted # The mute (if it exists) with User.current_user as the muter and this # user as the muted: has_one :mute_by_current_user, -> { where(muter: User.current_user) }, class_name: "Mute", foreign_key: :muted_id, inverse_of: :muted has_many :skins, foreign_key: "author_id", dependent: :nullify has_many :work_skins, foreign_key: "author_id", dependent: :nullify before_create :create_default_associateds before_destroy :remove_user_from_kudos before_update :add_renamed_at, if: :will_save_change_to_login? after_update :update_pseud_name after_update :send_wrangler_username_change_notification, if: :is_tag_wrangler? after_update :log_change_if_login_was_edited, if: :saved_change_to_login? after_update :log_email_change, if: :saved_change_to_email? after_commit :reindex_user_creations_after_rename has_many :collection_participants, through: :pseuds has_many :collections, through: :collection_participants has_many :invited_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::INVITED) }, through: :collection_participants, source: :collection has_many :participated_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR, CollectionParticipant::MEMBER]) }, through: :collection_participants, source: :collection has_many :maintained_collections, -> { where("collection_participants.participant_role IN (?)", [CollectionParticipant::OWNER, CollectionParticipant::MODERATOR]) }, through: :collection_participants, source: :collection has_many :owned_collections, -> { where("collection_participants.participant_role = ?", CollectionParticipant::OWNER) }, through: :collection_participants, source: :collection has_many :challenge_signups, through: :pseuds has_many :offer_assignments, through: :pseuds has_many :pinch_hit_assignments, through: :pseuds has_many :request_claims, class_name: "ChallengeClaim", foreign_key: "claiming_user_id", inverse_of: :claiming_user has_many :gifts, -> { where(rejected: false) }, through: :pseuds has_many :gift_works, -> { distinct }, through: :pseuds has_many :rejected_gifts, -> { where(rejected: true) }, class_name: "Gift", through: :pseuds has_many :rejected_gift_works, -> { distinct }, through: :pseuds has_many :readings, dependent: :delete_all has_many :bookmarks, through: :pseuds has_many :bookmark_collection_items, through: :bookmarks, source: :collection_items has_many :comments, through: :pseuds has_many :kudos # Nested associations through creatorships got weird after 3.0.x has_many :creatorships, through: :pseuds has_many :works, -> { distinct }, through: :pseuds has_many :series, -> { distinct }, through: :pseuds has_many :chapters, through: :pseuds has_many :related_works, through: :works has_many :parent_work_relationships, through: :works has_many :tags, through: :works has_many :filters, through: :works has_many :direct_filters, through: :works has_many :bookmark_tags, through: :bookmarks, source: :tags has_many :subscriptions, dependent: :destroy has_many :followings, class_name: "Subscription", as: :subscribable, dependent: :destroy has_many :subscribed_users, through: :subscriptions, source: :subscribable, source_type: "User" has_many :subscribers, through: :followings, source: :user thread_cattr_accessor :should_update_wrangling_activity has_many :wrangling_assignments, dependent: :destroy has_many :fandoms, through: :wrangling_assignments has_many :wrangled_tags, class_name: "Tag", as: :last_wrangler has_one :last_wrangling_activity, dependent: :destroy has_many :inbox_comments, dependent: :destroy has_many :feedback_comments, -> { where(is_deleted: false, approved: true).order(created_at: :desc) }, through: :inbox_comments has_many :log_items, dependent: :destroy validates_associated :log_items after_update :expire_caches def expire_caches return unless saved_change_to_login? series.each(&:expire_byline_cache) chapters.each(&:expire_byline_cache) self.works.each do |work| work.touch work.expire_caches end end def remove_user_from_kudos # TODO: AO3-2195 Display orphaned kudos (no users; no IPs so not counted as guest kudos). Kudo.where(user: self).update_all(user_id: nil) end def read_inbox_comments inbox_comments.where(read: true) end def unread_inbox_comments inbox_comments.where(read: false) end def unread_inbox_comments_count unread_inbox_comments.with_bad_comments_removed.count end scope :alphabetical, -> { order(:login) } scope :starting_with, -> (letter) { where('login like ?', "#{letter}%") } scope :valid, -> { where(banned: false, suspended: false) } scope :out_of_invites, -> { where(out_of_invites: true) } validates :login, length: { within: ArchiveConfig.LOGIN_LENGTH_MIN..ArchiveConfig.LOGIN_LENGTH_MAX }, format: { with: /\A[A-Za-z0-9]\w*[A-Za-z0-9]\Z/, min_login: ArchiveConfig.LOGIN_LENGTH_MIN, max_login: ArchiveConfig.LOGIN_LENGTH_MAX }, uniqueness: true, not_forbidden_name: { if: :will_save_change_to_login? } validate :username_is_not_recently_changed, if: :will_save_change_to_login? validate :admin_username_generic, if: :will_save_change_to_login? # allow nil so can save existing users validates_length_of :password, within: ArchiveConfig.PASSWORD_LENGTH_MIN..ArchiveConfig.PASSWORD_LENGTH_MAX, allow_nil: true, too_short: ts("is too short (minimum is %{min_pwd} characters)", min_pwd: ArchiveConfig.PASSWORD_LENGTH_MIN), too_long: ts("is too long (maximum is %{max_pwd} characters)", max_pwd: ArchiveConfig.PASSWORD_LENGTH_MAX) validates :email, email_format: true, uniqueness: true # Virtual attribute for age check, data processing agreement, and terms of service attr_accessor :age_over_13, :data_processing, :terms_of_service validates :data_processing, acceptance: { allow_nil: false, if: :first_save? } validates :age_over_13, acceptance: { allow_nil: false, if: :first_save? } validates :terms_of_service, acceptance: { allow_nil: false, if: :first_save? } def to_param login end # Override of Devise method to allow user to login with login OR username as # well as to make login case insensitive without losing user-preferred case # for login display def self.find_first_by_auth_conditions(tainted_conditions, options = {}) conditions = devise_parameter_filter.filter(tainted_conditions).merge(options) login = conditions.delete(:login) relation = self.where(conditions) if login.present? # MySQL is case-insensitive with utf8mb4_unicode_ci so we don't have to use # lowercase values relation = relation.where(["login = :value OR email = :value", value: login]) end relation.first end def self.for_claims(claims_ids) joins(:request_claims). where("challenge_claims.id IN (?)", claims_ids) end scope :with_includes_for_admin_index, -> { includes(:roles, :fannish_next_of_kin) } def self.search_multiple_by_email(emails = []) # Normalise and dedupe emails all_emails = emails.map(&:downcase) unique_emails = all_emails.uniq # Find users and their email addresses users = User.where(email: unique_emails) found_emails = users.map(&:email).map(&:downcase) # Remove found users from the total list of unique emails and count duplicates not_found_emails = unique_emails - found_emails num_duplicates = emails.size - unique_emails.size [users, not_found_emails, num_duplicates] end ### AUTHENTICATION AND PASSWORDS def active? !confirmed_at.nil? end def activate return false if self.active? self.update_attribute(:confirmed_at, Time.now.utc) end def create_default_associateds self.pseuds << Pseud.new(name: self.login, is_default: true) self.profile = Profile.new self.preference = Preference.new(locale: Locale.default) end def prevent_password_resets? is_protected_user? || no_resets? end protected def first_save? self.new_record? end # Override of Devise method for email sending to set I18n.locale # Based on https://github.com/heartcombo/devise/blob/v4.9.3/lib/devise/models/authenticatable.rb#L200 def send_devise_notification(notification, *args) I18n.with_locale(preference.locale_for_mails) do devise_mailer.send(notification, self, *args).deliver_now end end public # Returns an array (of pseuds) of this user's co-authors def coauthors works.collect(&:pseuds).flatten.uniq - pseuds end # Gets the user's most recent unposted work def unposted_work return @unposted_work if @unposted_work @unposted_work = unposted_works.first end def unposted_works return @unposted_works if @unposted_works @unposted_works = works.where(posted: false).order("works.created_at DESC") end # removes ALL unposted works def wipeout_unposted_works works.where(posted: false).destroy_all end # Removes all of the user's series that don't have any listed works. def destroy_empty_series series.left_joins(:serial_works).where(serial_works: { id: nil }). destroy_all end # Checks authorship of any sort of object def is_author_of?(item) if item.respond_to?(:pseud_id) pseud_ids.include?(item.pseud_id) elsif item.respond_to?(:user_id) id == item.user_id elsif item.respond_to?(:pseuds) item.pseuds.pluck(:user_id).include?(id) elsif item.respond_to?(:author) self == item.author elsif item.respond_to?(:creator_id) self.id == item.creator_id else false end end # Gets the number of works by this user that the current user can see def visible_work_count Work.owned_by(self).visible_to_user(User.current_user).revealed.non_anon.distinct.count(:id) end # Gets the user account for authored objects if orphaning is enabled def self.orphan_account User.fetch_orphan_account if ArchiveConfig.ORPHANING_ALLOWED end # Is this user an authorized tag wrangler? def tag_wrangler self.is_tag_wrangler? end def is_tag_wrangler? has_role?(:tag_wrangler) end # Is this user an authorized archivist? def archivist self.is_archivist? end def is_archivist? has_role?(:archivist) end # Is this user an authorized official? def official has_role?(:official) end # Is this user a protected user? These are users experiencing certain types # of harassment. def protected_user self.is_protected_user? end def is_protected_user? has_role?(:protected_user) end # Is this user assigned the no resets role? These users do not wish to receive # password resets. def no_resets? has_role?(:no_resets) end # Should this user's comments be spam-checked? def should_spam_check_comments? # When account_age_threshold_for_comment_spam_check is 0, no users' comments should be spam-checked (Time.current - created_at).seconds.in_days.to_i < AdminSetting.current.account_age_threshold_for_comment_spam_check end # Creates log item tracking changes to user def create_log_item(options = {}) options.reverse_merge! note: "System Generated", user_id: self.id LogItem.create(options) end def update_last_wrangling_activity return unless is_tag_wrangler? last_activity = LastWranglingActivity.find_or_create_by(user: self) last_activity.touch end # Returns true if user is the sole author of a work # Should also be true if the user has used more than one of their pseuds on a work def is_sole_author_of?(item) other_pseuds = item.pseuds - pseuds self.is_author_of?(item) && other_pseuds.blank? end # Returns array of works where the user is the sole author def sole_authored_works @sole_authored_works = [] works.where(posted: 1).each do |w| if self.is_sole_author_of?(w) @sole_authored_works << w end end return @sole_authored_works end # Returns array of the user's co-authored works def coauthored_works @coauthored_works = [] works.where(posted: 1).each do |w| unless self.is_sole_author_of?(w) @coauthored_works << w end end return @coauthored_works end # Returns array of collections where the user is the sole author def sole_owned_collections self.collections.to_a.delete_if { |collection| !(collection.all_owners - pseuds).empty? } end ### BETA INVITATIONS ### #If a new user has an invitation_token (meaning they were invited), the method sets the redeemed_at column for that invitation to Time.now def mark_invitation_redeemed unless self.invitation_token.blank? invitation = Invitation.find_by(token: self.invitation_token) if invitation self.update_attribute(:invitation_id, invitation.id) invitation.mark_as_redeemed(self) end end end # Existing users should be removed from the invitations queue def remove_from_queue invite_request = InviteRequest.find_by(email: self.email) invite_request.destroy if invite_request end def fix_user_subscriptions # Delete any subscriptions the user has to deleted items because this causes # the user's subscription page to error @subscriptions = subscriptions.includes(:subscribable) @subscriptions.to_a.each do |sub| if sub.name.nil? sub.destroy end end end def set_user_work_dates # Fix user stats page error caused by the existence of works with nil revised_at dates works.each do |work| if work.revised_at.nil? work.save end IndexQueue.enqueue(work, :main) end end def reindex_user_creations IndexQueue.enqueue_ids(Work, works.pluck(:id), :main) IndexQueue.enqueue_ids(Bookmark, bookmarks.pluck(:id), :main) IndexQueue.enqueue_ids(Series, series.pluck(:id), :main) IndexQueue.enqueue_ids(Pseud, pseuds.pluck(:id), :main) end # Function to make it easier to retrieve info from the audits table. # # Looks up all past values of the given field, excluding the current value of # the field: def historic_values(field) field = field.to_s audits.filter_map do |audit| audit.audited_changes[field] end.flatten.uniq.without(self[field]) end private # Override the default Justifiable enabled check, because we only need to justify # username changes at the moment. def justification_enabled? User.current_user.is_a?(Admin) && login_changed? end # Create and/or return a user account for holding orphaned works def self.fetch_orphan_account orphan_account = User.find_or_create_by(login: "orphan_account") if orphan_account.new_record? Rails.logger.fatal "You must have a User with the login 'orphan_account'. Please create one." end orphan_account end def update_pseud_name return unless saved_change_to_login? && login_before_last_save.present? old_pseud = pseuds.where(name: login_before_last_save).first if login.downcase == login_before_last_save.downcase old_pseud.name = login old_pseud.save! else new_pseud = pseuds.where(name: login).first # do nothing if they already have the matching pseud if new_pseud.blank? if old_pseud.present? # change the old pseud to match old_pseud.name = login old_pseud.save!(validate: false) else # shouldn't be able to get here, but just in case Pseud.create!(name: login, user_id: id) end end end end def reindex_user_creations_after_rename return unless saved_change_to_login? && login_before_last_save.present? # Everything is indexed with the user's byline, # which has the old username, so they all need to be reindexed. reindex_user_creations end def add_renamed_at if User.current_user == self self.renamed_at = Time.current else self.admin_renamed_at = Time.current end end def log_change_if_login_was_edited current_admin = User.current_user if User.current_user.is_a?(Admin) options = { action: ArchiveConfig.ACTION_RENAME, admin: current_admin } options[:note] = if current_admin "Old Username: #{login_before_last_save}, New Username: #{login}, Changed by: #{current_admin.login}, Ticket ID: ##{ticket_number}" else "Old Username: #{login_before_last_save}; New Username: #{login}" end create_log_item(options) end def send_wrangler_username_change_notification return unless saved_change_to_login? && login_before_last_save.present? TagWranglingSupervisorMailer.wrangler_username_change_notification(login_before_last_save, login).deliver_now end def log_email_change current_admin = User.current_user if User.current_user.is_a?(Admin) options = { action: ArchiveConfig.ACTION_NEW_EMAIL, admin_id: current_admin&.id } options[:note] = "Change made by #{current_admin&.login}" if current_admin create_log_item(options) end def remove_stale_from_autocomplete self.class.remove_from_autocomplete(self.autocomplete_search_string_was, self.autocomplete_prefixes, self.autocomplete_value_was) end def username_is_not_recently_changed return if User.current_user.is_a?(Admin) change_interval_days = ArchiveConfig.USER_RENAME_LIMIT_DAYS return unless renamed_at && change_interval_days.days.ago <= renamed_at errors.add(:login, :changed_too_recently, count: change_interval_days, renamed_at: I18n.l(renamed_at)) end def admin_username_generic return unless User.current_user.is_a?(Admin) errors.add(:login, :admin_must_use_default) unless login == "user#{id}" end # Extra callback to make sure readings are deleted in an order consistent # with the ReadingsJob. # # TODO: In the long term, it might be better to change the indexes on the # readings table so that it deletes things in the correct order by default if # we just set dependent: :delete_all, but for now we need to explicitly sort # by work_id to make sure that the readings are locked in the correct order. before_destroy :clear_readings, prepend: true def clear_readings readings.order(:work_id).each(&:delete) end end