app/models/user.rb
# == Schema Information
#
# Table name: users
#
# id :integer not null, primary key
# about :string(500) default(""), not null
# about_formatted :text
# ao_password :string
# ao_pro :integer
# approved_edit_count :integer default(0)
# avatar_content_type :string(255)
# avatar_file_name :string(255)
# avatar_file_size :integer
# avatar_meta :text
# avatar_processing :boolean
# avatar_updated_at :datetime
# bio :string(140) default(""), not null
# birthday :date
# comments_count :integer default(0), not null
# confirmed_at :datetime
# country :string(2)
# cover_image_content_type :string(255)
# cover_image_file_name :string(255)
# cover_image_file_size :integer
# cover_image_meta :text
# cover_image_processing :boolean
# cover_image_updated_at :datetime
# current_sign_in_at :datetime
# deleted_at :datetime
# dropbox_secret :string(255)
# dropbox_token :string(255)
# email :string(255) default(""), indexed
# favorites_count :integer default(0), not null
# feed_completed :boolean default(FALSE), not null
# followers_count :integer default(0)
# following_count :integer default(0)
# gender :string
# import_error :string(255)
# import_from :string(255)
# import_status :integer
# language :string
# last_backup :datetime
# last_recommendations_update :datetime
# last_sign_in_at :datetime
# life_spent_on_anime :integer default(0), not null
# likes_given_count :integer default(0), not null
# likes_received_count :integer default(0), not null
# location :string(255)
# mal_username :string(255)
# media_reactions_count :integer default(0), not null
# name :string(255)
# ninja_banned :boolean default(FALSE)
# password_digest :string(255) default("")
# past_names :string default([]), not null, is an Array
# posts_count :integer default(0), not null
# previous_email :string
# pro_expires_at :datetime
# profile_completed :boolean default(FALSE), not null
# rating_system :integer default(0), not null
# ratings_count :integer default(0), not null
# recommendations_up_to_date :boolean
# rejected_edit_count :integer default(0)
# remember_created_at :datetime
# reviews_count :integer default(0), not null
# sfw_filter :boolean default(TRUE)
# share_to_global :boolean default(TRUE), not null
# sign_in_count :integer default(0)
# slug :citext indexed
# stripe_token :string(255)
# subscribed_to_newsletter :boolean default(TRUE)
# theme :integer default(0), not null
# time_zone :string
# title :string
# title_language_preference :string(255) default("canonical")
# to_follow :boolean default(FALSE), indexed
# waifu_or_husbando :string(255)
# created_at :datetime not null
# updated_at :datetime not null
# ao_facebook_id :string
# ao_id :string
# facebook_id :string(255) indexed
# pinned_post_id :integer
# pro_membership_plan_id :integer
# stripe_customer_id :string(255)
# twitter_id :string
# waifu_id :integer indexed
#
# Indexes
#
# index_users_on_email (email) UNIQUE
# index_users_on_facebook_id (facebook_id) UNIQUE
# index_users_on_slug (slug) UNIQUE
# index_users_on_to_follow (to_follow)
# index_users_on_waifu_id (waifu_id)
#
# Foreign Keys
#
# fk_rails_bc615464bf (pinned_post_id => posts.id)
#
class User < ApplicationRecord
include AvatarUploader::Attachment(:avatar)
include CoverImageUploader::Attachment(:cover_image)
PAST_NAMES_LIMIT = 10
PAST_IPS_LIMIT = 20
RESERVED_NAMES = %w[
admin administrator connect dashboard developer developers edit favorites
feature featured features feed follow followers following hummingbird index
javascript json kitsu sysadmin sysadministrator system unfollow user users
wiki you staff mod
].to_set.freeze
CONTROL_CHARACTERS = /\p{Line_Separator}|\p{Paragraph_Separator}|\p{Other}/u.freeze
BANNED_CHARACTERS = [
# Swastikas
"\u534D",
"\u5350"
].join.freeze
enum rating_system: { simple: 0, advanced: 1, regular: 2 }
enum status: { unregistered: 0, registered: 1, aozora: 2 }
enum theme: { light: 0, dark: 1 }
enum pro_tier: { ao_pro: 0, ao_pro_plus: 1, pro: 2, patron: 3 }
enum email_status: { email_unconfirmed: 0, email_confirmed: 1, email_bounced: 2 }
enum title_language_preference: { canonical: 0, romanized: 1, localized: 2 }
enum sfw_filter_preference: { sfw: 0, nsfw_sometimes: 1, nsfw_everywhere: 2 }
rolify
flag :permissions, %i[admin community_mod database_mod]
has_secure_password validations: false
update_index('users#user') { self }
update_algolia('AlgoliaUsersIndex')
belongs_to :waifu, optional: true, class_name: 'Character'
belongs_to :pinned_post, class_name: 'Post', optional: true
has_one :pro_subscription, dependent: :destroy, required: false
has_many :followers, class_name: 'Follow', foreign_key: 'followed_id', inverse_of: :followed
has_many :following, class_name: 'Follow', foreign_key: 'follower_id', inverse_of: :follower
has_many :comments, dependent: :destroy
has_many :posts, dependent: :destroy
has_many :blocks, dependent: :delete_all
has_many :blocked, class_name: 'Block', foreign_key: 'blocked_id', dependent: :delete_all
has_many :linked_accounts, dependent: :delete_all
has_many :profile_links, dependent: :delete_all
has_many :user_roles, dependent: :delete_all
has_many :library_events, dependent: :delete_all
has_many :library_entries, dependent: :destroy
has_many :favorites, dependent: :destroy
has_many :reviews, dependent: :destroy
has_many :media_reactions, dependent: :destroy
has_many :media_reaction_votes, dependent: :destroy
has_many :comment_likes
has_many :post_likes
has_many :post_follows, dependent: :destroy
has_many :review_likes, dependent: :destroy
has_many :list_imports, dependent: :delete_all
has_many :group_action_logs, dependent: :destroy
has_many :group_bans, dependent: :delete_all
has_many :group_invites, dependent: :destroy
has_many :group_members, dependent: :destroy
has_many :group_reports
has_many :group_reports_as_moderator, class_name: 'GroupReport', foreign_key: 'moderator_id'
has_many :group_ticket_messages, dependent: :destroy
has_many :group_tickets, dependent: :destroy
has_many :leader_chat_messages, dependent: :destroy
has_many :reports
has_many :reports_as_moderator, class_name: 'Report', foreign_key: 'moderator_id'
has_many :site_announcements
has_many :stats, dependent: :delete_all
has_many :library_events, dependent: :delete_all
has_many :notification_settings, dependent: :delete_all
has_many :one_signal_players, dependent: :delete_all
has_many :reposts, dependent: :destroy
has_many :ip_addresses, dependent: :delete_all, class_name: 'UserIpAddress'
has_many :category_favorites, dependent: :delete_all
has_many :quotes, dependent: :nullify
has_many :wiki_submissions
has_many :wiki_submission_logs
validates :email, format: { with: /\A.+@.+\.[a-z]+\z/, message: 'is not an email' },
if: :email_changed?, allow_blank: true
validates :email, :name, :password, :slug, absence: true, if: :unregistered?
validates :email, :name, :password_digest, presence: true, if: :registered?
validates :email, uniqueness: { case_sensitive: false }, if: :email_changed?, allow_blank: true
validates :email, real_email: true, if: ->(user) {
Flipper.enabled?(:email_validation) && user.email_changed?
}
with_options if: :slug_changed?, allow_nil: true do
validates :slug, uniqueness: { case_sensitive: false }
validates :slug, format: {
with: /\A[_A-Za-z0-9]+\z/,
message: 'can only contain letters, numbers, and underscores'
}
validates :slug, format: {
with: /\A[A-Za-z0-9]/,
message: 'must begin with a letter or number'
}
validates :slug, format: {
without: /\A[0-9]*\z/,
message: 'cannot be entirely numbers'
}
validates :slug, length: 3..20
end
validate :not_reserved_slug, if: ->(user) { user.slug.present? && user.slug_changed? }
validate :not_reserved_name, if: :name_changed?
validate :not_taken_on_aozora, on: :create
validates :name, presence: true,
length: { minimum: 3, maximum: 20 },
if: ->(user) { user.registered? && user.name_changed? }
validates :name, format: {
without: CONTROL_CHARACTERS,
message: 'cannot contain control characters, you silly haxx0r'
}, if: ->(user) { user.registered? && user.name_changed? }
validate :not_banned_characters
validates :about, length: { maximum: 500 }
validates :gender, length: { maximum: 20 }
validates :password, length: { maximum: 72 }, if: :registered?
validates :facebook_id, uniqueness: true, allow_nil: true
scope :active, -> { where(deleted_at: nil) }
scope :by_slug, ->(*slugs) { where(slug: slugs&.flatten) }
scope :by_name, ->(*names) {
where('lower(users.name) IN (?)', names&.flatten&.compact&.map(&:downcase))
}
scope :by_email, ->(*emails) {
where('lower(users.email) IN (?)', emails&.flatten&.compact&.map(&:downcase))
}
scope :blocking, ->(*users) { where.not(id: users.flatten) }
scope :followed_first, ->(user) {
user_id = sanitize_sql(user.id)
joins(Arel.sql(<<-SQL.squish)).order(Arel.sql('(f.id IS NULL) ASC'))
LEFT OUTER JOIN follows f
ON f.followed_id = users.id
AND f.follower_id = #{user_id}
SQL
}
alias_method :flipper_id, :id
# @return [User] the system user
def self.system_user
User.find(-11)
end
# @return [User,nil] the current user as stored in the Thread-local variable
def self.current
Thread.current[:current_user]
end
# Override the version provided by has_secure_password to accept the aozora password too
# @param unencrypted_password [String] the unencrypted password to test
def authenticate(unencrypted_password)
[password_digest, ao_password].compact.any? do |password|
BCrypt::Password.new(password).is_password?(unencrypted_password)
end && self
end
def self.find_for_auth(identification)
by_email(identification).or(by_slug(identification)).first
end
def not_reserved_slug
errors.add(:slug, 'is reserved') if RESERVED_NAMES.include?(slug&.downcase)
end
def not_reserved_name
errors.add(:name, 'is reserved') if RESERVED_NAMES.include?(name.downcase)
end
def not_banned_characters
errors.add(:name, 'contains banned characters') if name&.count(BANNED_CHARACTERS) != 0
end
def not_taken_on_aozora
return unless Rails.env.production?
if Zorro::DB::User.find(email: /\A\s*#{email}\s*\z/i).count.nonzero? && ao_id.blank?
errors.add(:email, 'is already taken by an Aozora user')
end
end
def pro_tier
tier = super
# If they're Aozora pro, expiration doesn't apply
return tier if tier.to_s.start_with?('ao_')
# Otherwise check the expiration
tier unless pro_expires_at&.past?
end
def pro?
!pro_tier.nil?
end
def ao_pro
# If they're ao_pro then strip the ao_ prefix and return it
pro_tier.to_s.sub('ao_', '').to_sym if pro_tier.to_s.start_with?('ao_')
end
def pro_streak
return unless pro_started_at
streak_end = [Time.now, pro_expires_at].compact.min
streak_end - pro_started_at
end
def sfw_filter?
sfw_filter_preference == 'sfw'
end
def stripe_customer
@stripe_customer ||= if stripe_customer_id
Stripe::Customer.retrieve(stripe_customer_id)
else
customer = Stripe::Customer.create(email: email)
self.stripe_customer_id = customer.id
customer
end
end
def blocked?(user)
Block.exists?(user: [self, user], blocked: [self, user])
end
def confirmed
return false if confirmed_at.nil?
confirmed_at <= Time.now
end
def confirmed=(val)
self.confirmed_at = Time.now if val
end
def previous_name
past_names.first
end
def one_signal_player_ids
one_signal_players.pluck(:player_id).compact
end
def title_preference_list
case title_language_preference
when :canonical then %i[canonical]
when :romanized then %i[romanized localized canonical]
when :localized then %i[localized romanized canonical]
end
end
def add_ip(new_ip)
ip_addresses.where(ip_address: new_ip).first_or_create
rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation
ip_addresses.where(ip_address: new_ip).first
end
def alts
alts = {}
user_ips = ip_addresses.select(:ip_address)
user_ip_count = user_ips.count
shared_ips = UserIpAddress.where(ip_address: user_ips).where.not(user: self).includes(:user)
alt_ip_counts = UserIpAddress.where(user_id: shared_ips.select(:user_id)).group(:user_id).count
shared_ips.group(:user).count.each do |alt, shared_ips_count|
alts[alt] = shared_ips_count.to_f / [[user_ip_count, alt_ip_counts[alt.id]].min, 2].max
end
alts.sort_by { |_, v| v }.reverse
end
def update_title
if permissions.admin?
self.title = 'Staff'
elsif permissions.database_mod? || permissions.community_mod?
self.title = 'Mod'
end
end
def admin?
permissions.admin? || permissions.database_mod? || permissions.community_mod?
end
def profile_feed
@profile_feed ||= ProfileFeed.new(id)
end
def timeline
@timeline ||= TimelineFeed.new(id)
end
def notifications
@notifications ||= NotificationsFeed.new(id)
end
def site_announcements_feed
@site_announcements_feed ||= SiteAnnouncementsFeed.new(id)
end
def update_feed_completed
return self if feed_completed?
if library_entries.rated.count >= 5 && following.count >= 5 &&
comments.count.nonzero? && post_likes.count >= 3
assign_attributes(feed_completed: true)
end
self
end
def update_feed_completed!
update_feed_completed.save!
end
def update_profile_completed
return self if profile_completed?
if library_entries.rated.count >= 5 && avatar.present? &&
cover_image.present? && about.present? && favorites.count.nonzero?
assign_attributes(profile_completed: true)
end
self
end
def update_profile_completed!
update_profile_completed.save!
end
def enabled_features
features = Flipper.preload_all
flags = features.map { |f| [f.name, f.enabled?(self)] }.to_h
flags.select { |_, enabled| enabled }.keys
end
before_validation if: :email_changed? do
# Strip the email and downcase it just for good measure
self.email = email&.strip&.downcase
end
before_destroy do
UserDeletionService.new(self).delete
end
after_commit on: :create do
# Set up feeds
profile_feed.setup!
timeline.setup!
site_announcements_feed.setup!
# Automatically join "Kitsu" group
GroupMember.create!(user: self, group: Group.kitsu) if Group.kitsu
end
after_create do
# Set up Notification Settings for User
NotificationSetting.setup!(self)
end
before_update do
self.max_pro_streak = [max_pro_streak, pro_streak].compact.max
if name_changed?
# Push it onto the front and limit
self.past_names = [name_was, *past_names].first(PAST_NAMES_LIMIT)
end
self.previous_email = nil if confirmed_at_changed?
self.previous_email = email_was if email_changed?
update_title
update_profile_completed
update_feed_completed
end
after_commit on: :update do
# Update email on Stripe
stripe_customer.save(email: email) if previous_changes['email']
end
after_commit if: ->(u) { u.previous_changes['email'] && !Rails.env.staging? } do
self.confirmed_at = nil
# Send Confirmation Email
Accounts::SendConfirmationEmailWorker.perform_async(self)
end
end