diff --git a/status.rb b/status.rb new file mode 100644 index 0000000..e955fca --- /dev/null +++ b/status.rb @@ -0,0 +1,4 @@ +class Status < ApplicationRecord + belongs_to :user + has_one_attached :icon +end diff --git a/user.rb b/user.rb new file mode 100644 index 0000000..f93f885 --- /dev/null +++ b/user.rb @@ -0,0 +1,625 @@ +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