discourse/app/models/invite.rb
Martin Brennan 40e8912395
FIX: Invite redemption error if user had already redeemed (#19070)
When opening the invite acceptance page when the user
was already logged in, we were still showing the Accept
Invitation prompt even if the user had already redeemed
the invitation and was present in the `InvitedUser` table.

This would lead to errors when the user clicked on the button.

This commit fixes the issue by hiding the Accept Invitation
button and showing an error message instead indicating that
the user had already redeemed the invitation. This only applies
to multi-use invite links.
2022-11-17 15:51:58 +10:00

359 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
# TODO(2021-05-22): remove
self.ignored_columns = %w{
user_id
redeemed_at
}
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
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
if will_save_change_to_email?
self.email_token = email.present? ? SecureRandom.hex : nil
end
end
before_validation do
self.email = Email.downcase(email) unless email.nil?
end
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
if email.present? && domain.present?
errors.add(:base, I18n.t('invite.email_xor_domain'))
end
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.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)
with_email_token ? "#{Discourse.base_url}/invites/#{invite_key}?t=#{email_token}"
: "#{Discourse.base_url}/invites/#{invite_key}"
end
def link_valid?
invalidated_at.nil?
end
def self.generate(invited_by, opts = nil)
opts ||= {}
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] || SiteSetting.invite_expiry_days.days.from_now,
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] || SiteSetting.invite_expiry_days.days.from_now
invite = Invite.create!(create_args)
end
topic_id = opts[:topic]&.id || opts[:topic_id]
if topic_id.present?
invite.topic_invites.find_or_create_by!(topic_id: topic_id)
end
group_ids = opts[:group_ids]
if group_ids.present?
group_ids.each do |group_id|
invite.invited_groups.find_or_create_by!(group_id: group_id)
end
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 = Invite.find_by(email: Email.downcase(email))
invite.update!(invalidated_at: Time.zone.now) if invite
invite
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 = invited_by&.staff? ? SiteSetting.invite_link_max_redemptions_limit
: SiteSetting.invite_link_max_redemptions_limit_users
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'))
end
end
def user_exists_error_msg(email)
I18n.t("invite.user_exists", 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)
#