about summary refs log tree commit diff
path: root/app/models
diff options
context:
space:
mode:
Diffstat (limited to 'app/models')
-rw-r--r--app/models/account.rb9
-rw-r--r--app/models/account_filter.rb27
-rw-r--r--app/models/concerns/account_interactions.rb13
-rw-r--r--app/models/concerns/user_roles.rb68
-rw-r--r--app/models/custom_emoji.rb4
-rw-r--r--app/models/custom_filter.rb87
-rw-r--r--app/models/custom_filter_keyword.rb34
-rw-r--r--app/models/domain_allow.rb1
-rw-r--r--app/models/featured_tag.rb33
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/media_attachment.rb2
-rw-r--r--app/models/notification.rb5
-rw-r--r--app/models/tag.rb21
-rw-r--r--app/models/tag_follow.rb24
-rw-r--r--app/models/trends.rb2
-rw-r--r--app/models/user.rb38
-rw-r--r--app/models/user_role.rb186
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