mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 03:58:34 +08:00
d643294c9d
There was a race condition when 2 invites existed for 1 user where in some cases data from both invites would be used for the redeem. Depending on DB ordering. Fix is to delete duplicate invites earlier in the process prior to `redeem_from_email` being called.
279 lines
7.9 KiB
Ruby
279 lines
7.9 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_dependency 'rate_limiter'
|
|
|
|
class Invite < ActiveRecord::Base
|
|
class UserExists < StandardError; end
|
|
include RateLimiter::OnCreateRecord
|
|
include Trashable
|
|
|
|
rate_limit :limit_invites_per_day
|
|
|
|
belongs_to :user
|
|
belongs_to :topic
|
|
belongs_to :invited_by, class_name: 'User'
|
|
|
|
has_many :invited_groups
|
|
has_many :groups, through: :invited_groups
|
|
has_many :topic_invites
|
|
has_many :topics, through: :topic_invites, source: :topic
|
|
validates_presence_of :invited_by_id
|
|
validates :email, email: true, format: { with: EmailValidator.email_regex }
|
|
|
|
before_create do
|
|
self.invite_key ||= SecureRandom.hex
|
|
end
|
|
|
|
before_validation do
|
|
self.email = Email.downcase(email) unless email.nil?
|
|
end
|
|
|
|
validate :user_doesnt_already_exist
|
|
attr_accessor :email_already_exists
|
|
|
|
def user_doesnt_already_exist
|
|
@email_already_exists = false
|
|
return if email.blank?
|
|
user = Invite.find_user_by_email(email)
|
|
|
|
if user && user.id != self.user_id
|
|
@email_already_exists = true
|
|
errors.add(:email)
|
|
end
|
|
end
|
|
|
|
def redeemed?
|
|
redeemed_at.present?
|
|
end
|
|
|
|
def expired?
|
|
created_at < SiteSetting.invite_expiry_days.days.ago
|
|
end
|
|
|
|
# link_valid? indicates whether the invite link can be used to log in to the site
|
|
def link_valid?
|
|
invalidated_at.nil?
|
|
end
|
|
|
|
def redeem(username: nil, name: nil, password: nil, user_custom_fields: nil, ip_address: nil)
|
|
if !expired? && !destroyed? && link_valid?
|
|
InviteRedeemer.new(self, username, name, password, user_custom_fields, ip_address).redeem
|
|
end
|
|
end
|
|
|
|
def self.invite_by_email(email, invited_by, topic = nil, group_ids = nil, custom_message = nil)
|
|
create_invite_by_email(email, invited_by,
|
|
topic: topic,
|
|
group_ids: group_ids,
|
|
custom_message: custom_message,
|
|
send_email: true
|
|
)
|
|
end
|
|
|
|
# generate invite link
|
|
def self.generate_invite_link(email, invited_by, topic = nil, group_ids = nil)
|
|
invite = create_invite_by_email(email, invited_by,
|
|
topic: topic,
|
|
group_ids: group_ids,
|
|
send_email: false
|
|
)
|
|
|
|
"#{Discourse.base_url}/invites/#{invite.invite_key}" if invite
|
|
end
|
|
|
|
# Create an invite for a user, supplying an optional topic
|
|
#
|
|
# Return the previously existing invite if already exists. Returns nil if the invite can't be created.
|
|
def self.create_invite_by_email(email, invited_by, opts = nil)
|
|
opts ||= {}
|
|
|
|
topic = opts[:topic]
|
|
group_ids = opts[:group_ids]
|
|
send_email = opts[:send_email].nil? ? true : opts[:send_email]
|
|
custom_message = opts[:custom_message]
|
|
lower_email = Email.downcase(email)
|
|
|
|
if user = find_user_by_email(lower_email)
|
|
raise UserExists.new(I18n.t("invite.user_exists",
|
|
email: lower_email,
|
|
username: user.username,
|
|
base_path: Discourse.base_path
|
|
))
|
|
end
|
|
|
|
invite = Invite.with_deleted
|
|
.where(email: lower_email, invited_by_id: invited_by.id)
|
|
.order('created_at DESC')
|
|
.first
|
|
|
|
if invite && (invite.expired? || invite.deleted_at)
|
|
invite.destroy
|
|
invite = nil
|
|
end
|
|
|
|
if invite
|
|
invite.update_columns(
|
|
created_at: Time.zone.now,
|
|
updated_at: Time.zone.now,
|
|
via_email: invite.via_email && send_email
|
|
)
|
|
else
|
|
create_args = {
|
|
invited_by: invited_by,
|
|
email: lower_email,
|
|
via_email: send_email
|
|
}
|
|
|
|
create_args[:moderator] = true if opts[:moderator]
|
|
create_args[:custom_message] = custom_message if custom_message
|
|
invite = Invite.create!(create_args)
|
|
end
|
|
|
|
if topic && !invite.topic_invites.pluck(:topic_id).include?(topic.id)
|
|
invite.topic_invites.create!(invite_id: invite.id, topic_id: topic.id)
|
|
# to correct association
|
|
topic.reload
|
|
end
|
|
|
|
if group_ids.present?
|
|
group_ids = group_ids - invite.invited_groups.pluck(:group_id)
|
|
|
|
group_ids.each do |group_id|
|
|
invite.invited_groups.create!(group_id: group_id)
|
|
end
|
|
end
|
|
|
|
Jobs.enqueue(:invite_email, invite_id: invite.id) if send_email
|
|
|
|
invite.reload
|
|
invite
|
|
end
|
|
|
|
def self.find_user_by_email(email)
|
|
User.with_email(email).where(staged: false).first
|
|
end
|
|
|
|
def self.get_group_ids(group_names)
|
|
group_ids = []
|
|
if group_names
|
|
group_names = group_names.split(',')
|
|
group_names.each { |group_name|
|
|
group_detail = Group.find_by_name(group_name)
|
|
group_ids.push(group_detail.id) if group_detail
|
|
}
|
|
end
|
|
group_ids
|
|
end
|
|
|
|
def self.find_all_invites_from(inviter, offset = 0, limit = SiteSetting.invites_per_page)
|
|
Invite.where(invited_by_id: inviter.id)
|
|
.where('invites.email IS NOT NULL')
|
|
.includes(user: :user_stat)
|
|
.order("CASE WHEN invites.user_id IS NOT NULL THEN 0 ELSE 1 END, user_stats.time_read DESC, invites.redeemed_at DESC")
|
|
.limit(limit)
|
|
.offset(offset)
|
|
.references('user_stats')
|
|
end
|
|
|
|
def self.find_pending_invites_from(inviter, offset = 0)
|
|
find_all_invites_from(inviter, offset).where('invites.user_id IS NULL').order('invites.created_at DESC')
|
|
end
|
|
|
|
def self.find_redeemed_invites_from(inviter, offset = 0)
|
|
find_all_invites_from(inviter, offset).where('invites.user_id IS NOT NULL').order('invites.redeemed_at DESC')
|
|
end
|
|
|
|
def self.find_pending_invites_count(inviter)
|
|
find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NULL').reorder(nil).count
|
|
end
|
|
|
|
def self.find_redeemed_invites_count(inviter)
|
|
find_all_invites_from(inviter, 0, nil).where('invites.user_id IS NOT NULL').reorder(nil).count
|
|
end
|
|
|
|
def self.filter_by(email_or_username)
|
|
if email_or_username
|
|
where(
|
|
'(LOWER(invites.email) LIKE :filter) or (LOWER(users.username) LIKE :filter)',
|
|
filter: "%#{email_or_username.downcase}%"
|
|
)
|
|
else
|
|
all
|
|
end
|
|
end
|
|
|
|
def self.invalidate_for_email(email)
|
|
i = Invite.find_by(email: Email.downcase(email))
|
|
if i
|
|
i.invalidated_at = Time.zone.now
|
|
i.save
|
|
end
|
|
i
|
|
end
|
|
|
|
def self.redeem_from_email(email)
|
|
invite = Invite.find_by(email: Email.downcase(email))
|
|
InviteRedeemer.new(invite).redeem if invite
|
|
invite
|
|
end
|
|
|
|
def resend_invite
|
|
self.update_columns(created_at: Time.zone.now, updated_at: Time.zone.now)
|
|
Jobs.enqueue(:invite_email, invite_id: self.id)
|
|
end
|
|
|
|
def self.resend_all_invites_from(user_id)
|
|
Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user_id).find_each do |invite|
|
|
invite.resend_invite
|
|
end
|
|
end
|
|
|
|
def self.rescind_all_expired_invites_from(user)
|
|
Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ? AND invites.created_at < ?',
|
|
user.id, SiteSetting.invite_expiry_days.days.ago).find_each do |invite|
|
|
invite.trash!(user)
|
|
end
|
|
end
|
|
|
|
def limit_invites_per_day
|
|
RateLimiter.new(invited_by, "invites-per-day", SiteSetting.max_invites_per_day, 1.day.to_i)
|
|
end
|
|
|
|
def self.base_directory
|
|
File.join(Rails.root, "public", "uploads", "csv", RailsMultisite::ConnectionManagement.current_db)
|
|
end
|
|
|
|
def self.create_csv(file, name)
|
|
extension = File.extname(file.original_filename)
|
|
path = "#{Invite.base_directory}/#{name}#{extension}"
|
|
FileUtils.mkdir_p(Pathname.new(path).dirname)
|
|
File.open(path, "wb") { |f| f << file.tempfile.read }
|
|
path
|
|
end
|
|
end
|
|
|
|
# == Schema Information
|
|
#
|
|
# Table name: invites
|
|
#
|
|
# id :integer not null, primary key
|
|
# invite_key :string(32) not null
|
|
# email :string
|
|
# invited_by_id :integer not null
|
|
# user_id :integer
|
|
# redeemed_at :datetime
|
|
# created_at :datetime not null
|
|
# updated_at :datetime not null
|
|
# deleted_at :datetime
|
|
# deleted_by_id :integer
|
|
# invalidated_at :datetime
|
|
# moderator :boolean default(FALSE), not null
|
|
# custom_message :text
|
|
# via_email :boolean default(FALSE), not null
|
|
#
|
|
# Indexes
|
|
#
|
|
# index_invites_on_email_and_invited_by_id (email,invited_by_id)
|
|
# index_invites_on_invite_key (invite_key) UNIQUE
|
|
#
|