diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 843eed21213..a9f8ac7d1f0 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -37,7 +37,10 @@ class InvitesController < ApplicationController render layout: "no_ember" end - def create + 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? @@ -59,37 +62,107 @@ class InvitesController < ApplicationController ) 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 emails.size > SiteSetting.max_api_invites + return( + render_json_error( + I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites), + 422, + ) ) - - 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) + + 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 diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 95088c3ede6..a6856ff2981 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -297,6 +297,7 @@ en: discourse_connect_enabled: "Invites are disabled because DiscourseConnect is enabled." invalid_access: "You are not permitted to view the requested resource." requires_groups: "Invite was not saved because the specified topic is inaccessible. Add one of the following groups: %{groups}." + max_invite_emails_limit_exceeded: "Request failed because number of emails exceeded the maximum (%{max})." domain_not_allowed: "Your email cannot be used to redeem this invite." max_redemptions_allowed_one: "for email invites should be 1." redemption_count_less_than_max: "should be less than %{max_redemptions_allowed}." diff --git a/config/routes.rb b/config/routes.rb index 9ac25194af4..6d3e09a50e0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1432,6 +1432,7 @@ Discourse::Application.routes.draw do resources :invites, only: %i[create update destroy] get "/invites/:id" => "invites#show", :constraints => { format: :html } + post "invites/create-multiple" => "invites#create_multiple", :constraints => { format: :json } post "invites/upload_csv" => "invites#upload_csv" post "invites/destroy-all-expired" => "invites#destroy_all_expired" diff --git a/config/site_settings.yml b/config/site_settings.yml index 8b7eeb03d22..675f99ade1e 100644 --- a/config/site_settings.yml +++ b/config/site_settings.yml @@ -2787,6 +2787,10 @@ uncategorized: default: 50000 hidden: true + max_api_invites: + default: 200 + hidden: true + overridden_robots_txt: default: "" hidden: true diff --git a/spec/requests/api/multiple_invites_spec.rb b/spec/requests/api/multiple_invites_spec.rb new file mode 100644 index 00000000000..ab36d4e8c3f --- /dev/null +++ b/spec/requests/api/multiple_invites_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true +require "swagger_helper" + +RSpec.describe "multiple invites" do + let(:"Api-Key") { Fabricate(:api_key).key } + let(:"Api-Username") { "system" } + + path "/invites/create-multiple.json" do + post "Create multiple invites" do + tags "Invites" + operationId "createMultipleInvites" + consumes "application/json" + parameter name: "Api-Key", in: :header, type: :string, required: true + parameter name: "Api-Username", in: :header, type: :string, required: true + + parameter name: :request_body, + in: :body, + schema: { + type: :object, + properties: { + email: { + type: :string, + example: %w[not-a-user-yet-1@example.com not-a-user-yet-2@example.com], + description: + "pass 1 email per invite to be generated. other properties will be shared by each invite.", + }, + skip_email: { + type: :boolean, + default: false, + }, + custom_message: { + type: :string, + description: "optional, for email invites", + }, + max_redemptions_allowed: { + type: :integer, + example: 5, + default: 1, + description: "optional, for link invites", + }, + topic_id: { + type: :integer, + }, + group_ids: { + type: :string, + description: + "Optional, either this or `group_names`. Comma separated list for multiple ids.", + example: "42,43", + }, + group_names: { + type: :string, + description: + "Optional, either this or `group_ids`. Comma separated list for multiple names.", + example: "foo,bar", + }, + expires_at: { + type: :string, + description: + "optional, if not supplied, the invite_expiry_days site setting is used", + }, + }, + } + + produces "application/json" + response "200", "success response" do + schema type: :object, + properties: { + num_successfully_created_invitations: { + type: :integer, + example: 42, + }, + num_failed_invitations: { + type: :integer, + example: 42, + }, + failed_invitations: { + type: :array, + items: { + }, + example: [], + }, + successful_invitations: { + type: :array, + example: [ + { + id: 42, + link: "http://example.com/invites/9045fd767efe201ca60c6658bcf14158", + email: "not-a-user-yet-1@example.com", + emailed: true, + custom_message: "Hello world!", + topics: [], + groups: [], + created_at: "2021-01-01T12:00:00.000Z", + updated_at: "2021-01-01T12:00:00.000Z", + expires_at: "2021-02-01T12:00:00.000Z", + expired: false, + }, + { + id: 42, + link: "http://example.com/invites/c6658bcf141589045fd767efe201ca60", + email: "not-a-user-yet-2@example.com", + emailed: true, + custom_message: "Hello world!", + topics: [], + groups: [], + created_at: "2021-01-01T12:00:00.000Z", + updated_at: "2021-01-01T12:00:00.000Z", + expires_at: "2021-02-01T12:00:00.000Z", + expired: false, + }, + ], + }, + } + + let(:request_body) do + { email: %w[not-a-user-yet-1@example.com not-a-user-yet-2@example.com] } + end + run_test! + end + end + end +end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 79f6f3d623c..c30b2ed7971 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -505,6 +505,152 @@ RSpec.describe InvitesController do end end + describe "#create-multiple" do + it "fails if you are not admin" do + sign_in(Fabricate(:user)) + post "/invites/create-multiple.json", + params: { + email: %w[test@example.com test1@example.com bademail], + } + expect(response.status).to eq(403) + end + + it "creates multiple invites for multiple emails" do + sign_in(admin) + post "/invites/create-multiple.json", + params: { + email: %w[test@example.com test1@example.com bademail], + } + expect(response.status).to eq(200) + json = JSON(response.body) + expect(json["failed_invitations"].length).to eq(1) + expect(json["successful_invitations"].length).to eq(2) + end + + it "creates many invite codes with one request" do #change to + sign_in(admin) + num_emails = 5 # increase manually for load testing + post "/invites/create-multiple.json", + params: { + email: 1.upto(num_emails).map { |i| "test#{i}@example.com" }, + #email: %w[test+1@example.com test1@example.com] + } + expect(response.status).to eq(200) + json = JSON(response.body) + expect(json["failed_invitations"].length).to eq(0) + expect(json["successful_invitations"].length).to eq(num_emails) + end + + context "with invite to topic" do + fab!(:topic) + + it "works" do + sign_in(admin) + + post "/invites/create-multiple.json", + params: { + email: ["test@example.com"], + topic_id: topic.id, + invite_to_topic: true, + } + expect(response.status).to eq(200) + expect(Jobs::InviteEmail.jobs.first["args"].first["invite_to_topic"]).to be_truthy + end + + it "fails when topic_id is invalid" do + sign_in(admin) + + post "/invites/create-multiple.json", + params: { + email: ["test@example.com"], + topic_id: -9999, + } + expect(response.status).to eq(400) + end + end + + context "with invite to group" do + fab!(:group) + + it "works for admins" do + sign_in(admin) + + post "/invites/create-multiple.json", + params: { + email: ["test@example.com"], + group_ids: [group.id], + } + expect(response.status).to eq(200) + expect(Invite.find_by(email: "test@example.com").invited_groups.count).to eq(1) + end + + it "works with multiple groups" do + sign_in(admin) + group2 = Fabricate(:group) + + post "/invites/create-multiple.json", + params: { + email: ["test@example.com"], + group_names: "#{group.name},#{group2.name}", + } + expect(response.status).to eq(200) + expect(Invite.find_by(email: "test@example.com").invited_groups.count).to eq(2) + end + end + + context "with email invite" do + subject(:create_multiple_invites) { post "/invites/create-multiple.json", params: params } + + let(:params) { { email: [email] } } + let(:email) { "test@example.com" } + + before { sign_in(admin) } + + context "when doing successive calls" do + let(:invite) { Invite.last } + + it "creates invite once and updates it after" do + create_multiple_invites + expect(response).to have_http_status :ok + expect(Jobs::InviteEmail.jobs.size).to eq(1) + + create_multiple_invites + expect(response).to have_http_status :ok + expect(response.parsed_body["successful_invitations"][0]["invite"]["id"]).to eq(invite.id) + end + end + + context 'when "skip_email" parameter is provided' do + before { params[:skip_email] = true } + + it "accepts the parameter" do + create_multiple_invites + expect(response).to have_http_status :ok + expect(Jobs::InviteEmail.jobs.size).to eq(0) + end + end + end + + it "fails if asked to generate too many invites at once" do + SiteSetting.max_api_invites = 3 + sign_in(admin) + post "/invites/create-multiple.json", + params: { + email: %w[ + mail1@mailinator.com + mail2@mailinator.com + mail3@mailinator.com + mail4@mailinator.com + ], + } + + expect(response.status).to eq(422) + expect(response.parsed_body["errors"][0]).to eq( + I18n.t("invite.max_invite_emails_limit_exceeded", max: SiteSetting.max_api_invites), + ) + end + end + describe "#retrieve" do it "requires to be logged in" do get "/invites/retrieve.json", params: { email: "test@example.com" }