mirror of
https://gh.wpcy.net/https://github.com/discourse/discourse.git
synced 2026-05-10 21:14:41 +08:00
Admins can view the list of invites created by other users and they can see the delete button for invites in the list, but it currently doesn't actually delete anything due to a bug in the `invites#destroy` controller action where it looks up the invite record by the given id and expects it to be created by the current user, but when an invite is being deleted by an admin, this logic fails because the invite isn't created by the admin. This commit fixes the issue by removing this check for current user and adding a proper guardian check that validates the action is performed by either the user who created the invite or an admin. Internal topic: t/158288.
684 lines
20 KiB
Ruby
684 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!
|
|
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,
|
|
description: params[:description],
|
|
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],
|
|
description: params[:description],
|
|
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,
|
|
:description,
|
|
: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(id: params[:id])
|
|
raise Discourse::InvalidParameters.new(:id) if invite.blank?
|
|
|
|
guardian.ensure_can_destroy_invite!(invite)
|
|
|
|
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!
|
|
user = fetch_user_from_params
|
|
|
|
Invite
|
|
.where(invited_by: 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!
|
|
|
|
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!
|
|
|
|
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?
|
|
custom_error =
|
|
DiscoursePluginRegistry.apply_modifier(:invite_bulk_csv_custom_error, nil, invites)
|
|
|
|
if custom_error.present?
|
|
return render json: failed_json.merge(errors: [custom_error]), status: 422
|
|
end
|
|
|
|
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
|