discourse/app/controllers/invites_controller.rb
Gary Pendergast 672007549b
FIX: Allow existing users to accept invites that add them to a group. (#32762)
Check to see if the user needs to be added to any groups before redirecting. To avoid introducing a CSRF issue, force the user through the normal invite acceptance flow, ensuring there's user interaction before being added to any groups.

This change also tweaks the behaviour of 2e10fe9 to not follow redirects of expired invitations.

Co-authored-by: Krzysztof Kotlarek <kotlarek.krzysztof@gmail.com>
2025-05-16 16:42:02 +10:00

666 lines
20 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
skip_before_action :redirect_to_profile_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_irredeemable_invite(invite)
return
end
# automatically redirect to the topic if the user is logged in and can see it,
# but only if the invite wouldn't also add the user to a group
if current_user
new_group_ids = invite.groups.pluck(:id) - current_user.group_users.pluck(:group_id)
if new_group_ids.empty? && topic = invite.topics.first
return redirect_to(topic.url) if current_user.guardian.can_see?(topic)
end
end
show_invite(invite)
rescue RateLimiter::LimitExceeded => e
flash.now[:error] = e.description
render layout: "no_ember"
end
def create_multiple
guardian.ensure_can_bulk_invite_to_forum!(current_user)
emails = params[:email]
# validate that topics and groups can accept invites.
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
if emails.size > SiteSetting.max_api_invites
return(
render_json_error(
I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites),
422,
)
)
end
success = []
fail = []
emails.map do |email|
begin
invite =
Invite.generate(
current_user,
email: 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],
)
success.push({ email: email, invite: invite }) if invite
rescue Invite::UserExists => e
fail.push({ email: email, error: e.message })
rescue ActiveRecord::RecordInvalid => e
fail.push({ email: email, error: e.record.errors.full_messages.first })
end
end
render json: {
num_successfully_created_invitations: success.length,
num_failed_invitations: fail.length,
failed_invitations: fail,
successful_invitations:
success.map do |s| InviteSerializer.new(s[:invite], scope: guardian) end,
}
end
def create
begin
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
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: {
},
)
raise Discourse::NotFound if SiteSetting.enable_discourse_connect
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? || !SiteSetting.use_email_for_username_and_name_suggestions
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
secure_session["invite-key"] = invite.invite_key
respond_to do |format|
format.html { store_preloaded("invite_info", MultiJson.dump(info)) }
format.json { render_json_dump(info) }
end
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