From 07a4059901e8723cd3d986a0aefb216faa42b5fe Mon Sep 17 00:00:00 2001 From: Claire Date: Mon, 13 Nov 2023 14:27:00 +0100 Subject: [PATCH] Add support for invite codes in the registration API (#27805) --- app/controllers/api/v1/accounts_controller.rb | 18 ++-- app/controllers/api/v1/invites_controller.rb | 30 ++++++ .../auth/registrations_controller.rb | 15 +-- app/helpers/registration_helper.rb | 21 +++++ app/services/app_sign_up_service.rb | 30 ++---- config/locales/en.yml | 1 + config/routes.rb | 2 + spec/requests/invite_spec.rb | 27 ++++++ spec/services/app_sign_up_service_spec.rb | 92 ++++++++++++------- 9 files changed, 158 insertions(+), 78 deletions(-) create mode 100644 app/controllers/api/v1/invites_controller.rb create mode 100644 app/helpers/registration_helper.rb create mode 100644 spec/requests/invite_spec.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index ddb94d5ca..653529316 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::AccountsController < Api::BaseController + include RegistrationHelper + before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :remove_from_followers, :block, :unblock, :mute, :unmute] before_action -> { doorkeeper_authorize! :follow, :write, :'write:follows' }, only: [:follow, :unfollow, :remove_from_followers] before_action -> { doorkeeper_authorize! :follow, :write, :'write:mutes' }, only: [:mute, :unmute] @@ -90,18 +92,14 @@ class Api::V1::AccountsController < Api::BaseController end def account_params - params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone) + params.permit(:username, :email, :password, :agreement, :locale, :reason, :time_zone, :invite_code) + end + + def invite + Invite.find_by(code: params[:invite_code]) if params[:invite_code].present? end def check_enabled_registrations - forbidden if single_user_mode? || omniauth_only? || !allowed_registrations? - end - - def allowed_registrations? - Setting.registrations_mode != 'none' - end - - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' + forbidden unless allowed_registration?(request.remote_ip, invite) end end diff --git a/app/controllers/api/v1/invites_controller.rb b/app/controllers/api/v1/invites_controller.rb new file mode 100644 index 000000000..ea17ba740 --- /dev/null +++ b/app/controllers/api/v1/invites_controller.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Api::V1::InvitesController < Api::BaseController + include RegistrationHelper + + skip_before_action :require_authenticated_user! + skip_around_action :set_locale + + before_action :set_invite + before_action :check_enabled_registrations! + + # Override `current_user` to avoid reading session cookies + def current_user; end + + def show + render json: { invite_code: params[:invite_code], instance_api_url: api_v2_instance_url }, status: 200 + end + + private + + def set_invite + @invite = Invite.find_by!(code: params[:invite_code]) + end + + def check_enabled_registrations! + return render json: { error: I18n.t('invites.invalid') }, status: 401 unless @invite.valid_for_use? + + raise Mastodon::NotPermittedError unless allowed_registration?(request.remote_ip, @invite) + end +end diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 331484f36..8be7c5f19 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Auth::RegistrationsController < Devise::RegistrationsController + include RegistrationHelper include RegistrationSpamConcern layout :determine_layout @@ -82,19 +83,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController end def check_enabled_registrations - redirect_to root_path if single_user_mode? || omniauth_only? || !allowed_registrations? || ip_blocked? - end - - def allowed_registrations? - Setting.registrations_mode != 'none' || @invite&.valid_for_use? - end - - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' - end - - def ip_blocked? - IpBlock.where(severity: :sign_up_block).where('ip >>= ?', request.remote_ip.to_s).exists? + redirect_to root_path unless allowed_registration?(request.remote_ip, @invite) end def invite_code diff --git a/app/helpers/registration_helper.rb b/app/helpers/registration_helper.rb new file mode 100644 index 000000000..ef5462ac8 --- /dev/null +++ b/app/helpers/registration_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module RegistrationHelper + extend ActiveSupport::Concern + + def allowed_registration?(remote_ip, invite) + !Rails.configuration.x.single_user_mode && !omniauth_only? && (registrations_open? || invite&.valid_for_use?) && !ip_blocked?(remote_ip) + end + + def registrations_open? + Setting.registrations_mode != 'none' + end + + def omniauth_only? + ENV['OMNIAUTH_ONLY'] == 'true' + end + + def ip_blocked?(remote_ip) + IpBlock.where(severity: :sign_up_block).exists?(['ip >>= ?', remote_ip.to_s]) + end +end diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb index 94547b61b..766588011 100644 --- a/app/services/app_sign_up_service.rb +++ b/app/services/app_sign_up_service.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class AppSignUpService < BaseService + include RegistrationHelper + def call(app, remote_ip, params) @app = app @remote_ip = remote_ip @params = params - raise Mastodon::NotPermittedError unless allowed_registrations? + raise Mastodon::NotPermittedError unless allowed_registration?(remote_ip, invite) ApplicationRecord.transaction do create_user! @@ -34,8 +36,12 @@ class AppSignUpService < BaseService ) end + def invite + Invite.find_by(code: @params[:invite_code]) if @params[:invite_code].present? + end + def user_params - @params.slice(:email, :password, :agreement, :locale, :time_zone) + @params.slice(:email, :password, :agreement, :locale, :time_zone, :invite_code) end def account_params @@ -45,24 +51,4 @@ class AppSignUpService < BaseService def invite_request_params { text: @params[:reason] } end - - def allowed_registrations? - registrations_open? && !single_user_mode? && !omniauth_only? && !ip_blocked? - end - - def registrations_open? - Setting.registrations_mode != 'none' - end - - def single_user_mode? - Rails.configuration.x.single_user_mode - end - - def omniauth_only? - ENV['OMNIAUTH_ONLY'] == 'true' - end - - def ip_blocked? - IpBlock.where(severity: :sign_up_block).where('ip >>= ?', @remote_ip.to_s).exists? - end end diff --git a/config/locales/en.yml b/config/locales/en.yml index c298c47d3..7319de53d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1368,6 +1368,7 @@ en: '86400': 1 day expires_in_prompt: Never generate: Generate invite link + invalid: This invite is not valid invited_by: 'You were invited by:' max_uses: one: 1 use diff --git a/config/routes.rb b/config/routes.rb index 3adda3b82..82431f6ec 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,8 @@ Rails.application.routes.draw do resource :outbox, only: [:show], module: :activitypub end + get '/invite/:invite_code', constraints: ->(req) { req.format == :json }, to: 'api/v1/invites#show' + devise_scope :user do get '/invite/:invite_code', to: 'auth/registrations#new', as: :public_invite diff --git a/spec/requests/invite_spec.rb b/spec/requests/invite_spec.rb new file mode 100644 index 000000000..c44ef2419 --- /dev/null +++ b/spec/requests/invite_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe 'invites' do + let(:invite) { Fabricate(:invite) } + + context 'when requesting a JSON document' do + it 'returns a JSON document with expected attributes' do + get "/invite/#{invite.code}", headers: { 'Accept' => 'application/activity+json' } + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'application/json' + + expect(body_as_json[:invite_code]).to eq invite.code + end + end + + context 'when not requesting a JSON document' do + it 'returns an HTML page' do + get "/invite/#{invite.code}" + + expect(response).to have_http_status(200) + expect(response.media_type).to eq 'text/html' + end + end +end diff --git a/spec/services/app_sign_up_service_spec.rb b/spec/services/app_sign_up_service_spec.rb index 253230496..d5946cf9b 100644 --- a/spec/services/app_sign_up_service_spec.rb +++ b/spec/services/app_sign_up_service_spec.rb @@ -10,46 +10,72 @@ RSpec.describe AppSignUpService, type: :service do let(:remote_ip) { IPAddr.new('198.0.2.1') } describe '#call' do - it 'returns nil when registrations are closed' do - tmp = Setting.registrations_mode - Setting.registrations_mode = 'none' - expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError - Setting.registrations_mode = tmp + let(:params) { good_params } + + shared_examples 'successful registration' do + it 'creates an unconfirmed user with access token and the app\'s scope', :aggregate_failures do + access_token = subject.call(app, remote_ip, params) + expect(access_token).to_not be_nil + expect(access_token.scopes.to_s).to eq 'read write' + + user = User.find_by(id: access_token.resource_owner_id) + expect(user).to_not be_nil + expect(user.confirmed?).to be false + + expect(user.account).to_not be_nil + expect(user.invite_request).to be_nil + end + end + + context 'when registrations are closed' do + around do |example| + tmp = Setting.registrations_mode + Setting.registrations_mode = 'none' + + example.run + + Setting.registrations_mode = tmp + end + + it 'raises an error', :aggregate_failures do + expect { subject.call(app, remote_ip, good_params) }.to raise_error Mastodon::NotPermittedError + end + + context 'when using a valid invite' do + let(:params) { good_params.merge({ invite_code: invite.code }) } + let(:invite) { Fabricate(:invite) } + + before do + invite.user.approve! + end + + it_behaves_like 'successful registration' + end + + context 'when using an invalid invite' do + let(:params) { good_params.merge({ invite_code: invite.code }) } + let(:invite) { Fabricate(:invite, uses: 1, max_uses: 1) } + + it 'raises an error', :aggregate_failures do + expect { subject.call(app, remote_ip, params) }.to raise_error Mastodon::NotPermittedError + end + end end it 'raises an error when params are missing' do expect { subject.call(app, remote_ip, {}) }.to raise_error ActiveRecord::RecordInvalid end - it 'creates an unconfirmed user with access token' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.confirmed?).to be false - end + it_behaves_like 'successful registration' - it 'creates access token with the app\'s scopes' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - expect(access_token.scopes.to_s).to eq 'read write' - end - - it 'creates an account' do - access_token = subject.call(app, remote_ip, good_params) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.account).to_not be_nil - expect(user.invite_request).to be_nil - end - - it 'creates an account with invite request text' do - access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar')) - expect(access_token).to_not be_nil - user = User.find_by(id: access_token.resource_owner_id) - expect(user).to_not be_nil - expect(user.invite_request&.text).to eq 'Foo bar' + context 'when given an invite request text' do + it 'creates an account with invite request text' do + access_token = subject.call(app, remote_ip, good_params.merge(reason: 'Foo bar')) + expect(access_token).to_not be_nil + user = User.find_by(id: access_token.resource_owner_id) + expect(user).to_not be_nil + expect(user.invite_request&.text).to eq 'Foo bar' + end end end end