discourse/app/models/user.rb
Robin Ward d554a59102 Support for a new site setting: newuser_spam_host_threshold. If a new user posts a link
to the same host enough tiles, they will not be able to post the same link again.

Additionally, the site will flag all their previous posts with links as spam and they will
be instantly hidden via the auto hide workflow.
2013-05-16 12:19:50 -04:00

671 lines
18 KiB
Ruby

require_dependency 'email'
require_dependency 'email_token'
require_dependency 'trust_level'
require_dependency 'pbkdf2'
require_dependency 'summarize'
require_dependency 'discourse'
class User < ActiveRecord::Base
attr_accessible :name, :username, :password, :email, :bio_raw, :website
has_many :posts
has_many :notifications
has_many :topic_users
has_many :topics
has_many :user_open_ids, dependent: :destroy
has_many :user_actions
has_many :post_actions
has_many :email_logs
has_many :post_timings
has_many :topic_allowed_users
has_many :topics_allowed, through: :topic_allowed_users, source: :topic
has_many :email_tokens
has_many :views
has_many :user_visits
has_many :invites
has_many :topic_links
has_one :twitter_user_info, dependent: :destroy
has_one :github_user_info, dependent: :destroy
belongs_to :approved_by, class_name: 'User'
has_many :group_users
has_many :groups, through: :group_users
has_many :secure_categories, through: :groups, source: :categories
validates_presence_of :username
validates_presence_of :email
validates_uniqueness_of :email
validate :username_validator
validate :email_validator, if: :email_changed?
validate :password_validator
before_save :cook
before_save :update_username_lower
before_save :ensure_password_is_hashed
after_initialize :add_trust_level
after_save :update_tracked_topics
after_create :create_email_token
# 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
scope :admins, ->{ where(admin: true) }
scope :moderators, ->{ where(moderator: true) }
scope :staff, ->{ where("moderator = 't' or admin = 't'") }
module NewTopicDuration
ALWAYS = -1
LAST_VISIT = -2
end
def self.username_length
3..15
end
def self.sanitize_username!(name)
name.gsub!(/^[^A-Za-z0-9]+|[^A-Za-z0-9_]+$/, "")
name.gsub!(/[^A-Za-z0-9_]+/, "_")
end
def self.pad_missing_chars_with_1s!(name)
missing_chars = User.username_length.begin - name.length
name << ('1' * missing_chars) if missing_chars > 0
end
def self.find_available_username_based_on(name)
i = 1
attempt = name
until username_available?(attempt)
suffix = i.to_s
max_length = User.username_length.end - suffix.length - 1
attempt = "#{name[0..max_length]}#{suffix}"
i += 1
end
attempt
end
EMAIL = %r{([^@]+)@([^\.]+)}
def self.suggest_username(name)
return unless name.present?
if name =~ EMAIL
# When 'walter@white.com' take 'walter'
name = Regexp.last_match[1]
# When 'me@eviltrout.com' take 'eviltrout'
name = Regexp.last_match[2] if ['i', 'me'].include?(name)
end
sanitize_username!(name)
pad_missing_chars_with_1s!(name)
# Trim extra length
name = name[0..User.username_length.end-1]
find_available_username_based_on(name)
end
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 self.create_for_email(email, opts={})
username = suggest_username(email)
if SiteSetting.call_discourse_hub?
begin
match, available, suggestion = DiscourseHub.nickname_match?(username, email)
username = suggestion unless match || available
rescue => e
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
end
end
user = User.new(email: email, username: username, name: username)
user.trust_level = opts[:trust_level] if opts[:trust_level].present?
user.save!
if SiteSetting.call_discourse_hub?
begin
DiscourseHub.register_nickname(username, email)
rescue => e
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
end
end
user
end
def self.username_available?(username)
lower = username.downcase
User.where(username_lower: lower).blank?
end
def self.username_valid?(username)
u = User.new(username: username)
u.username_format_validator
u.errors[:username].blank?
end
def self.suggest_name(email)
return "" unless email
name = email.split(/[@\+]/)[0]
name = name.gsub(".", " ")
name.titleize
end
# Find a user by temporary key, nil if not found or key is invalid
def self.find_by_temporary_key(key)
user_id = $redis.get("temporary_key:#{key}")
if user_id.present?
where(id: user_id.to_i).first
end
end
def self.find_by_username_or_email(username_or_email)
lower_user = username_or_email.downcase
lower_email = Email.downcase(username_or_email)
where("username_lower = :user or lower(username) = :user or email = :email or lower(name) = :user", user: lower_user, email: lower_email)
end
def save_and_refresh_staff_groups!
transaction do
self.save!
Group.refresh_automatic_groups!(:admins,:moderators,:staff)
end
end
def grant_moderation!
self.moderator = true
save_and_refresh_staff_groups!
end
def revoke_moderation!
self.moderator = false
save_and_refresh_staff_groups!
end
def grant_admin!
self.admin = true
save_and_refresh_staff_groups!
end
def revoke_admin!
self.admin = false
save_and_refresh_staff_groups!
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 change_username(new_username)
current_username, self.username = username, new_username
if SiteSetting.call_discourse_hub? && valid?
begin
DiscourseHub.change_nickname(current_username, new_username)
rescue DiscourseHub::NicknameUnavailable
false
rescue => e
Rails.logger.error e.message + "\n" + e.backtrace.join("\n")
end
end
save
end
# Use a temporary key to find this user, store it in redis with an expiry
def temporary_key
key = SecureRandom.hex(32)
$redis.setex "temporary_key:#{key}", 1.week, id.to_s
key
end
# 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
used_invite = invites.where("redeemed_at is not null").includes(:invited_by).first
used_invite.try(:invited_by)
end
# Approve this user
def approve(approved_by)
self.approved = true
self.approved_by = approved_by
self.approved_at = Time.now
enqueue_welcome_message('welcome_approved') if save
end
def self.email_hash(email)
Digest::MD5.hexdigest(email.strip.downcase)
end
def email_hash
User.email_hash(email)
end
def unread_notifications_by_type
@unread_notifications_by_type ||= notifications.where("id > ? and read = false", seen_notification_id).group(:notification_type).count
end
def reload
@unread_notifications_by_type = nil
@unread_pms = nil
super
end
def unread_private_messages
@unread_pms ||= notifications.where("read = false AND notification_type = ?", Notification.types[:private_message]).count
end
def unread_notifications
unread_notifications_by_type.except(Notification.types[:private_message]).values.sum
end
def saw_notification_id(notification_id)
User.update_all ["seen_notification_id = ?", notification_id],
["seen_notification_id < ?", notification_id]
end
def publish_notifications_state
MessageBus.publish("/notification/#{id}",
{ unread_notifications: unread_notifications,
unread_private_messages: unread_private_messages },
user_ids: [id] # only publish the notification to this user
)
end
# A selection of people to autocomplete on @mention
def self.mentionable_usernames
User.select(:username).order('last_posted_at desc').limit(20)
end
# any user that is either a moderator or an admin
def staff?
admin || moderator
end
def regular?
!(admin? || moderator?)
end
def password=(password)
# special case for passwordless accounts
@raw_password = password unless password.blank?
end
# Indicate that this is NOT a passwordless account for the purposes of validation
def password_required!
@password_required = true
end
def confirm_password?(password)
return false unless password_hash && salt
self.password_hash == hash_password(password, salt)
end
def seen?(date)
last_seen_at.to_date >= date if last_seen_at.present?
end
def seen_before?
last_seen_at.present?
end
def has_visit_record?(date)
user_visits.where(visited_at: date).first
end
def update_visit_record!(date)
unless seen_before?
user_visits.create!(visited_at: date)
update_column(:days_visited, 1)
end
unless seen?(date) || has_visit_record?(date)
user_visits.create!(visited_at: date)
User.update_all('days_visited = days_visited + 1', id: self.id)
end
end
def update_ip_address!(new_ip_address)
unless ip_address == new_ip_address || new_ip_address.blank?
update_column(:ip_address, new_ip_address)
end
end
def update_last_seen!
now = DateTime.now
now_date = now.to_date
# Only update last seen once every minute
redis_key = "user:#{self.id}:#{now_date.to_s}"
if $redis.setnx(redis_key, "1")
$redis.expire(redis_key, SiteSetting.active_user_rate_limit_secs)
update_visit_record!(now_date)
# using update_column to avoid the AR transaction
# Keep track of our last visit
if seen_before? && (self.last_seen_at < (now - SiteSetting.previous_visit_timeout_hours.hours))
previous_visit_at = last_seen_at
update_column(:previous_visit_at, previous_visit_at)
end
update_column(:last_seen_at, now)
end
end
def self.avatar_template(email)
email_hash = self.email_hash(email)
# robohash was possibly causing caching issues
# robohash = CGI.escape("http://robohash.org/size_") << "{size}x{size}" << CGI.escape("/#{email_hash}.png")
"https://www.gravatar.com/avatar/#{email_hash}.png?s={size}&r=pg&d=identicon"
end
# Don't pass this up to the client - it's meant for server side use
# The only spot this is now used is for self oneboxes in open graph data
def small_avatar_url
"https://www.gravatar.com/avatar/#{email_hash}.png?s=60&r=pg&d=identicon"
end
# return null for local avatars, a template for gravatar
def avatar_template
User.avatar_template(email)
end
# Updates the denormalized view counts for all users
def self.update_view_counts
# Update denormalized topics_entered
exec_sql "UPDATE users SET topics_entered = x.c
FROM
(SELECT v.user_id,
COUNT(DISTINCT parent_id) AS c
FROM views AS v
WHERE parent_type = 'Topic'
GROUP BY v.user_id) AS X
WHERE x.user_id = users.id"
# Update denormalzied posts_read_count
exec_sql "UPDATE users SET posts_read_count = x.c
FROM
(SELECT pt.user_id,
COUNT(*) AS c
FROM post_timings AS pt
GROUP BY pt.user_id) AS X
WHERE x.user_id = users.id"
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 post_count
posts.count
end
def flags_given_count
PostAction.where(user_id: id, post_action_type_id: PostActionType.flag_types.values).count
end
def flags_received_count
posts.includes(:post_actions).where('post_actions.post_action_type_id' => PostActionType.flag_types.values).count
end
def private_topics_count
topics_allowed.where(archetype: Archetype.private_message).count
end
def bio_excerpt
PrettyText.excerpt(bio_cooked, 350)
end
def delete_all_posts!(guardian)
raise Discourse::InvalidAccess unless guardian.can_delete_all_posts? self
posts.order("post_number desc").each do |p|
if p.post_number == 1
p.topic.trash!
# TODO: But the post is not destroyed. Why?
else
# TODO: This should be using the PostDestroyer!
p.trash!
end
end
end
def is_banned?
banned_till && banned_till > DateTime.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 "Invalid trust level #{level}" unless TrustLevel.valid_level?(level)
admin? || moderator? || TrustLevel.compare(trust_level, level)
end
# a touch faster than automatic
def admin?
admin
end
def change_trust_level!(level)
raise "Invalid trust level #{level}" unless TrustLevel.valid_level?(level)
self.trust_level = TrustLevel.levels[level]
transaction do
self.save!
Group.user_trust_level_change!(self.id, self.trust_level)
end
end
def guardian
Guardian.new(self)
end
def username_format_validator
validator = UsernameValidator.new(username)
unless validator.valid_format?
validator.errors.each { |e| errors.add(:username, e) }
end
end
def email_confirmed?
email_tokens.where(email: email, confirmed: true).present? || email_tokens.empty?
end
def activate
email_token = self.email_tokens.active.first
if email_token
EmailToken.confirm(email_token.token)
else
self.active = true
save
end
end
def deactivate
self.active = false
save
end
def treat_as_new_topic_start_date
duration = new_topic_duration_minutes || SiteSetting.new_topic_duration_minutes
case duration
when User::NewTopicDuration::ALWAYS
created_at
when User::NewTopicDuration::LAST_VISIT
previous_visit_at || created_at
else
duration.minutes.ago
end
end
MAX_TIME_READ_DIFF = 100
# attempt to add total read time to user based on previous time this was called
def update_time_read!
last_seen_key = "user-last-seen:#{id}"
last_seen = $redis.get(last_seen_key)
if last_seen.present?
diff = (Time.now.to_f - last_seen.to_f).round
if diff > 0 && diff < MAX_TIME_READ_DIFF
User.update_all ["time_read = time_read + ?", diff], id: id, time_read: time_read
end
end
$redis.set(last_seen_key, Time.now.to_f)
end
def readable_name
return "#{name} (#{username})" if name.present? && name != username
username
end
def bio_summary
return nil unless bio_cooked.present?
Summarize.new(bio_cooked).summary
end
def self.count_by_signup_date(sinceDaysAgo=30)
where('created_at > ?', sinceDaysAgo.days.ago).group('date(created_at)').order('date(created_at)').count
end
def self.counts_by_trust_level
group('trust_level').count
end
def update_topic_reply_count
self.topic_reply_count =
Topic
.where(['id in (
SELECT topic_id FROM posts p
JOIN topics t2 ON t2.id = p.topic_id
WHERE p.deleted_at IS NULL AND
t2.user_id <> p.user_id AND
p.user_id = ?
)', self.id])
.count
end
def secure_category_ids
cats = self.staff? ? Category.select(:id).where(secure: true) : secure_categories.select('categories.id')
cats.map{|c| c.id}.sort
end
# Flag all posts from a user as spam
def flag_linked_posts_as_spam
admin = Discourse.system_user
topic_links.includes(:post).each do |tl|
begin
PostAction.act(admin, tl.post, PostActionType.types[:spam])
rescue PostAction::AlreadyActed
# If the user has already acted, just ignore it
end
end
end
protected
def cook
if bio_raw.present?
self.bio_cooked = PrettyText.cook(bio_raw) if bio_raw_changed?
else
self.bio_cooked = nil
end
end
def update_tracked_topics
return unless auto_track_topics_after_msecs_changed?
where_conditions = {notifications_reason_id: nil, user_id: id}
if auto_track_topics_after_msecs < 0
TopicUser.update_all({notification_level: TopicUser.notification_levels[:regular]}, where_conditions)
else
TopicUser.update_all(["notification_level = CASE WHEN total_msecs_viewed < ? THEN ? ELSE ? END",
auto_track_topics_after_msecs, TopicUser.notification_levels[:regular], TopicUser.notification_levels[:tracking]], where_conditions)
end
end
def create_email_token
email_tokens.create(email: email)
end
def ensure_password_is_hashed
if @raw_password
self.salt = SecureRandom.hex(16)
self.password_hash = hash_password(@raw_password, salt)
end
end
def hash_password(password, salt)
Pbkdf2.hash_password(password, salt, Rails.configuration.pbkdf2_iterations)
end
def add_trust_level
# there is a possiblity 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_username_lower
self.username_lower = username.downcase
end
def username_validator
username_format_validator || begin
lower = username.downcase
if username_changed? && User.where(username_lower: lower).exists?
errors.add(:username, I18n.t(:'user.username.unique'))
end
end
end
def email_validator
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
end
end
def email_in_restriction_setting?(setting)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
self.email =~ regexp
end
def password_validator
if (@raw_password && @raw_password.length < 6) || (@password_required && !@raw_password)
errors.add(:password, "must be 6 letters or longer")
end
end
end