discourse/app/models/invite.rb

393 lines
11 KiB
Ruby

# frozen_string_literal: true
class Invite < ActiveRecord::Base
class UserExists < StandardError
end
class RedemptionFailed < StandardError
end
class ValidationFailed < StandardError
end
include RateLimiter::OnCreateRecord
include Trashable
self.ignored_columns = %w[user_id redeemed_at] # TODO: Remove when 20240212034010_drop_deprecated_columns has been promoted to pre-deploy
BULK_INVITE_EMAIL_LIMIT = 200
DOMAIN_REGEX =
/\A(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])\z/
rate_limit :limit_invites_per_day
belongs_to :invited_by, class_name: "User"
has_many :invited_users
has_many :users, through: :invited_users
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, allow_blank: true, length: { maximum: 500 }
validates :custom_message, length: { maximum: 1000 }
validates :domain, length: { maximum: 500 }
validate :ensure_max_redemptions_allowed
validate :valid_redemption_count
validate :valid_domain, if: :will_save_change_to_domain?
validate :user_doesnt_already_exist, if: :will_save_change_to_email?
validate :email_xor_domain
before_create do
self.invite_key ||= SecureRandom.base58(10)
self.expires_at ||= SiteSetting.invite_expiry_days.days.from_now
end
before_save do
self.email_token = email.present? ? SecureRandom.hex : nil if will_save_change_to_email?
end
before_validation { self.email = Email.downcase(email) unless email.nil? }
attribute :email_already_exists
def self.emailed_status_types
@emailed_status_types ||=
Enum.new(not_required: 0, pending: 1, bulk_pending: 2, sending: 3, sent: 4)
end
def user_doesnt_already_exist
self.email_already_exists = false
return if email.blank?
user = Invite.find_user_by_email(email)
if user && user.id != self.invited_users&.first&.user_id
self.email_already_exists = true
errors.add(:base, user_exists_error_msg(email))
end
end
def email_xor_domain
errors.add(:base, I18n.t("invite.email_xor_domain")) if email.present? && domain.present?
end
# Even if a domain is specified on the invite, it still counts as
# an invite link.
def is_invite_link?
self.email.blank?
end
# Email invites have specific behaviour and it's easier to visually
# parse is_email_invite? than !is_invite_link?
def is_email_invite?
self.email.present?
end
def redeemable?
!redeemed? && !expired? && !deleted_at? && !destroyed? && link_valid?
end
def redeemed_by_user?(redeeming_user)
self.invited_users.exists?(user: redeeming_user)
end
def redeemed?
if is_invite_link?
redemption_count >= max_redemptions_allowed
else
self.invited_users.count > 0
end
end
def email_matches?(email)
email.downcase == self.email.downcase
end
def domain_matches?(email)
_, domain = email.split("@")
self.domain == domain
end
def can_be_redeemed_by?(user)
return false if !self.redeemable?
return false if redeemed_by_user?(user)
return true if self.domain.blank? && self.email.blank?
return true if self.email.present? && email_matches?(user.email)
self.domain.present? && domain_matches?(user.email)
end
def expired?
expires_at < Time.zone.now
end
def link(with_email_token: false)
if with_email_token
"#{Discourse.base_url}/invites/#{invite_key}?t=#{email_token}"
else
"#{Discourse.base_url}/invites/#{invite_key}"
end
end
def link_valid?
invalidated_at.nil?
end
def self.generate(invited_by, opts = nil)
opts ||= {}
time_zone = Time.find_zone(invited_by&.user_option&.timezone) || Time.zone
email = Email.downcase(opts[:email]) if opts[:email].present?
raise UserExists.new(new.user_exists_error_msg(email)) if find_user_by_email(email)
if email.present?
invite =
Invite
.with_deleted
.where(email: email, invited_by_id: invited_by.id)
.order("created_at DESC")
.first
if invite && (invite.expired? || invite.deleted_at)
invite.destroy
invite = nil
end
email_digest = Digest::SHA256.hexdigest(email)
RateLimiter.new(invited_by, "reinvites-per-day-#{email_digest}", 3, 1.day.to_i).performed!
end
emailed_status =
if opts[:skip_email] || invite&.emailed_status == emailed_status_types[:not_required]
emailed_status_types[:not_required]
elsif opts[:emailed_status].present?
opts[:emailed_status]
elsif email.present?
emailed_status_types[:pending]
else
emailed_status_types[:not_required]
end
if invite
invite.update_columns(
created_at: Time.zone.now,
updated_at: Time.zone.now,
expires_at: opts[:expires_at] || time_zone.now + SiteSetting.invite_expiry_days.days,
emailed_status: emailed_status,
)
else
create_args =
opts.slice(:email, :domain, :moderator, :custom_message, :max_redemptions_allowed)
create_args[:invited_by] = invited_by
create_args[:email] = email
create_args[:emailed_status] = emailed_status
create_args[:expires_at] = opts[:expires_at] ||
time_zone.now + SiteSetting.invite_expiry_days.days
invite = Invite.create!(create_args)
end
topic_id = opts[:topic]&.id || opts[:topic_id]
invite.topic_invites.find_or_create_by!(topic_id: topic_id) if topic_id.present?
group_ids = opts[:group_ids]
if group_ids.present?
group_ids.each { |group_id| invite.invited_groups.find_or_create_by!(group_id: group_id) }
end
if emailed_status == emailed_status_types[:pending]
invite.update_column(:emailed_status, emailed_status_types[:sending])
Jobs.enqueue(:invite_email, invite_id: invite.id, invite_to_topic: opts[:invite_to_topic])
end
invite.reload
end
def redeem(
email: nil,
username: nil,
name: nil,
password: nil,
user_custom_fields: nil,
ip_address: nil,
session: nil,
email_token: nil,
redeeming_user: nil
)
return if !redeemable?
InviteRedeemer.new(
invite: self,
email: email,
username: username,
name: name,
password: password,
user_custom_fields: user_custom_fields,
ip_address: ip_address,
session: session,
email_token: email_token,
redeeming_user: redeeming_user,
).redeem
end
def self.redeem_for_existing_user(user)
invite = Invite.find_by(email: Email.downcase(user.email))
if invite.present? && invite.redeemable?
InviteRedeemer.new(invite: invite, redeeming_user: user).redeem
end
invite
end
def self.find_user_by_email(email)
User.with_email(Email.downcase(email)).where(staged: false).first
end
def self.pending(inviter)
Invite
.distinct
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
.where(invited_by_id: inviter.id)
.where("redemption_count < max_redemptions_allowed")
.where("expires_at > ?", Time.zone.now)
.order("invites.updated_at DESC")
end
def self.expired(inviter)
Invite
.distinct
.joins("LEFT JOIN invited_users ON invites.id = invited_users.invite_id")
.joins("LEFT JOIN users ON invited_users.user_id = users.id")
.where(invited_by_id: inviter.id)
.where("redemption_count < max_redemptions_allowed")
.where("expires_at < ?", Time.zone.now)
.order("invites.expires_at ASC")
end
def self.redeemed_users(inviter)
InvitedUser
.joins("LEFT JOIN invites ON invites.id = invited_users.invite_id")
.includes(user: :user_stat)
.where("invited_users.user_id IS NOT NULL")
.where("invites.invited_by_id = ?", inviter.id)
.order("invited_users.redeemed_at DESC")
.references("invite")
.references("user")
.references("user_stat")
end
def self.invalidate_for_email(email)
Invite.find_by(email: Email.downcase(email))&.invalidate!
end
def invalidate!
update_attribute(:invalidated_at, Time.current)
self
end
def resend_invite
self.update_columns(
updated_at: Time.zone.now,
invalidated_at: nil,
expires_at: SiteSetting.invite_expiry_days.days.from_now,
)
Jobs.enqueue(:invite_email, invite_id: self.id)
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 ensure_max_redemptions_allowed
if self.max_redemptions_allowed.nil?
self.max_redemptions_allowed = 1
else
limit =
(
if invited_by&.staff?
SiteSetting.invite_link_max_redemptions_limit
else
SiteSetting.invite_link_max_redemptions_limit_users
end
)
if self.email.present? && self.max_redemptions_allowed != 1
errors.add(:max_redemptions_allowed, I18n.t("invite.max_redemptions_allowed_one"))
elsif !self.max_redemptions_allowed.between?(1, limit)
errors.add(
:max_redemptions_allowed,
I18n.t("invite_link.max_redemptions_limit", max_limit: limit),
)
end
end
end
def valid_redemption_count
if self.redemption_count > self.max_redemptions_allowed
errors.add(
:redemption_count,
I18n.t(
"invite.redemption_count_less_than_max",
max_redemptions_allowed: self.max_redemptions_allowed,
),
)
end
end
def valid_domain
return if self.domain.blank?
self.domain.downcase!
if self.domain !~ Invite::DOMAIN_REGEX
self.errors.add(:base, I18n.t("invite.domain_not_allowed_admin"))
end
end
def user_exists_error_msg(email)
error_key = SiteSetting.hide_email_address_taken? ? "generic_error_response" : "user_exists"
I18n.t("invite.#{error_key}", email: CGI.escapeHTML(email))
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
# 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
# emailed_status :integer
# max_redemptions_allowed :integer default(1), not null
# redemption_count :integer default(0), not null
# expires_at :datetime not null
# email_token :string
# domain :string
#
# Indexes
#
# index_invites_on_email_and_invited_by_id (email,invited_by_id)
# index_invites_on_emailed_status (emailed_status)
# index_invites_on_invite_key (invite_key) UNIQUE
# index_invites_on_invited_by_id (invited_by_id)
#