mirror of
https://github.com/discourse/discourse.git
synced 2024-12-01 07:13:38 +08:00
62a609ea2d
Raise an error on concurrent invite accept attempts.
580 lines
17 KiB
Ruby
580 lines
17 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
|
|
class InvitesController < ApplicationController
|
|
requires_login only: %i[
|
|
create
|
|
retrieve
|
|
destroy
|
|
destroy_all_expired
|
|
resend_invite
|
|
resend_all_invites
|
|
upload_csv
|
|
]
|
|
|
|
skip_before_action :check_xhr, except: [:perform_accept_invitation]
|
|
skip_before_action :preload_json, except: [:show]
|
|
skip_before_action :redirect_to_login_if_required
|
|
|
|
before_action :ensure_invites_allowed, only: %i[show perform_accept_invitation]
|
|
before_action :ensure_new_registrations_allowed, only: %i[show perform_accept_invitation]
|
|
|
|
def show
|
|
expires_now
|
|
|
|
RateLimiter.new(nil, "invites-show-#{request.remote_ip}", 100, 1.minute).performed!
|
|
|
|
invite = Invite.find_by(invite_key: params[:id])
|
|
|
|
if invite.present? && invite.redeemable?
|
|
show_invite(invite)
|
|
else
|
|
show_irredeemable_invite(invite)
|
|
end
|
|
rescue RateLimiter::LimitExceeded => e
|
|
flash.now[:error] = e.description
|
|
render layout: "no_ember"
|
|
end
|
|
|
|
def create
|
|
if params[:topic_id].present?
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
|
|
guardian.ensure_can_invite_to!(topic)
|
|
end
|
|
|
|
if params[:group_ids].present? || params[:group_names].present?
|
|
groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names])
|
|
end
|
|
|
|
guardian.ensure_can_invite_to_forum!(groups)
|
|
|
|
if !groups_can_see_topic?(groups, topic)
|
|
editable_topic_groups = topic.category.groups.filter { |g| guardian.can_edit_group?(g) }
|
|
return(
|
|
render_json_error(
|
|
I18n.t("invite.requires_groups", groups: editable_topic_groups.pluck(:name).join(", ")),
|
|
)
|
|
)
|
|
end
|
|
|
|
invite =
|
|
Invite.generate(
|
|
current_user,
|
|
email: params[:email],
|
|
domain: params[:domain],
|
|
skip_email: params[:skip_email],
|
|
invited_by: current_user,
|
|
custom_message: params[:custom_message],
|
|
max_redemptions_allowed: params[:max_redemptions_allowed],
|
|
topic_id: topic&.id,
|
|
group_ids: groups&.map(&:id),
|
|
expires_at: params[:expires_at],
|
|
invite_to_topic: params[:invite_to_topic],
|
|
)
|
|
|
|
if invite.present?
|
|
render_serialized(
|
|
invite,
|
|
InviteSerializer,
|
|
scope: guardian,
|
|
root: nil,
|
|
show_emails: params.has_key?(:email),
|
|
show_warnings: true,
|
|
)
|
|
else
|
|
render json: failed_json, status: 422
|
|
end
|
|
rescue Invite::UserExists => e
|
|
render_json_error(e.message)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
render_json_error(e.record.errors.full_messages.first)
|
|
end
|
|
|
|
def retrieve
|
|
params.require(:email)
|
|
|
|
invite = Invite.find_by(invited_by: current_user, email: params[:email])
|
|
raise Discourse::InvalidParameters.new(:email) if invite.blank?
|
|
|
|
guardian.ensure_can_invite_to_forum!(nil)
|
|
|
|
render_serialized(
|
|
invite,
|
|
InviteSerializer,
|
|
scope: guardian,
|
|
root: nil,
|
|
show_emails: params.has_key?(:email),
|
|
show_warnings: true,
|
|
)
|
|
end
|
|
|
|
def update
|
|
invite = Invite.find_by(invited_by: current_user, id: params[:id])
|
|
raise Discourse::InvalidParameters.new(:id) if invite.blank?
|
|
|
|
if params[:topic_id].present?
|
|
topic = Topic.find_by(id: params[:topic_id])
|
|
raise Discourse::InvalidParameters.new(:topic_id) if topic.blank?
|
|
guardian.ensure_can_invite_to!(topic)
|
|
end
|
|
|
|
if params[:group_ids].present? || params[:group_names].present?
|
|
groups = Group.lookup_groups(group_ids: params[:group_ids], group_names: params[:group_names])
|
|
end
|
|
|
|
guardian.ensure_can_invite_to_forum!(groups)
|
|
|
|
Invite.transaction do
|
|
if params.has_key?(:topic_id)
|
|
invite.topic_invites.destroy_all
|
|
invite.topic_invites.create!(topic_id: topic.id) if topic.present?
|
|
end
|
|
|
|
if params.has_key?(:group_ids) || params.has_key?(:group_names)
|
|
invite.invited_groups.destroy_all
|
|
if groups.present?
|
|
groups.each { |group| invite.invited_groups.find_or_create_by!(group_id: group.id) }
|
|
end
|
|
end
|
|
|
|
if !groups_can_see_topic?(invite.groups, invite.topics.first)
|
|
editable_topic_groups =
|
|
invite.topics.first.category.groups.filter { |g| guardian.can_edit_group?(g) }
|
|
return(
|
|
render_json_error(
|
|
I18n.t("invite.requires_groups", groups: editable_topic_groups.pluck(:name).join(", ")),
|
|
)
|
|
)
|
|
end
|
|
|
|
if params.has_key?(:email)
|
|
old_email = invite.email.presence
|
|
new_email = params[:email].presence
|
|
|
|
if new_email
|
|
if Invite
|
|
.where.not(id: invite.id)
|
|
.find_by(email: new_email.downcase, invited_by_id: current_user.id)
|
|
&.redeemable?
|
|
return(
|
|
render_json_error(
|
|
I18n.t("invite.invite_exists", email: CGI.escapeHTML(new_email)),
|
|
status: 409,
|
|
)
|
|
)
|
|
end
|
|
end
|
|
|
|
if old_email != new_email
|
|
invite.emailed_status =
|
|
if new_email && !params[:skip_email]
|
|
Invite.emailed_status_types[:pending]
|
|
else
|
|
Invite.emailed_status_types[:not_required]
|
|
end
|
|
end
|
|
|
|
invite.domain = nil if invite.email.present?
|
|
end
|
|
|
|
if params.has_key?(:domain)
|
|
invite.domain = params[:domain]
|
|
|
|
if invite.domain.present?
|
|
invite.email = nil
|
|
invite.emailed_status = Invite.emailed_status_types[:not_required]
|
|
end
|
|
end
|
|
|
|
if params[:send_email]
|
|
if invite.emailed_status != Invite.emailed_status_types[:pending]
|
|
begin
|
|
RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed!
|
|
rescue RateLimiter::LimitExceeded
|
|
return render_json_error(I18n.t("rate_limiter.slow_down"))
|
|
end
|
|
end
|
|
|
|
invite.emailed_status = Invite.emailed_status_types[:pending]
|
|
end
|
|
|
|
begin
|
|
invite.update!(
|
|
params.permit(:email, :custom_message, :max_redemptions_allowed, :expires_at),
|
|
)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
return render_json_error(e.record.errors.full_messages.first)
|
|
end
|
|
end
|
|
|
|
if invite.emailed_status == Invite.emailed_status_types[:pending]
|
|
invite.update_column(:emailed_status, Invite.emailed_status_types[:sending])
|
|
Jobs.enqueue(:invite_email, invite_id: invite.id, invite_to_topic: params[:invite_to_topic])
|
|
end
|
|
|
|
render_serialized(
|
|
invite,
|
|
InviteSerializer,
|
|
scope: guardian,
|
|
root: nil,
|
|
show_emails: params.has_key?(:email),
|
|
show_warnings: true,
|
|
)
|
|
end
|
|
|
|
def destroy
|
|
params.require(:id)
|
|
|
|
invite = Invite.find_by(invited_by_id: current_user.id, id: params[:id])
|
|
raise Discourse::InvalidParameters.new(:id) if invite.blank?
|
|
|
|
invite.trash!(current_user)
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
# For DiscourseConnect SSO, all invite acceptance is done
|
|
# via the SessionController#sso_login route
|
|
def perform_accept_invitation
|
|
params.require(:id)
|
|
params.permit(
|
|
:email,
|
|
:username,
|
|
:name,
|
|
:password,
|
|
:timezone,
|
|
:email_token,
|
|
user_custom_fields: {
|
|
},
|
|
)
|
|
|
|
invite = Invite.find_by(invite_key: params[:id])
|
|
redeeming_user = current_user
|
|
|
|
if invite.present?
|
|
begin
|
|
attrs = { ip_address: request.remote_ip, session: session }
|
|
|
|
if redeeming_user
|
|
attrs[:redeeming_user] = redeeming_user
|
|
else
|
|
attrs[:username] = params[:username]
|
|
attrs[:name] = params[:name]
|
|
attrs[:password] = params[:password]
|
|
attrs[:user_custom_fields] = params[:user_custom_fields]
|
|
|
|
# If the invite is not scoped to an email then we allow the
|
|
# user to provide it themselves
|
|
if invite.is_invite_link?
|
|
params.require(:email)
|
|
attrs[:email] = params[:email]
|
|
else
|
|
# Otherwise we always use the email from the invitation.
|
|
attrs[:email] = invite.email
|
|
attrs[:email_token] = params[:email_token] if params[:email_token].present?
|
|
end
|
|
end
|
|
|
|
user = invite.redeem(**attrs)
|
|
rescue ActiveRecord::RecordInvalid,
|
|
ActiveRecord::RecordNotSaved,
|
|
ActiveRecord::LockWaitTimeout,
|
|
Invite::UserExists => e
|
|
return render json: failed_json.merge(message: e.message), status: 412
|
|
end
|
|
|
|
if user.blank?
|
|
return render json: failed_json.merge(message: I18n.t("invite.not_found_json")), status: 404
|
|
end
|
|
|
|
log_on_user(user) if !redeeming_user && user.active? && user.guardian.can_access_forum?
|
|
|
|
user.update_timezone_if_missing(params[:timezone])
|
|
post_process_invite(user)
|
|
create_topic_invite_notifications(invite, user)
|
|
|
|
topic = invite.topics.first
|
|
response = {}
|
|
|
|
if user.present?
|
|
if user.active? && user.guardian.can_access_forum?
|
|
response[:message] = I18n.t("invite.existing_user_success") if redeeming_user
|
|
|
|
if user.guardian.can_see?(topic)
|
|
response[:redirect_to] = path(topic.relative_url)
|
|
else
|
|
response[:redirect_to] = path("/")
|
|
end
|
|
else
|
|
response[:message] = if user.active?
|
|
I18n.t("activation.approval_required")
|
|
else
|
|
I18n.t("invite.confirm_email")
|
|
end
|
|
|
|
cookies[:destination_url] = path(topic.relative_url) if user.guardian.can_see?(topic)
|
|
end
|
|
end
|
|
|
|
render json: success_json.merge(response)
|
|
else
|
|
render json: failed_json.merge(message: I18n.t("invite.not_found_json")), status: 404
|
|
end
|
|
end
|
|
|
|
def destroy_all_expired
|
|
guardian.ensure_can_destroy_all_invites!(current_user)
|
|
|
|
Invite
|
|
.where(invited_by: current_user)
|
|
.where("expires_at < ?", Time.zone.now)
|
|
.find_each { |invite| invite.trash!(current_user) }
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def resend_invite
|
|
params.require(:email)
|
|
RateLimiter.new(current_user, "resend-invite-per-hour", 10, 1.hour).performed!
|
|
|
|
invite = Invite.find_by(invited_by_id: current_user.id, email: params[:email])
|
|
raise Discourse::InvalidParameters.new(:email) if invite.blank?
|
|
invite.resend_invite
|
|
render json: success_json
|
|
rescue RateLimiter::LimitExceeded
|
|
render_json_error(I18n.t("rate_limiter.slow_down"))
|
|
end
|
|
|
|
def resend_all_invites
|
|
guardian.ensure_can_resend_all_invites!(current_user)
|
|
|
|
begin
|
|
RateLimiter.new(
|
|
current_user,
|
|
"bulk-reinvite-per-day",
|
|
1,
|
|
1.day,
|
|
apply_limit_to_staff: true,
|
|
).performed!
|
|
rescue RateLimiter::LimitExceeded
|
|
return render_json_error(I18n.t("rate_limiter.slow_down"))
|
|
end
|
|
|
|
Invite
|
|
.pending(current_user)
|
|
.where("invites.email IS NOT NULL")
|
|
.find_each { |invite| invite.resend_invite }
|
|
|
|
render json: success_json
|
|
end
|
|
|
|
def upload_csv
|
|
guardian.ensure_can_bulk_invite_to_forum!(current_user)
|
|
|
|
hijack do
|
|
begin
|
|
file = params[:file] || params[:files].first
|
|
|
|
csv_header = nil
|
|
invites = []
|
|
|
|
CSV.foreach(file.tempfile, encoding: "bom|utf-8") do |row|
|
|
# Try to extract a CSV header, if it exists
|
|
if csv_header.nil?
|
|
if row[0] == "email"
|
|
csv_header = row
|
|
next
|
|
else
|
|
csv_header = %w[email groups topic_id]
|
|
end
|
|
end
|
|
|
|
invites.push(csv_header.zip(row).map.to_h.filter { |k, v| v.present? }) if row[0].present?
|
|
|
|
break if invites.count >= SiteSetting.max_bulk_invites
|
|
end
|
|
|
|
if invites.present?
|
|
Jobs.enqueue(:bulk_invite, invites: invites, current_user_id: current_user.id)
|
|
|
|
if invites.count >= SiteSetting.max_bulk_invites
|
|
render json:
|
|
failed_json.merge(
|
|
errors: [
|
|
I18n.t(
|
|
"bulk_invite.max_rows",
|
|
max_bulk_invites: SiteSetting.max_bulk_invites,
|
|
),
|
|
],
|
|
),
|
|
status: 422
|
|
else
|
|
render json: success_json
|
|
end
|
|
else
|
|
render json: failed_json.merge(errors: [I18n.t("bulk_invite.error")]), status: 422
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def show_invite(invite)
|
|
email = Email.obfuscate(invite.email)
|
|
|
|
# Show email if the user already authenticated their email
|
|
different_external_email = false
|
|
|
|
if session[:authentication]
|
|
auth_result = Auth::Result.from_session_data(session[:authentication], user: nil)
|
|
if invite.email == auth_result.email
|
|
email = invite.email
|
|
else
|
|
different_external_email = true
|
|
end
|
|
end
|
|
|
|
email_verified_by_link = invite.email_token.present? && params[:t] == invite.email_token
|
|
|
|
email = invite.email if email_verified_by_link
|
|
|
|
hidden_email = email != invite.email
|
|
|
|
if hidden_email || invite.email.nil?
|
|
username = ""
|
|
else
|
|
username = UserNameSuggester.suggest(invite.email)
|
|
end
|
|
|
|
info = {
|
|
invited_by: UserNameSerializer.new(invite.invited_by, scope: guardian, root: false),
|
|
email: email,
|
|
hidden_email: hidden_email,
|
|
username: username,
|
|
is_invite_link: invite.is_invite_link?,
|
|
email_verified_by_link: email_verified_by_link,
|
|
}
|
|
|
|
info[:different_external_email] = true if different_external_email
|
|
|
|
if staged_user = User.where(staged: true).with_email(invite.email).first
|
|
info[:username] = staged_user.username
|
|
info[:user_fields] = staged_user.user_fields
|
|
end
|
|
|
|
if current_user
|
|
info[:existing_user_id] = current_user.id
|
|
info[:existing_user_can_redeem] = invite.can_be_redeemed_by?(current_user)
|
|
info[:existing_user_can_redeem_error] = existing_user_can_redeem_error(invite)
|
|
info[:email] = current_user.email
|
|
info[:username] = current_user.username
|
|
end
|
|
|
|
store_preloaded("invite_info", MultiJson.dump(info))
|
|
|
|
secure_session["invite-key"] = invite.invite_key
|
|
|
|
render layout: "application"
|
|
end
|
|
|
|
def show_irredeemable_invite(invite)
|
|
flash.now[:error] = if invite.blank?
|
|
I18n.t("invite.not_found", base_url: Discourse.base_url)
|
|
elsif invite.redeemed?
|
|
if invite.is_invite_link?
|
|
I18n.t(
|
|
"invite.not_found_template_link",
|
|
site_name: SiteSetting.title,
|
|
base_url: Discourse.base_url,
|
|
)
|
|
else
|
|
I18n.t(
|
|
"invite.not_found_template",
|
|
site_name: SiteSetting.title,
|
|
base_url: Discourse.base_url,
|
|
)
|
|
end
|
|
elsif invite.expired?
|
|
I18n.t("invite.expired", base_url: Discourse.base_url)
|
|
end
|
|
|
|
render layout: "no_ember"
|
|
end
|
|
|
|
def ensure_invites_allowed
|
|
if (
|
|
!SiteSetting.enable_local_logins && Discourse.enabled_auth_providers.count == 0 &&
|
|
!SiteSetting.enable_discourse_connect
|
|
)
|
|
raise Discourse::NotFound
|
|
end
|
|
end
|
|
|
|
def ensure_new_registrations_allowed
|
|
unless SiteSetting.allow_new_registrations
|
|
flash[:error] = I18n.t("login.new_registrations_disabled")
|
|
render layout: "no_ember"
|
|
false
|
|
end
|
|
end
|
|
|
|
def groups_can_see_topic?(groups, topic)
|
|
if topic&.read_restricted_category?
|
|
topic_groups = topic.category.groups
|
|
return false if (groups & topic_groups).blank?
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
def post_process_invite(user)
|
|
user.enqueue_welcome_message("welcome_invite") if user.send_welcome_message
|
|
|
|
Group.refresh_automatic_groups!(:admins, :moderators, :staff) if user.staff?
|
|
|
|
if user.has_password?
|
|
if !user.active
|
|
email_token =
|
|
user.email_tokens.create!(email: user.email, scope: EmailToken.scopes[:signup])
|
|
EmailToken.enqueue_signup_email(email_token)
|
|
end
|
|
elsif !SiteSetting.enable_discourse_connect && SiteSetting.enable_local_logins
|
|
Jobs.enqueue(:invite_password_instructions_email, username: user.username)
|
|
end
|
|
end
|
|
|
|
def create_topic_invite_notifications(invite, user)
|
|
invite.topics.each do |topic|
|
|
if user.guardian.can_see?(topic)
|
|
last_notification =
|
|
user
|
|
.notifications
|
|
.where(notification_type: Notification.types[:invited_to_topic])
|
|
.where(topic_id: topic.id)
|
|
.where(post_number: 1)
|
|
.where("created_at > ?", 1.hour.ago)
|
|
|
|
if !last_notification.exists?
|
|
topic.create_invite_notification!(
|
|
user,
|
|
Notification.types[:invited_to_topic],
|
|
invite.invited_by,
|
|
)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def existing_user_can_redeem_error(invite)
|
|
return if invite.can_be_redeemed_by?(current_user)
|
|
if invite.invited_users.exists?(user: current_user)
|
|
I18n.t("invite.existing_user_already_redemeed")
|
|
else
|
|
I18n.t("invite.existing_user_cannot_redeem")
|
|
end
|
|
end
|
|
end
|