mirror of
https://github.com/discourse/discourse.git
synced 2024-11-30 08:13:43 +08:00
0dd1ee2e09
This is a bug that happens only when the current date is less than 90 days from a date on which the time zone transitions into or out of Daylight Savings Time. In these conditions, bulk invites show the time of day of their expiration as being 1 hour later than the current time. Whereas it should match the time of day the invite was generated. This is because the server has not been using the user's timezone in calculating the expiration time of day. This PR fixes issue by considering the user's timezone when doing the date math. https://meta.discourse.org/t/bulk-invite-logic-to-generate-expire-date-bug/274689
391 lines
11 KiB
Ruby
391 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
|
|
validates :custom_message, length: { maximum: 1000 }
|
|
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"))
|
|
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)
|
|
#
|