th: invite quota
parent
41adbcaa80
commit
a126a2733f
|
@ -14,6 +14,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
|
||||||
|
|
|
@ -21,6 +21,11 @@ class Invite < ApplicationRecord
|
||||||
|
|
||||||
COMMENT_SIZE_LIMIT = 420
|
COMMENT_SIZE_LIMIT = 420
|
||||||
|
|
||||||
|
# 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).to_i
|
||||||
|
TH_ACTIVE_INVITE_SLOT_QUOTA = ENV.fetch('TH_ACTIVE_INVITE_SLOT_QUOTA', 40).to_i
|
||||||
|
|
||||||
belongs_to :user, inverse_of: :invites
|
belongs_to :user, inverse_of: :invites
|
||||||
has_many :users, inverse_of: :invite, dependent: nil
|
has_many :users, inverse_of: :invite, dependent: nil
|
||||||
|
|
||||||
|
@ -28,6 +33,15 @@ class Invite < ApplicationRecord
|
||||||
|
|
||||||
validates :comment, length: { maximum: COMMENT_SIZE_LIMIT }
|
validates :comment, length: { maximum: COMMENT_SIZE_LIMIT }
|
||||||
|
|
||||||
|
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?
|
||||||
|
@ -42,4 +56,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.can?(:manage_invites)
|
||||||
|
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
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
.fields-row
|
.fields-row
|
||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= form.input :max_uses, wrapper: :with_label, collection: invites_max_uses_options, label_method: ->(num) { I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt')
|
= form.input :max_uses, wrapper: :with_label, collection: invites_max_uses_options, label_method: ->(num) { I18n.t('invites.max_uses', count: num) }, prompt: I18n.t('invites.max_uses_prompt'), include_blank: false, include_hidden: false
|
||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= form.input :expires_in, wrapper: :with_label, collection: invites_expires_options.map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt')
|
= form.input :expires_in, wrapper: :with_label, collection: invites_expires_options.map(&:to_i), label_method: ->(i) { I18n.t("invites.expires_in.#{i}") }, prompt: I18n.t('invites.expires_in_prompt'), include_blank: false, include_hidden: false
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= form.input :autofollow, wrapper: :with_label
|
= form.input :autofollow, wrapper: :with_label
|
||||||
|
|
|
@ -107,7 +107,7 @@ en:
|
||||||
imports:
|
imports:
|
||||||
data: CSV file exported from another Mastodon server
|
data: CSV file exported from another Mastodon server
|
||||||
invite_request:
|
invite_request:
|
||||||
text: This will help us review your application
|
text: "Be sure to provide at least one link to an established account on another social website: GitHub, Twitter, or a personal blog or website. This will help us review your request in a timely manner."
|
||||||
ip_block:
|
ip_block:
|
||||||
comment: Optional. Remember why you added this rule.
|
comment: Optional. Remember why you added this rule.
|
||||||
expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended.
|
expires_in: IP addresses are a finite resource, they are sometimes shared and often change hands. For this reason, indefinite IP blocks are not recommended.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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(:moderator_user) }
|
||||||
|
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
|
||||||
|
|
Loading…
Reference in New Issue