diff options
author | Starfall <us@starfall.systems> | 2022-07-25 18:53:31 -0500 |
---|---|---|
committer | Starfall <us@starfall.systems> | 2022-07-25 18:53:31 -0500 |
commit | 5b9419060d79eda85c40a12c567dd0e1e44a7ecb (patch) | |
tree | f5e21930844f7c11ae40b9097a78a32916ba5dba /app/models | |
parent | a137fecf94d25a03ef7224843c1afd0c30f428e6 (diff) | |
parent | 3a7c641dd4db1d67b172f731518b472d58dd2262 (diff) |
Merge remote-tracking branch 'glitch/main'
Diffstat (limited to 'app/models')
-rw-r--r-- | app/models/account.rb | 9 | ||||
-rw-r--r-- | app/models/account_filter.rb | 27 | ||||
-rw-r--r-- | app/models/concerns/account_interactions.rb | 13 | ||||
-rw-r--r-- | app/models/concerns/user_roles.rb | 68 | ||||
-rw-r--r-- | app/models/custom_emoji.rb | 4 | ||||
-rw-r--r-- | app/models/custom_filter.rb | 87 | ||||
-rw-r--r-- | app/models/custom_filter_keyword.rb | 34 | ||||
-rw-r--r-- | app/models/domain_allow.rb | 1 | ||||
-rw-r--r-- | app/models/featured_tag.rb | 33 | ||||
-rw-r--r-- | app/models/form/admin_settings.rb | 4 | ||||
-rw-r--r-- | app/models/media_attachment.rb | 2 | ||||
-rw-r--r-- | app/models/notification.rb | 5 | ||||
-rw-r--r-- | app/models/tag.rb | 21 | ||||
-rw-r--r-- | app/models/tag_follow.rb | 24 | ||||
-rw-r--r-- | app/models/trends.rb | 2 | ||||
-rw-r--r-- | app/models/user.rb | 38 | ||||
-rw-r--r-- | app/models/user_role.rb | 186 |
17 files changed, 420 insertions, 138 deletions
diff --git a/app/models/account.rb b/app/models/account.rb index 688e6fabd..9627cc608 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -120,7 +120,7 @@ class Account < ApplicationRecord scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) } scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) } scope :popular, -> { order('account_stats.followers_count desc') } - scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches('%.' + domain))) } + scope :by_domain_and_subdomains, ->(domain) { where(domain: domain).or(where(arel_table[:domain].matches("%.#{domain}"))) } scope :not_excluded_by_account, ->(account) { where.not(id: account.excluded_from_timeline_account_ids) } scope :not_domain_blocked_by_account, ->(account) { where(arel_table[:domain].eq(nil).or(arel_table[:domain].not_in(account.excluded_from_timeline_domains))) } @@ -136,9 +136,6 @@ class Account < ApplicationRecord :unconfirmed?, :unconfirmed_or_pending?, :role, - :admin?, - :moderator?, - :staff?, :locale, :shows_application?, to: :user, @@ -456,7 +453,7 @@ class Account < ApplicationRecord DeliveryFailureTracker.without_unavailable(urls) end - def search_for(terms, limit = 10, offset = 0) + def search_for(terms, limit: 10, offset: 0) tsquery = generate_query_for_search(terms) sql = <<-SQL.squish @@ -478,7 +475,7 @@ class Account < ApplicationRecord records end - def advanced_search_for(terms, account, limit = 10, following = false, offset = 0) + def advanced_search_for(terms, account, limit: 10, following: false, offset: 0) tsquery = generate_query_for_search(terms) sql = advanced_search_for_sql_template(following) records = find_by_sql([sql, id: account.id, limit: limit, offset: offset, tsquery: tsquery]) diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb index ec309ce09..e214e0bad 100644 --- a/app/models/account_filter.rb +++ b/app/models/account_filter.rb @@ -4,7 +4,7 @@ class AccountFilter KEYS = %i( origin status - permissions + role_ids username by_domain display_name @@ -26,7 +26,7 @@ class AccountFilter params.each do |key, value| next if key.to_s == 'page' - scope.merge!(scope_for(key, value.to_s.strip)) if value.present? + scope.merge!(scope_for(key, value)) if value.present? end scope @@ -38,18 +38,18 @@ class AccountFilter case key.to_s when 'origin' origin_scope(value) - when 'permissions' - permissions_scope(value) + when 'role_ids' + role_scope(value) when 'status' status_scope(value) when 'by_domain' - Account.where(domain: value) + Account.where(domain: value.to_s) when 'username' - Account.matches_username(value) + Account.matches_username(value.to_s) when 'display_name' - Account.matches_display_name(value) + Account.matches_display_name(value.to_s) when 'email' - accounts_with_users.merge(User.matches_email(value)) + accounts_with_users.merge(User.matches_email(value.to_s)) when 'ip' valid_ip?(value) ? accounts_with_users.merge(User.matches_ip(value).group('users.id, accounts.id')) : Account.none when 'invited_by' @@ -104,13 +104,8 @@ class AccountFilter Account.left_joins(user: :invite).merge(Invite.where(user_id: value.to_s)) end - def permissions_scope(value) - case value.to_s - when 'staff' - accounts_with_users.merge(User.staff) - else - raise "Unknown permissions: #{value}" - end + def role_scope(value) + accounts_with_users.merge(User.where(role_id: Array(value).map(&:to_s))) end def accounts_with_users @@ -118,7 +113,7 @@ class AccountFilter end def valid_ip?(value) - IPAddr.new(value) && true + IPAddr.new(value.to_s) && true rescue IPAddr::InvalidAddressError false end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index ad1665dc4..a7401362f 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -247,6 +247,19 @@ module AccountInteractions account_pins.where(target_account: account).exists? end + def status_matches_filters(status) + active_filters = CustomFilter.cached_filters_for(id) + + filter_matches = active_filters.filter_map do |filter, rules| + next if rules[:keywords].blank? + + match = rules[:keywords].match(status.proper.searchable_text) + FilterResultPresenter.new(filter: filter, keyword_matches: [match.to_s]) unless match.nil? + end + + filter_matches + end + def followers_for_local_distribution followers.local .joins(:user) diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb deleted file mode 100644 index a42b4a172..000000000 --- a/app/models/concerns/user_roles.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -module UserRoles - extend ActiveSupport::Concern - - included do - scope :admins, -> { where(admin: true) } - scope :moderators, -> { where(moderator: true) } - scope :staff, -> { admins.or(moderators) } - end - - def staff? - admin? || moderator? - end - - def role=(value) - case value - when 'admin' - self.admin = true - self.moderator = false - when 'moderator' - self.admin = false - self.moderator = true - else - self.admin = false - self.moderator = false - end - end - - def role - if admin? - 'admin' - elsif moderator? - 'moderator' - else - 'user' - end - end - - def role?(role) - case role - when 'user' - true - when 'moderator' - staff? - when 'admin' - admin? - else - false - end - end - - def promote! - if moderator? - update!(moderator: false, admin: true) - elsif !admin? - update!(moderator: true) - end - end - - def demote! - if admin? - update!(admin: false, moderator: true) - elsif moderator? - update!(moderator: false) - end - end -end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb index 196ae0297..c89bf0586 100644 --- a/app/models/custom_emoji.rb +++ b/app/models/custom_emoji.rb @@ -23,8 +23,8 @@ class CustomEmoji < ApplicationRecord include Attachmentable - LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 50.kilobytes).to_i - LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 200.kilobytes).to_i].max + LOCAL_LIMIT = (ENV['MAX_EMOJI_SIZE'] || 256.kilobytes).to_i + LIMIT = [LOCAL_LIMIT, (ENV['MAX_REMOTE_EMOJI_SIZE'] || 256.kilobytes).to_i].max SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index 8e3476794..985eab125 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -3,18 +3,22 @@ # # Table name: custom_filters # -# id :bigint(8) not null, primary key -# account_id :bigint(8) -# expires_at :datetime -# phrase :text default(""), not null -# context :string default([]), not null, is an Array -# whole_word :boolean default(TRUE), not null -# irreversible :boolean default(FALSE), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) +# expires_at :datetime +# phrase :text default(""), not null +# context :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# action :integer default("warn"), not null # class CustomFilter < ApplicationRecord + self.ignored_columns = %w(whole_word irreversible) + + alias_attribute :title, :phrase + alias_attribute :filter_action, :action + VALID_CONTEXTS = %w( home notifications @@ -26,16 +30,20 @@ class CustomFilter < ApplicationRecord include Expireable include Redisable + enum action: [:warn, :hide], _suffix: :action + belongs_to :account + has_many :keywords, class_name: 'CustomFilterKeyword', foreign_key: :custom_filter_id, inverse_of: :custom_filter, dependent: :destroy + accepts_nested_attributes_for :keywords, reject_if: :all_blank, allow_destroy: true - validates :phrase, :context, presence: true + validates :title, :context, presence: true validate :context_must_be_valid - validate :irreversible_must_be_within_context - - scope :active_irreversible, -> { where(irreversible: true).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) } before_validation :clean_up_contexts - after_commit :remove_cache + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! def expires_in return @expires_in if defined?(@expires_in) @@ -44,22 +52,55 @@ class CustomFilter < ApplicationRecord [30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at } end - private + def irreversible=(value) + self.action = value ? :hide : :warn + end - def clean_up_contexts - self.context = Array(context).map(&:strip).filter_map(&:presence) + def irreversible? + hide_action? + end + + def self.cached_filters_for(account_id) + active_filters = Rails.cache.fetch("filters:v3:#{account_id}") do + scope = CustomFilterKeyword.includes(:custom_filter).where(custom_filter: { account_id: account_id }).where(Arel.sql('expires_at IS NULL OR expires_at > NOW()')) + scope.to_a.group_by(&:custom_filter).map do |filter, keywords| + keywords.map! do |keyword| + if keyword.whole_word + sb = /\A[[:word:]]/.match?(keyword.keyword) ? '\b' : '' + eb = /[[:word:]]\z/.match?(keyword.keyword) ? '\b' : '' + + /(?mix:#{sb}#{Regexp.escape(keyword.keyword)}#{eb})/ + else + /#{Regexp.escape(keyword.keyword)}/i + end + end + [filter, { keywords: Regexp.union(keywords) }] + end + end.to_a + + active_filters.select { |custom_filter, _| !custom_filter.expired? } + end + + def prepare_cache_invalidation! + @should_invalidate_cache = true end - def remove_cache - Rails.cache.delete("filters:#{account_id}") + def invalidate_cache! + return unless @should_invalidate_cache + @should_invalidate_cache = false + + Rails.cache.delete("filters:v3:#{account_id}") redis.publish("timeline:#{account_id}", Oj.dump(event: :filters_changed)) + redis.publish("timeline:system:#{account_id}", Oj.dump(event: :filters_changed)) end - def context_must_be_valid - errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } + private + + def clean_up_contexts + self.context = Array(context).map(&:strip).filter_map(&:presence) end - def irreversible_must_be_within_context - errors.add(:irreversible, I18n.t('filters.errors.invalid_irreversible')) if irreversible? && !context.include?('home') && !context.include?('notifications') + def context_must_be_valid + errors.add(:context, I18n.t('filters.errors.invalid_context')) if context.empty? || context.any? { |c| !VALID_CONTEXTS.include?(c) } end end diff --git a/app/models/custom_filter_keyword.rb b/app/models/custom_filter_keyword.rb new file mode 100644 index 000000000..e0d0289ae --- /dev/null +++ b/app/models/custom_filter_keyword.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_filter_keywords +# +# id :bigint(8) not null, primary key +# custom_filter_id :bigint(8) not null +# keyword :text default(""), not null +# whole_word :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomFilterKeyword < ApplicationRecord + belongs_to :custom_filter + + validates :keyword, presence: true + + alias_attribute :phrase, :keyword + + before_save :prepare_cache_invalidation! + before_destroy :prepare_cache_invalidation! + after_commit :invalidate_cache! + + private + + def prepare_cache_invalidation! + custom_filter.prepare_cache_invalidation! + end + + def invalidate_cache! + custom_filter.invalidate_cache! + end +end diff --git a/app/models/domain_allow.rb b/app/models/domain_allow.rb index 2e14fce25..7a0acbe32 100644 --- a/app/models/domain_allow.rb +++ b/app/models/domain_allow.rb @@ -11,6 +11,7 @@ # class DomainAllow < ApplicationRecord + include Paginable include DomainNormalizable include DomainMaterializable diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index 74d62e777..201ce75f5 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -13,17 +13,21 @@ # class FeaturedTag < ApplicationRecord - belongs_to :account, inverse_of: :featured_tags, required: true - belongs_to :tag, inverse_of: :featured_tags, required: true + belongs_to :account, inverse_of: :featured_tags + belongs_to :tag, inverse_of: :featured_tags, optional: true # Set after validation - delegate :name, to: :tag, allow_nil: true - - validates_associated :tag, on: :create - validates :name, presence: true, on: :create + validate :validate_tag_name, on: :create validate :validate_featured_tags_limit, on: :create - def name=(str) - self.tag = Tag.find_or_create_by_names(str.strip)&.first + before_create :set_tag + before_create :reset_data + + delegate :display_name, to: :tag + + attr_writer :name + + def name + tag_id.present? ? tag.name : @name end def increment(timestamp) @@ -34,14 +38,23 @@ class FeaturedTag < ApplicationRecord update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at) end + private + + def set_tag + self.tag = Tag.find_or_create_by_names(@name)&.first + end + def reset_data self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at end - private - def validate_featured_tags_limit errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10 end + + def validate_tag_name + errors.add(:name, :blank) if @name.blank? + errors.add(:name, :invalid) unless @name.match?(/\A(#{Tag::HASHTAG_NAME_RE})\z/i) + end end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 5627f8a84..4c100ba6b 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -15,11 +15,9 @@ class Form::AdminSettings closed_registrations_message open_deletion timeline_preview - show_staff_badge bootstrap_timeline_accounts flavour skin - min_invite_role activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page @@ -47,7 +45,6 @@ class Form::AdminSettings BOOLEAN_KEYS = %i( open_deletion timeline_preview - show_staff_badge activity_api_enabled peers_api_enabled show_known_fediverse_at_about_page @@ -79,7 +76,6 @@ class Form::AdminSettings validates :site_short_description, :site_description, html: { wrap_with: :p } validates :site_extended_description, :site_terms, :closed_registrations_message, html: true validates :registrations_mode, inclusion: { in: %w(open approved none) } - validates :min_invite_role, inclusion: { in: %w(disabled user moderator admin) } validates :site_contact_email, :site_contact_username, presence: true validates :site_contact_username, existing_username: true validates :bootstrap_timeline_accounts, existing_username: { multiple: true } diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index ef269c659..69feffbf0 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -58,7 +58,7 @@ class MediaAttachment < ApplicationRecord IMAGE_MIME_TYPES = %w(image/jpeg image/png image/gif image/webp).freeze VIDEO_MIME_TYPES = %w(video/webm video/mp4 video/quicktime video/ogg).freeze VIDEO_CONVERTIBLE_MIME_TYPES = %w(video/webm video/quicktime).freeze - AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/ogg audio/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze + AUDIO_MIME_TYPES = %w(audio/wave audio/wav audio/x-wav audio/x-pn-wave audio/vnd.wave audio/ogg audio/vorbis audio/mpeg audio/mp3 audio/webm audio/flac audio/aac audio/m4a audio/x-m4a audio/mp4 audio/3gpp video/x-ms-asf).freeze BLURHASH_OPTIONS = { x_comp: 4, diff --git a/app/models/notification.rb b/app/models/notification.rb index ba94b54d1..bbc63c1c0 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -37,6 +37,7 @@ class Notification < ApplicationRecord poll update admin.sign_up + admin.report ).freeze TARGET_STATUS_INCLUDES_BY_TYPE = { @@ -46,6 +47,7 @@ class Notification < ApplicationRecord favourite: [favourite: :status], poll: [poll: :status], update: :status, + 'admin.report': [report: :target_account], }.freeze belongs_to :account, optional: true @@ -58,6 +60,7 @@ class Notification < ApplicationRecord belongs_to :follow_request, foreign_key: 'activity_id', optional: true belongs_to :favourite, foreign_key: 'activity_id', optional: true belongs_to :poll, foreign_key: 'activity_id', optional: true + belongs_to :report, foreign_key: 'activity_id', optional: true validates :type, inclusion: { in: TYPES } @@ -146,7 +149,7 @@ class Notification < ApplicationRecord return unless new_record? case activity_type - when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll' + when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report' self.from_account_id = activity&.account_id when 'Mention' self.from_account_id = activity&.status&.account_id diff --git a/app/models/tag.rb b/app/models/tag.rb index a64042614..8929baf66 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -15,20 +15,25 @@ # last_status_at :datetime # max_score :float # max_score_at :datetime +# display_name :string # class Tag < ApplicationRecord has_and_belongs_to_many :statuses has_and_belongs_to_many :accounts + has_many :passive_relationships, class_name: 'TagFollow', inverse_of: :tag, dependent: :destroy has_many :featured_tags, dependent: :destroy, inverse_of: :tag + has_many :followers, through: :passive_relationships, source: :account HASHTAG_SEPARATORS = "_\u00B7\u200c" HASHTAG_NAME_RE = "([[:word:]_][[:word:]#{HASHTAG_SEPARATORS}]*[[:alpha:]#{HASHTAG_SEPARATORS}][[:word:]#{HASHTAG_SEPARATORS}]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)" HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } + validates :display_name, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } validate :validate_name_change, if: -> { !new_record? && name_changed? } + validate :validate_display_name_change, if: -> { !new_record? && display_name_changed? } scope :reviewed, -> { where.not(reviewed_at: nil) } scope :unreviewed, -> { where(reviewed_at: nil) } @@ -46,6 +51,10 @@ class Tag < ApplicationRecord name end + def display_name + attributes['display_name'] || name + end + def usable boolean_with_default('usable', true) end @@ -90,8 +99,10 @@ class Tag < ApplicationRecord class << self def find_or_create_by_names(name_or_names) - Array(name_or_names).map(&method(:normalize)).uniq { |str| str.mb_chars.downcase.to_s }.map do |normalized_name| - tag = matching_name(normalized_name).first || create(name: normalized_name) + names = Array(name_or_names).map { |str| [normalize(str), str] }.uniq(&:first) + + names.map do |(normalized_name, display_name)| + tag = matching_name(normalized_name).first || create(name: normalized_name, display_name: display_name.gsub(/[^[:alnum:]#{HASHTAG_SEPARATORS}]/, '')) yield tag if block_given? @@ -129,7 +140,7 @@ class Tag < ApplicationRecord end def normalize(str) - str.gsub(/\A#/, '') + HashtagNormalizer.new.normalize(str) end end @@ -138,4 +149,8 @@ class Tag < ApplicationRecord def validate_name_change errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero? end + + def validate_display_name_change + errors.add(:display_name, I18n.t('tags.does_not_match_previous_name')) unless HashtagNormalizer.new.normalize(display_name).casecmp(name.mb_chars).zero? + end end diff --git a/app/models/tag_follow.rb b/app/models/tag_follow.rb new file mode 100644 index 000000000..abe36cd17 --- /dev/null +++ b/app/models/tag_follow.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: tag_follows +# +# id :bigint(8) not null, primary key +# tag_id :bigint(8) not null +# account_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class TagFollow < ApplicationRecord + include RateLimitable + include Paginable + + belongs_to :tag + belongs_to :account + + accepts_nested_attributes_for :tag + + rate_limit by: :account, family: :follows +end diff --git a/app/models/trends.rb b/app/models/trends.rb index 0fff66a9f..5d5f2eb22 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -32,7 +32,7 @@ module Trends tags_requiring_review = tags.request_review statuses_requiring_review = statuses.request_review - User.staff.includes(:account).find_each do |user| + User.those_who_can(:manage_taxonomies).includes(:account).find_each do |user| links = user.allows_trending_links_review_emails? ? links_requiring_review : [] tags = user.allows_trending_tags_review_emails? ? tags_requiring_review : [] statuses = user.allows_trending_statuses_review_emails? ? statuses_requiring_review : [] diff --git a/app/models/user.rb b/app/models/user.rb index 6d2d94625..ffad4ae5a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -37,6 +37,7 @@ # sign_in_token_sent_at :datetime # webauthn_id :string # sign_up_ip :inet +# role_id :bigint(8) # class User < ApplicationRecord @@ -50,7 +51,6 @@ class User < ApplicationRecord ) include Settings::Extend - include UserRoles include Redisable include LanguagesHelper @@ -79,6 +79,7 @@ class User < ApplicationRecord belongs_to :account, inverse_of: :user belongs_to :invite, counter_cache: :uses, optional: true belongs_to :created_by_application, class_name: 'Doorkeeper::Application', optional: true + belongs_to :role, class_name: 'UserRole', optional: true accepts_nested_attributes_for :account has_many :applications, class_name: 'Doorkeeper::Application', as: :owner @@ -103,6 +104,7 @@ class User < ApplicationRecord validates_with RegistrationFormTimeValidator, on: :create validates :website, absence: true, on: :create validates :confirm_password, absence: true, on: :create + validate :validate_role_elevation scope :recent, -> { order(id: :desc) } scope :pending, -> { where(approved: false) } @@ -117,6 +119,7 @@ class User < ApplicationRecord scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) } before_validation :sanitize_languages + before_validation :sanitize_role before_create :set_approved after_commit :send_pending_devise_notifications after_create_commit :trigger_webhooks @@ -135,8 +138,28 @@ class User < ApplicationRecord :disable_swiping, :always_send_emails, :default_content_type, :system_emoji_font, to: :settings, prefix: :setting, allow_nil: false + delegate :can?, to: :role + attr_reader :invite_code - attr_writer :external, :bypass_invite_request_check + attr_writer :external, :bypass_invite_request_check, :current_account + + def self.those_who_can(*any_of_privileges) + matching_role_ids = UserRole.that_can(*any_of_privileges).map(&:id) + + if matching_role_ids.empty? + none + else + where(role_id: matching_role_ids) + end + end + + def role + if role_id.nil? + UserRole.everyone + else + super + end + end def confirmed? confirmed_at.present? @@ -449,6 +472,11 @@ class User < ApplicationRecord self.chosen_languages = nil if chosen_languages.empty? end + def sanitize_role + return if role.nil? + self.role = nil if role.everyone? + end + def prepare_new_user! BootstrapTimelineWorker.perform_async(account_id) ActivityTracker.increment('activity:accounts:local') @@ -461,7 +489,7 @@ class User < ApplicationRecord end def notify_staff_about_pending_account! - User.staff.includes(:account).find_each do |u| + User.those_who_can(:manage_users).includes(:account).find_each do |u| next unless u.allows_pending_account_emails? AdminMailer.new_pending_account(u.account, self).deliver_later end @@ -479,6 +507,10 @@ class User < ApplicationRecord email_changed? && !external? && !(Rails.env.test? || Rails.env.development?) end + def validate_role_elevation + errors.add(:role_id, :elevated) if defined?(@current_account) && role&.overrides?(@current_account&.user_role) + end + def invite_text_required? Setting.require_invite_text && !invited? && !external? && !bypass_invite_request_check? end diff --git a/app/models/user_role.rb b/app/models/user_role.rb new file mode 100644 index 000000000..57a56c0b0 --- /dev/null +++ b/app/models/user_role.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: user_roles +# +# id :bigint(8) not null, primary key +# name :string default(""), not null +# color :string default(""), not null +# position :integer default(0), not null +# permissions :bigint(8) default(0), not null +# highlighted :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# + +class UserRole < ApplicationRecord + FLAGS = { + administrator: (1 << 0), + view_devops: (1 << 1), + view_audit_log: (1 << 2), + view_dashboard: (1 << 3), + manage_reports: (1 << 4), + manage_federation: (1 << 5), + manage_settings: (1 << 6), + manage_blocks: (1 << 7), + manage_taxonomies: (1 << 8), + manage_appeals: (1 << 9), + manage_users: (1 << 10), + manage_invites: (1 << 11), + manage_rules: (1 << 12), + manage_announcements: (1 << 13), + manage_custom_emojis: (1 << 14), + manage_webhooks: (1 << 15), + invite_users: (1 << 16), + manage_roles: (1 << 17), + manage_user_access: (1 << 18), + delete_user_data: (1 << 19), + }.freeze + + module Flags + NONE = 0 + ALL = FLAGS.values.reduce(&:|) + + DEFAULT = FLAGS[:invite_users] + + CATEGORIES = { + invites: %i( + invite_users + ).freeze, + + moderation: %w( + view_dashboard + view_audit_log + manage_users + manage_user_access + delete_user_data + manage_reports + manage_appeals + manage_federation + manage_blocks + manage_taxonomies + manage_invites + ).freeze, + + administration: %w( + manage_settings + manage_rules + manage_roles + manage_webhooks + manage_custom_emojis + manage_announcements + ).freeze, + + devops: %w( + view_devops + ).freeze, + + special: %i( + administrator + ).freeze, + }.freeze + end + + attr_writer :current_account + + validates :name, presence: true, unless: :everyone? + validates :color, format: { with: /\A#?(?:[A-F0-9]{3}){1,2}\z/i }, unless: -> { color.blank? } + + validate :validate_permissions_elevation + validate :validate_position_elevation + validate :validate_dangerous_permissions + validate :validate_own_role_edition + + before_validation :set_position + + scope :assignable, -> { where.not(id: -99).order(position: :asc) } + + has_many :users, inverse_of: :role, foreign_key: 'role_id', dependent: :nullify + + def self.nobody + @nobody ||= UserRole.new(permissions: Flags::NONE, position: -1) + end + + def self.everyone + UserRole.find(-99) + rescue ActiveRecord::RecordNotFound + UserRole.create!(id: -99, permissions: Flags::DEFAULT) + end + + def self.that_can(*any_of_privileges) + all.select { |role| role.can?(*any_of_privileges) } + end + + def everyone? + id == -99 + end + + def nobody? + id.nil? + end + + def permissions_as_keys + FLAGS.keys.select { |privilege| permissions & FLAGS[privilege] == FLAGS[privilege] }.map(&:to_s) + end + + def permissions_as_keys=(value) + self.permissions = value.map(&:presence).compact.reduce(Flags::NONE) { |bitmask, privilege| FLAGS.key?(privilege.to_sym) ? (bitmask | FLAGS[privilege.to_sym]) : bitmask } + end + + def can?(*any_of_privileges) + any_of_privileges.any? { |privilege| in_permissions?(privilege) } + end + + def overrides?(other_role) + other_role.nil? || position > other_role.position + end + + def computed_permissions + # If called on the everyone role, no further computation needed + return permissions if everyone? + + # If called on the nobody role, no permissions are there to be given + return Flags::NONE if nobody? + + # Otherwise, compute permissions based on special conditions + @computed_permissions ||= begin + permissions = self.class.everyone.permissions | self.permissions + + if permissions & FLAGS[:administrator] == FLAGS[:administrator] + Flags::ALL + else + permissions + end + end + end + + private + + def in_permissions?(privilege) + raise ArgumentError, "Unknown privilege: #{privilege}" unless FLAGS.key?(privilege) + computed_permissions & FLAGS[privilege] == FLAGS[privilege] + end + + def set_position + self.position = -1 if everyone? + end + + def validate_own_role_edition + return unless defined?(@current_account) && @current_account.user_role.id == id + errors.add(:permissions_as_keys, :own_role) if permissions_changed? + errors.add(:position, :own_role) if position_changed? + end + + def validate_permissions_elevation + errors.add(:permissions_as_keys, :elevated) if defined?(@current_account) && @current_account.user_role.computed_permissions & permissions != permissions + end + + def validate_position_elevation + errors.add(:position, :elevated) if defined?(@current_account) && @current_account.user_role.position < position + end + + def validate_dangerous_permissions + errors.add(:permissions_as_keys, :dangerous) if everyone? && Flags::DEFAULT & permissions != permissions + end +end |