th: add invite limits behind TH_USE_INVITE_QUOTA
ci/woodpecker/push/woodpecker Pipeline was successful Details

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
pull/62/head
kouhai dev 2023-07-05 00:14:00 -07:00
parent c4f112ff01
commit 0c68cb08f5
6 changed files with 154 additions and 2 deletions

View File

@ -5,3 +5,5 @@ DB_HOST=$(pwd)/data/postgres
DB_USER=mastodon DB_USER=mastodon
DB_NAME=mastodon_dev DB_NAME=mastodon_dev
REDIS_URL=unix://./data/redis/redis-dev.sock REDIS_URL=unix://./data/redis/redis-dev.sock
TH_USE_INVITE_QUOTA=1

View File

@ -15,6 +15,8 @@ class InvitesController < ApplicationController
@invites = invites @invites = invites
@invite = Invite.new @invite = Invite.new
@invite.max_uses ||= 1
@invite.expires_in ||= 1.day.in_seconds
end end
def create def create

View File

@ -19,6 +19,11 @@
class Invite < ApplicationRecord class Invite < ApplicationRecord
include Expireable 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 belongs_to :user, inverse_of: :invites
has_many :users, inverse_of: :invite has_many :users, inverse_of: :invite
@ -26,6 +31,15 @@ class Invite < ApplicationRecord
validates :comment, length: { maximum: 420 } 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 before_validation :set_code
def valid_for_use? def valid_for_use?
@ -40,4 +54,31 @@ class Invite < ApplicationRecord
break if Invite.find_by(code: code).nil? break if Invite.find_by(code: code).nil?
end end
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 end

View File

@ -3,9 +3,9 @@
.fields-row .fields-row
.fields-row__column.fields-row__column-6.fields-group .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 .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 .fields-group
= f.input :autofollow, wrapper: :with_label = f.input :autofollow, wrapper: :with_label

View File

@ -56,6 +56,50 @@ describe InvitesController do
expect(subject).to redirect_to invites_path expect(subject).to redirect_to invites_path
expect(Invite.last).to have_attributes(user_id: user.id, max_uses: 10) expect(Invite.last).to have_attributes(user_id: user.id, max_uses: 10)
end 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 end
context 'when not everyone can invite' do context 'when not everyone can invite' do

View File

@ -35,4 +35,67 @@ RSpec.describe Invite do
expect(invite.valid_for_use?).to be false expect(invite.valid_for_use?).to be false
end end
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 end