From 280cf13f59d91ebdebabc9f843d621d22fde0651 Mon Sep 17 00:00:00 2001 From: Kouhai Date: Wed, 5 Jul 2023 00:14:00 -0700 Subject: [PATCH] th: add invite limits behind TH_USE_INVITE_QUOTA TH_USE_INVITE_QUOTA: feature flag TH_INVITE_MAX_USES: max uses per invite for non-moderators TH_ACTIVE_INVITE_SLOT_QUOTA: max slots in active invites, including consumed slots --- .env.development | 2 + app/controllers/invites_controller.rb | 2 + app/models/invite.rb | 41 ++++++++++++++ app/views/invites/_form.html.haml | 4 +- spec/controllers/invites_controller_spec.rb | 44 ++++++++++++++ spec/models/invite_spec.rb | 63 +++++++++++++++++++++ 6 files changed, 154 insertions(+), 2 deletions(-) diff --git a/.env.development b/.env.development index 0629bf94ce..767930c962 100644 --- a/.env.development +++ b/.env.development @@ -5,3 +5,5 @@ DB_HOST=$(pwd)/data/postgres DB_USER=mastodon DB_NAME=mastodon_dev REDIS_URL=unix://./data/redis/redis-dev.sock + +TH_USE_INVITE_QUOTA=1 diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 2db4bc5cbd..333846e1d1 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -15,6 +15,8 @@ class InvitesController < ApplicationController @invites = invites @invite = Invite.new + @invite.max_uses ||= 1 + @invite.expires_in ||= 1.day.in_seconds end def create diff --git a/app/models/invite.rb b/app/models/invite.rb index 8e816cef06..52c09e4806 100644 --- a/app/models/invite.rb +++ b/app/models/invite.rb @@ -19,6 +19,11 @@ class Invite < ApplicationRecord include Expireable + # FIXME: make this a rails cfg key or whatev? + TH_USE_INVITE_QUOTA = !!ENV['TH_USE_INVITE_QUOTA'] + TH_INVITE_MAX_USES = ENV.fetch('TH_INVITE_MAX_USES', 25) + TH_ACTIVE_INVITE_SLOT_QUOTA = ENV.fetch('TH_ACTIVE_INVITE_SLOT_QUOTA', 40) + belongs_to :user, inverse_of: :invites has_many :users, inverse_of: :invite @@ -26,6 +31,15 @@ class Invite < ApplicationRecord validates :comment, length: { maximum: 420 } + with_options if: :th_use_invite_quota?, unless: :created_by_moderator? do |invite| + invite.validates :expires_at, presence: true + invite.validate :expires_in_at_most_one_week? + invite.validates :max_uses, presence: true + # In Rails 6.1, numericality doesn't support :in + invite.validates :max_uses, numericality: { only_integer: true, greater_than: 0, less_than_or_equal_to: TH_INVITE_MAX_USES } + invite.validate :reasonable_outstanding_invite_count? + end + before_validation :set_code def valid_for_use? @@ -40,4 +54,31 @@ class Invite < ApplicationRecord break if Invite.find_by(code: code).nil? end end + + def created_by_moderator? + self.user.moderator + end + + def th_use_invite_quota? + TH_USE_INVITE_QUOTA + end + + def expires_in_at_most_one_week? + return if self.expires_in.to_i.seconds <= 1.week + # FIXME: Localize this + errors.add(:expires_in, 'must expire within one week') + end + + def reasonable_outstanding_invite_count? + valid_invites = self.user.invites.filter { |i| i.valid_for_use? } + count = valid_invites.sum do |i| + next i.max_uses unless i.max_uses.nil? + + errors.add(:max_uses, 'must not have any active unlimited-use invites') + return + end + + return if count + max_uses <= TH_ACTIVE_INVITE_SLOT_QUOTA + errors.add(:max_uses, "must not exceed active invite slot quota of #{TH_ACTIVE_INVITE_SLOT_QUOTA}") + end end diff --git a/app/views/invites/_form.html.haml b/app/views/invites/_form.html.haml index 3a2a5ef0e1..31789feaf4 100644 --- a/app/views/invites/_form.html.haml +++ b/app/views/invites/_form.html.haml @@ -3,9 +3,9 @@ .fields-row .fields-row__column.fields-row__column-6.fields-group - = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25, 50, 100], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt') + = f.input :max_uses, wrapper: :with_label, collection: [1, 5, 10, 25], label_method: lambda { |num| I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt'), include_blank: false, include_hidden: false # required: true .fields-row__column.fields-row__column-6.fields-group - = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt') + = f.input :expires_in, wrapper: :with_label, collection: [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week, 1.month].map(&:to_i), label_method: lambda { |i| I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt'), include_blank: false, include_hidden: false, selected: 1 # required: true .fields-group = f.input :autofollow, wrapper: :with_label diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb index 8718403bf3..ea017b855e 100644 --- a/spec/controllers/invites_controller_spec.rb +++ b/spec/controllers/invites_controller_spec.rb @@ -56,6 +56,50 @@ describe InvitesController do expect(subject).to redirect_to invites_path expect(Invite.last).to have_attributes(user_id: user.id, max_uses: 10) end + + # context 'when th_invite_limits_active?' do + # let(:max_uses) { 25 } + # let(:expires_in) { 86400 } + + # subject { post :create, params: { invite: { max_uses: "#{max_uses}", expires_in: expires_in } } } + + # before do + # # expect_any_instance_of(Invite).to receive(:th_invite_limits_active?).and_return true + # allow_any_instance_of(Invite).to receive(:th_invite_limits_active?).and_return true + # # expect_any_instance_of(Invite).to receive(:created_by_moderator?).and_return false + # allow_any_instance_of(Invite).to receive(:created_by_moderator?).and_return false + # end + + # it do + # expect(user.moderator).to be_falsy + # end + + # shared_examples 'fails to create an invite' do + # it 'fails to create an invite' do + # expect { subject }.not_to change { Invite.count } + # end + # end + + # it 'succeeds to create a invite' do + # expect { subject }.to change { Invite.count }.by(1) + # expect(subject).to redirect_to invites_path + # expect(Invite.last).to have_attributes(user_id: user.id, max_uses: max_uses) + # end + + # context 'when the request is over the limits' do + # context do + # let(:max_uses) { 26 } + + # include_examples 'fails to create an invite' + # end + + # context do + # let(:expires_in) { 86401 } + + # include_examples 'fails to create an invite' + # end + # end + # end end context 'when not everyone can invite' do diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb index 4ad589f2c7..92df6a959d 100644 --- a/spec/models/invite_spec.rb +++ b/spec/models/invite_spec.rb @@ -35,4 +35,67 @@ RSpec.describe Invite do expect(invite.valid_for_use?).to be false end end + + context 'when th_use_invite_quota?' do + let(:max_uses) { 25 } + let(:expires_in) { 1.week.in_seconds } + let(:regular_user) { Fabricate(:user) } + let(:moderator_user) { Fabricate(:user, moderator: true) } + let(:user) { regular_user } + let(:created_at) { Time.at(0) } + let(:expires_at) { Time.at(0) + expires_in } + + subject { Fabricate.build(:invite, user: user, max_uses: max_uses, created_at: created_at, expires_at: expires_at ) } + + before do + stub_const('Invite::TH_USE_INVITE_QUOTA', true) + stub_const('Invite::TH_INVITE_MAX_USES', 25) + stub_const('Invite::TH_ACTIVE_INVITE_SLOT_QUOTA', 30) + end + + it { is_expected.to be_valid } + + context 'and' do + context 'max_uses exceeds quota' do + let(:max_uses) { 26 } + + it { is_expected.not_to be_valid } + end + + context 'expires_in exceeds quota' do + let(:expires_in) { 1.week.in_seconds + 1 } + + it { is_expected.not_to be_valid } + end + + context 'multiple values exceed quota' do + let(:max_uses) { 26 } + let(:expires_in) { 86401 } + + it { is_expected.not_to be_valid } + end + + context 'an unlimited use invite' do + before do + Fabricate.build(:invite, user: user).save(validate: false) + end + + it { is_expected.not_to be_valid } + end + + context 'too many outstanding invites' do + before do + Fabricate.build(:invite, user: user, max_uses: 6).save(validate: false) + end + + it { is_expected.not_to be_valid } + end + + context 'a moderator created the invite' do + let(:user) { moderator_user } + + it { is_expected.to be_valid } + end + end + end end