require 'digest/sha1' class User < ActiveRecord::Base class Error < Exception ; end class PrivilegeError < Exception ; end module Levels BLOCKED = 10 MEMBER = 20 GOLD = 30 PLATINUM = 31 BUILDER = 32 CONTRIBUTOR = 33 JANITOR = 35 MODERATOR = 40 ADMIN = 50 end attr_accessor :password, :old_password attr_accessible :enable_privacy_mode, :enable_post_navigation, :new_post_navigation_layout, :password, :old_password, :password_confirmation, :password_hash, :email, :last_logged_in_at, :last_forum_read_at, :has_mail, :receive_email_notifications, :comment_threshold, :always_resize_images, :favorite_tags, :blacklisted_tags, :name, :ip_addr, :time_zone, :default_image_size, :enable_sequential_post_navigation, :per_page, :hide_deleted_posts, :style_usernames, :enable_auto_complete, :custom_style, :as => [:moderator, :janitor, :contributor, :gold, :member, :anonymous, :default, :builder, :admin] attr_accessible :level, :as => :admin validates_length_of :name, :within => 2..100, :on => :create validates_format_of :name, :with => /\A[^\s:]+\Z/, :on => :create, :message => "cannot have whitespace or colons" validates_format_of :name, :with => /\A[^_].*[^_]\Z/, :on => :create, :message => "cannot begin or end with an underscore" validates_uniqueness_of :name, :case_sensitive => false validates_uniqueness_of :email, :case_sensitive => false, :if => lambda {|rec| rec.email.present? && rec.email_changed? } validates_length_of :password, :minimum => 5, :if => lambda {|rec| rec.new_record? || rec.password.present?} validates_inclusion_of :default_image_size, :in => %w(large original) validates_inclusion_of :per_page, :in => 1..100 validates_confirmation_of :password validates_presence_of :email, :if => lambda {|rec| rec.new_record? && Danbooru.config.enable_email_verification?} validates_presence_of :comment_threshold validate :validate_ip_addr_is_not_banned, :on => :create before_validation :normalize_blacklisted_tags before_validation :set_per_page before_create :encrypt_password_on_create before_update :encrypt_password_on_update after_save :update_cache after_update :update_remote_cache before_create :promote_to_admin_if_first_user has_many :feedback, :class_name => "UserFeedback", :dependent => :destroy has_many :posts, :foreign_key => "uploader_id" has_many :bans, :order => "bans.id desc" has_one :recent_ban, :class_name => "Ban", :order => "bans.id desc" has_many :subscriptions, :class_name => "TagSubscription", :foreign_key => "creator_id", :order => "name" has_many :note_versions, :foreign_key => "updater_id" has_many :dmails, :foreign_key => "owner_id", :order => "dmails.id desc" belongs_to :inviter, :class_name => "User" after_update :create_mod_action module BanMethods def validate_ip_addr_is_not_banned if IpBan.is_banned?(CurrentUser.ip_addr) self.errors[:base] << "IP address is banned" return false end end def unban! update_column(:is_banned, false) ban.destroy end end module InvitationMethods def invite!(level) if level.to_i <= Levels::CONTRIBUTOR self.level = level self.inviter_id = CurrentUser.id save end end end module NameMethods extend ActiveSupport::Concern module ClassMethods def name_to_id(name) Cache.get("uni:#{Cache.sanitize(name)}", 4.hours) do select_value_sql("SELECT id FROM users WHERE lower(name) = ?", name.mb_chars.downcase.tr(" ", "_").to_s) end end def id_to_name(user_id) Cache.get("uin:#{user_id}", 4.hours) do select_value_sql("SELECT name FROM users WHERE id = ?", user_id) || Danbooru.config.default_guest_name end end def find_by_name(name) where("lower(name) = ?", name.mb_chars.downcase.tr(" ", "_")).first end def id_to_pretty_name(user_id) id_to_name(user_id).gsub(/([^_])_+(?=[^_])/, "\\1 \\2") end end def pretty_name name.gsub(/([^_])_+(?=[^_])/, "\\1 \\2") end def update_cache Cache.put("uin:#{id}", name) Cache.put("uni:#{Cache.sanitize(name)}", id) end def update_remote_cache if name_changed? Danbooru.config.other_server_hosts.each do |server| Net::HTTP.delete(URI.parse("http://#{server}/users/#{id}/cache")) end end rescue Exception # swallow, since it'll be expired eventually anyway end end module PasswordMethods def bcrypt_password BCrypt::Password.new(bcrypt_password_hash) end def bcrypt_cookie_password_hash bcrypt_password_hash.slice(20, 100) end def encrypt_password_on_create self.password_hash = "" self.bcrypt_password_hash = User.bcrypt(password) end def encrypt_password_on_update return if password.blank? return if old_password.blank? if bcrypt_password == User.sha1(old_password) self.bcrypt_password_hash = User.bcrypt(password) return true else errors[:old_password] = "is incorrect" return false end end def reset_password consonants = "bcdfghjklmnpqrstvqxyz" vowels = "aeiou" pass = "" 6.times do pass << consonants[rand(21), 1] pass << vowels[rand(5), 1] end pass << rand(100).to_s update_column(:bcrypt_password_hash, User.bcrypt(pass)) pass end def reset_password_and_deliver_notice new_password = reset_password() Maintenance::User::PasswordResetMailer.confirmation(self, new_password).deliver end end module AuthenticationMethods extend ActiveSupport::Concern module ClassMethods def authenticate(name, pass) authenticate_hash(name, sha1(pass)) end def authenticate_hash(name, hash) user = find_by_name(name) if user && user.bcrypt_password == hash user else nil end end def authenticate_cookie_hash(name, hash) user = find_by_name(name) if user && user.bcrypt_cookie_password_hash == hash user else nil end end def bcrypt(pass) BCrypt::Password.create(sha1(pass)) end def sha1(pass) Digest::SHA1.hexdigest("#{Danbooru.config.password_salt}--#{pass}--") end end end module FavoriteMethods def favorites Favorite.where("user_id % 100 = #{id % 100} and user_id = #{id}").order("id desc") end def clean_favorite_count? favorite_count < 0 || rand(100) < [Math.log(favorite_count, 2), 5].min end def clean_favorite_count! update_column(:favorite_count, Favorite.for_user(id).count) end def add_favorite!(post) Favorite.add(post, self) clean_favorite_count! if clean_favorite_count? end def remove_favorite!(post) Favorite.remove(post, self) end end module LevelMethods extend ActiveSupport::Concern module ClassMethods def level_hash return { "Member" => Levels::MEMBER, "Gold" => Levels::GOLD, "Platinum" => Levels::PLATINUM, "Builder" => Levels::BUILDER, "Contributor" => Levels::CONTRIBUTOR, "Janitor" => Levels::JANITOR, "Moderator" => Levels::MODERATOR, "Admin" => Levels::ADMIN } end end def promote_to(level) update_attributes({:level => level}, :as => :admin) end def promote_to_admin_if_first_user return if Rails.env.test? if User.count == 0 self.level = Levels::ADMIN else self.level = Levels::MEMBER end end def role case level when Levels::MEMBER :member when Levels::GOLD :gold when Levels::BUILDER :builder when Levels::CONTRIBUTOR :contributor when Levels::MODERATOR :moderator when Levels::JANITOR :janitor when Levels::ADMIN :admin end end def level_string(value = nil) case (value || level) when Levels::BLOCKED "Banned" when Levels::MEMBER "Member" when Levels::BUILDER "Builder" when Levels::GOLD "Gold" when Levels::PLATINUM "Platinum" when Levels::CONTRIBUTOR "Contributor" when Levels::JANITOR "Janitor" when Levels::MODERATOR "Moderator" when Levels::ADMIN "Admin" else "" end end def is_anonymous? false end def is_member? true end def is_builder? level >= Levels::BUILDER end def is_gold? level >= Levels::GOLD end def is_platinum? level >= Levels::PLATINUM end def is_contributor? level >= Levels::CONTRIBUTOR end def is_janitor? level >= Levels::JANITOR end def is_moderator? level >= Levels::MODERATOR end def is_mod? level >= Levels::MODERATOR end def is_admin? level >= Levels::ADMIN end def create_mod_action if level_changed? ModAction.create(:description => %{"#{name}":/users/#{id} level changed #{level_string(level_was)} -> #{level_string}}) end end def set_per_page if per_page.nil? || !is_gold? self.per_page = Danbooru.config.posts_per_page end return true end def level_class "user-#{level_string.downcase}" end end module EmailMethods def is_verified? email_verification_key.blank? end def generate_email_verification_key self.email_verification_key = Digest::SHA1.hexdigest("#{Time.now.to_f}--#{name}--#{rand(1_000_000)}--") end def verify!(key) if email_verification_key == key self.update_column(:email_verification_key, nil) else raise User::Error.new("Verification key does not match") end end end module BlacklistMethods def blacklisted_tag_array Tag.scan_query(blacklisted_tags) end def normalize_blacklisted_tags self.blacklisted_tags = blacklisted_tags.downcase if blacklisted_tags.present? end end module ForumMethods def has_forum_been_updated? return false unless is_gold? newest_topic = ForumTopic.order("updated_at desc").first return false if newest_topic.nil? return true if last_forum_read_at.nil? return newest_topic.updated_at > last_forum_read_at end end module LimitMethods def can_upload? if is_contributor? true elsif created_at > 1.week.ago false else upload_limit > 0 end end def upload_limited_reason if created_at > 1.week.ago "cannot upload during your first week of registration" else "can not upload until your pending posts have been approved" end end def can_comment? if is_gold? true else created_at <= Danbooru.config.member_comment_time_threshold end end def is_comment_limited? if is_gold? false else Comment.where("creator_id = ? and created_at > ?", id, 1.hour.ago).count >= Danbooru.config.member_comment_limit end end def can_comment_vote? CommentVote.where("user_id = ? and created_at > ?", id, 1.hour.ago).count < 10 end def can_remove_from_pools? created_at <= 1.week.ago end def upload_limit deleted_count = Post.for_user(id).deleted.where("is_banned = false").count pending_count = Post.for_user(id).pending.count approved_count = Post.where("is_flagged = false and is_pending = false and is_deleted = false and uploader_id = ?", id).count if base_upload_limit.to_i != 0 limit = [base_upload_limit - (deleted_count / 4), 4].max - pending_count else limit = [10 + (approved_count / 10) - (deleted_count / 4), 4].max - pending_count end if limit < 0 limit = 0 end limit end def tag_query_limit if is_platinum? Danbooru.config.base_tag_query_limit * 2 elsif is_gold? Danbooru.config.base_tag_query_limit else 2 end end def favorite_limit if is_platinum? nil elsif is_gold? 20_000 else 10_000 end end def api_hourly_limit if is_platinum? 20_000 elsif is_gold? 10_000 else 3_000 end end def statement_timeout if is_platinum? 9_000 elsif is_gold? 6_000 else 3_000 end end end module ApiMethods def hidden_attributes super + [:password_hash, :bcrypt_password_hash, :email, :email_verification_key, :time_zone, :updated_at, :receive_email_notifications, :last_logged_in_at, :last_forum_read_at, :has_mail, :default_image_size, :comment_threshold, :always_resize_images, :favorite_tags, :blacklisted_tags, :recent_tags, :enable_privacy_mode, :enable_post_navigation, :new_post_navigation_layout, :enable_sequential_post_navigation, :hide_deleted_posts, :per_page, :style_usernames, :enable_auto_complete, :custom_style] end def serializable_hash(options = {}) options ||= {} options[:except] ||= [] options[:except] += hidden_attributes options[:methods] ||= [] options[:methods] += [:wiki_page_version_count, :artist_version_count, :artist_commentary_version_count, :pool_version_count, :forum_post_count, :comment_count, :appeal_count, :flag_count, :positive_feedback_count, :neutral_feedback_count, :negative_feedback_count] super(options) end def to_xml(options = {}, &block) # to_xml ignores the serializable_hash method options ||= {} options[:except] ||= [] options[:except] += hidden_attributes options[:methods] ||= [] options[:methods] += [:wiki_page_version_count, :artist_version_count, :artist_commentary_version_count, :pool_version_count, :forum_post_count, :comment_count, :appeal_count, :flag_count, :positive_feedback_count, :neutral_feedback_count, :negative_feedback_count] super(options, &block) end def to_legacy_json return { "name" => name, "id" => id, "level" => level, "created_at" => created_at.strftime("%Y-%m-%d %H:%M") }.to_json end end module CountMethods def wiki_page_version_count WikiPageVersion.for_user(id).count end def artist_version_count ArtistVersion.for_user(id).count end def artist_commentary_version_count ArtistCommentaryVersion.for_user(id).count end def pool_version_count PoolVersion.for_user(id).count end def forum_post_count ForumPost.for_user(id).count end def comment_count Comment.for_creator(id).count end def appeal_count PostAppeal.for_creator(id).count end def flag_count PostFlag.for_creator(id).count end def positive_feedback_count feedback.positive.count end def neutral_feedback_count feedback.neutral.count end def negative_feedback_count feedback.negative.count end end module SearchMethods def named(name) where("lower(name) = ?", name) end def name_matches(name) where("lower(name) like ? escape E'\\\\'", name.to_escaped_for_sql_like) end def admins where("level = ?", Levels::ADMIN) end def with_email(email) if email.blank? where("FALSE") else where("email = ?", email) end end def find_for_password_reset(name, email) if email.blank? where("FALSE") else where(["name = ? AND email = ?", name, email]) end end def search(params) q = scoped return q if params.blank? if params[:name].present? q = q.name_matches(params[:name].mb_chars.downcase.strip.tr(" ", "_")) end if params[:name_matches].present? q = q.name_matches(params[:name_matches].mb_chars.downcase.strip.tr(" ", "_")) end if params[:min_level].present? q = q.where("level >= ?", params[:min_level].to_i) end if params[:max_level].present? q = q.where("level <= ?", params[:max_level].to_i) end if params[:level].present? q = q.where("level = ?", params[:level].to_i) end if params[:id].present? q = q.where("id in (?)", params[:id].split(",").map(&:to_i)) end case params[:order] when "name" q = q.order("name") when "post_upload_count" q = q.order("post_upload_count desc") when "note_count" q = q.order("note_update_count desc") when "post_update_count" q = q.order("post_update_count desc") else q = q.order("created_at desc") end q end end include BanMethods include NameMethods include PasswordMethods include AuthenticationMethods include FavoriteMethods include LevelMethods include EmailMethods include BlacklistMethods include ForumMethods include LimitMethods include InvitationMethods include ApiMethods include CountMethods extend SearchMethods def initialize_default_image_size self.default_image_size = "large" end def can_update?(object, foreign_key = :user_id) is_moderator? || is_admin? || object.__send__(foreign_key) == id end def dmail_count if has_mail? "(#{dmails.unread.count})" else "" end end end