diff --git a/app/assets/javascripts/discourse/app/controllers/create-invite.js b/app/assets/javascripts/discourse/app/controllers/create-invite.js index 2a4cb7e8412..8494aad52f0 100644 --- a/app/assets/javascripts/discourse/app/controllers/create-invite.js +++ b/app/assets/javascripts/discourse/app/controllers/create-invite.js @@ -1,9 +1,10 @@ import Controller from "@ember/controller"; import { action } from "@ember/object"; -import { empty, notEmpty } from "@ember/object/computed"; +import { not } from "@ember/object/computed"; import discourseComputed from "discourse-common/utils/decorators"; import { extractError } from "discourse/lib/ajax-error"; import { getNativeContact } from "discourse/lib/pwa-utils"; +import { emailValid, hostnameValid } from "discourse/lib/utilities"; import { bufferedProperty } from "discourse/mixins/buffered-content"; import ModalFunctionality from "discourse/mixins/modal-functionality"; import Group from "discourse/models/group"; @@ -28,8 +29,17 @@ export default Controller.extend( inviteToTopic: false, limitToEmail: false, - isLink: empty("buffered.email"), - isEmail: notEmpty("buffered.email"), + @discourseComputed("buffered.emailOrDomain") + isEmail(emailOrDomain) { + return emailValid(emailOrDomain); + }, + + @discourseComputed("buffered.emailOrDomain") + isDomain(emailOrDomain) { + return hostnameValid(emailOrDomain); + }, + + isLink: not("isEmail"), onShow() { Group.findAll().then((groups) => { @@ -67,6 +77,15 @@ export default Controller.extend( save(opts) { const data = { ...this.buffered.buffer }; + if (data.emailOrDomain) { + if (emailValid(data.emailOrDomain)) { + data.email = data.emailOrDomain; + } else if (hostnameValid(data.emailOrDomain)) { + data.domain = data.emailOrDomain; + } + delete data.emailOrDomain; + } + if (data.groupIds !== undefined) { data.group_ids = data.groupIds.length > 0 ? data.groupIds : ""; delete data.groupIds; diff --git a/app/assets/javascripts/discourse/app/lib/utilities.js b/app/assets/javascripts/discourse/app/lib/utilities.js index c6a5ca1446d..341512e4eba 100644 --- a/app/assets/javascripts/discourse/app/lib/utilities.js +++ b/app/assets/javascripts/discourse/app/lib/utilities.js @@ -142,6 +142,12 @@ export function emailValid(email) { return re.test(email); } +export function hostnameValid(hostname) { + // see: https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address + const re = /^(([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])$/; + return hostname && re.test(hostname); +} + export function extractDomainFromUrl(url) { if (url.indexOf("://") > -1) { url = url.split("/")[2]; diff --git a/app/assets/javascripts/discourse/app/models/invite.js b/app/assets/javascripts/discourse/app/models/invite.js index 7ad702f223c..122208bc1b2 100644 --- a/app/assets/javascripts/discourse/app/models/invite.js +++ b/app/assets/javascripts/discourse/app/models/invite.js @@ -49,6 +49,11 @@ const Invite = EmberObject.extend({ return topicData ? Topic.create(topicData) : null; }, + @discourseComputed("email", "domain") + emailOrDomain(email, domain) { + return email || domain; + }, + topicId: alias("topics.firstObject.id"), topicTitle: alias("topics.firstObject.title"), }); diff --git a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs index d31844c4846..0fce75b6681 100644 --- a/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs +++ b/app/assets/javascripts/discourse/app/templates/modal/create-invite.hbs @@ -37,12 +37,21 @@ {{/if}}
- +
{{input id="invite-email" - value=buffered.email - placeholderKey="topic.invite_reply.email_placeholder" + value=buffered.emailOrDomain + placeholderKey="user.invited.invite.email_or_domain_placeholder" }} {{#if capabilities.hasContactPicker}} {{d-button diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 1e1fd7c4311..408b4004d26 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -134,6 +134,7 @@ class InvitesController < ApplicationController begin 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], @@ -210,6 +211,17 @@ class InvitesController < ApplicationController 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] diff --git a/app/models/invite.rb b/app/models/invite.rb index a5c08d38347..77cc9fc79c2 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -15,6 +15,7 @@ class Invite < ActiveRecord::Base } BULK_INVITE_EMAIL_LIMIT = 200 + HOSTNAME_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 @@ -30,6 +31,7 @@ class Invite < ActiveRecord::Base validates_presence_of :invited_by_id validates :email, email: true, allow_blank: true validate :ensure_max_redemptions_allowed + validate :valid_domain, if: :will_save_change_to_domain? validate :user_doesnt_already_exist before_create do @@ -143,7 +145,7 @@ class Invite < ActiveRecord::Base emailed_status: emailed_status ) else - create_args = opts.slice(:email, :moderator, :custom_message, :max_redemptions_allowed) + 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 @@ -236,12 +238,10 @@ class Invite < ActiveRecord::Base end def self.invalidate_for_email(email) - i = Invite.find_by(email: Email.downcase(email)) - if i - i.invalidated_at = Time.zone.now - i.save - end - i + invite = Invite.find_by(email: Email.downcase(email)) + invite.update!(invalidated_at: Time.zone.now) if invite + + invite end def resend_invite @@ -286,6 +286,16 @@ class Invite < ActiveRecord::Base end end end + + def valid_domain + return if self.domain.blank? + + self.domain.downcase! + + if self.domain !~ Invite::HOSTNAME_REGEX + self.errors.add(:base, I18n.t('invite.domain_not_allowed', domain: self.domain)) + end + end end # == Schema Information @@ -308,6 +318,7 @@ end # redemption_count :integer default(0), not null # expires_at :datetime not null # email_token :string +# domain :string # # Indexes # diff --git a/app/models/invite_redeemer.rb b/app/models/invite_redeemer.rb index 2ba2a6771b0..e8eec563b7c 100644 --- a/app/models/invite_redeemer.rb +++ b/app/models/invite_redeemer.rb @@ -19,6 +19,13 @@ InviteRedeemer = Struct.new(:invite, :email, :username, :name, :password, :user_ available_username = UserNameSuggester.suggest(email) end + if email.present? && invite.domain.present? + username, domain = email.split('@') + if domain.present? && invite.domain != domain + raise ActiveRecord::RecordNotSaved.new(I18n.t('invite.domain_not_allowed')) + end + end + user = User.where(staged: true).with_email(email.strip.downcase).first user.unstage! if user user ||= User.new diff --git a/app/serializers/invite_serializer.rb b/app/serializers/invite_serializer.rb index 51aa36939e3..f9f61aa70ea 100644 --- a/app/serializers/invite_serializer.rb +++ b/app/serializers/invite_serializer.rb @@ -5,6 +5,7 @@ class InviteSerializer < ApplicationSerializer :invite_key, :link, :email, + :domain, :emailed, :max_redemptions_allowed, :redemption_count, diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 02761797b7f..4c5c10671e8 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1621,7 +1621,10 @@ en: show_advanced: "Show Advanced Options" hide_advanced: "Hide Advanced Options" + restrict_email_or_domain: "Restrict to email or domain" + email_or_domain_placeholder: "name@example.com or example.com" restrict_email: "Restrict to email" + restrict_domain: "Restrict to domain" max_redemptions_allowed: "Max uses" diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 1baf352b266..7aeb6d57e16 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -259,6 +259,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 saved. To give access to the specified topic, add one of the following groups: %{groups}." + domain_not_allowed: "Your email cannot be used to redeem this invite." bulk_invite: file_should_be_csv: "The uploaded file should be of csv format." diff --git a/db/migrate/20211207130646_add_domain_to_invites.rb b/db/migrate/20211207130646_add_domain_to_invites.rb new file mode 100644 index 00000000000..1782efdf22a --- /dev/null +++ b/db/migrate/20211207130646_add_domain_to_invites.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDomainToInvites < ActiveRecord::Migration[6.1] + def change + add_column :invites, :domain, :string + end +end diff --git a/spec/requests/invites_controller_spec.rb b/spec/requests/invites_controller_spec.rb index 4727df2dd13..8bd06700c4f 100644 --- a/spec/requests/invites_controller_spec.rb +++ b/spec/requests/invites_controller_spec.rb @@ -677,6 +677,31 @@ describe InvitesController do end end + context 'with a domain invite' do + fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required], domain: 'example.com') } + + it 'creates an user if email matches domain' do + expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example.com', password: 'verystrongpassword' } } + .to change { User.count } + + expect(response.status).to eq(200) + expect(response.parsed_body['message']).to eq(I18n.t('invite.confirm_email')) + expect(invite.reload.redemption_count).to eq(1) + + invited_user = User.find_by_email('test@example.com') + expect(invited_user).to be_present + end + + it 'does not create an user if email does not match domain' do + expect { put "/invites/show/#{invite.invite_key}.json", params: { email: 'test@example2.com', password: 'verystrongpassword' } } + .not_to change { User.count } + + expect(response.status).to eq(412) + expect(response.parsed_body['message']).to eq(I18n.t('invite.domain_not_allowed')) + expect(invite.reload.redemption_count).to eq(0) + end + end + context 'with an invite link' do fab!(:invite) { Fabricate(:invite, email: nil, emailed_status: Invite.emailed_status_types[:not_required]) }