about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/accounts_controller.rb13
-rw-r--r--app/controllers/application_controller.rb4
-rw-r--r--app/controllers/settings/profiles_controller.rb2
-rw-r--r--app/helpers/accounts_helper.rb6
-rw-r--r--app/helpers/statuses_helper.rb4
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js3
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss6
-rw-r--r--app/lib/bangtags.rb12
-rw-r--r--app/models/account.rb5
-rw-r--r--app/models/concerns/user_roles.rb45
-rw-r--r--app/models/form/admin_settings.rb2
-rw-r--r--app/models/user.rb3
-rw-r--r--app/policies/account_moderation_note_policy.rb4
-rw-r--r--app/policies/account_policy.rb44
-rw-r--r--app/policies/account_warning_preset_policy.rb8
-rw-r--r--app/policies/application_policy.rb2
-rw-r--r--app/policies/custom_emoji_policy.rb6
-rw-r--r--app/policies/domain_block_policy.rb10
-rw-r--r--app/policies/email_domain_block_policy.rb6
-rw-r--r--app/policies/instance_policy.rb4
-rw-r--r--app/policies/invite_policy.rb6
-rw-r--r--app/policies/relay_policy.rb2
-rw-r--r--app/policies/report_note_policy.rb4
-rw-r--r--app/policies/report_policy.rb6
-rw-r--r--app/policies/settings_policy.rb4
-rw-r--r--app/policies/status_policy.rb6
-rw-r--r--app/policies/tag_policy.rb6
-rw-r--r--app/policies/user_policy.rb24
-rw-r--r--app/serializers/initial_state_serializer.rb2
-rw-r--r--app/serializers/rest/account_serializer.rb1
-rw-r--r--app/services/notify_service.rb2
-rw-r--r--app/views/admin/custom_emojis/_custom_emoji.html.haml6
-rw-r--r--app/views/admin/settings/edit.html.haml2
-rw-r--r--app/views/settings/notifications/show.html.haml2
-rw-r--r--app/views/settings/profiles/show.html.haml6
-rw-r--r--app/workers/scheduler/defang_scheduler.rb14
36 files changed, 188 insertions, 94 deletions
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 3359eafdf..8bff3ab18 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -16,7 +16,8 @@ class AccountsController < ApplicationController
 
         unless current_account&.id == @account.id
           if @account.hidden || @account&.user&.hides_public_profile?
-            return not_found unless current_account&.following?(@account)
+            not_found unless current_account&.following?(@account)
+            return
           end
         end
 
@@ -44,10 +45,12 @@ class AccountsController < ApplicationController
       format.rss do
         expires_in 1.minute, public: true
 
-        return not_found unless current_account&.user&.allows_rss?
-
-        @statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
-        @statuses = cache_collection(@statuses, Status)
+        if current_account&.user&.allows_rss?
+          @statuses = filtered_statuses.without_reblogs.without_replies.limit(PAGE_SIZE)
+          @statuses = cache_collection(@statuses, Status)
+        else
+          @statuses = []
+        end
 
         render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
       end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3169151a8..b6c2feafb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -63,6 +63,10 @@ class ApplicationController < ActionController::Base
     forbidden unless current_user&.staff?
   end
 
+  def require_halfmod!
+    forbidden unless current_user&.halfmod?
+  end
+
   def check_user_permissions
     forbidden if current_user.disabled? || current_user.account.suspended?
   end
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 6b3f0d311..dab613085 100644
--- a/app/controllers/settings/profiles_controller.rb
+++ b/app/controllers/settings/profiles_controller.rb
@@ -25,7 +25,7 @@ class Settings::ProfilesController < Settings::BaseController
   private
 
   def account_params
-    params.require(:account).permit(:display_name, :note, :avatar, :header, :replies, :locked, :hidden, :unlisted, :block_anon, :gently, :kobold, :adult_content, :bot, :discoverable, :filter_undescribed, fields_attributes: [:name, :value])
+    params.require(:account).permit(:display_name, :note, :avatar, :header, :replies, :locked, :hidden, :unlisted, :block_anon, :gently, :kobold, :adult_content, :bot, :discoverable, :filter_undescribed, :user_defanged, fields_attributes: [:name, :value])
   end
 
   def set_account
diff --git a/app/helpers/accounts_helper.rb b/app/helpers/accounts_helper.rb
index c9e95d8d8..1a5f22e2a 100644
--- a/app/helpers/accounts_helper.rb
+++ b/app/helpers/accounts_helper.rb
@@ -64,14 +64,16 @@ module AccountsHelper
       content_tag(:div, content_tag(:div, t('accounts.roles.bot'), class: 'account-role bot'), class: 'roles')
     elsif account.group?
       content_tag(:div, content_tag(:div, t('accounts.roles.group'), class: 'account-role group'), class: 'roles')
-    elsif (Setting.show_staff_badge && account.user_staff?) || all
+    elsif (Setting.show_staff_badge && account.user_can_moderate?) || all
       content_tag(:div, class: 'roles') do
-        if all && !account.user_staff?
+        if all && !account.user_can_moderate?
           content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
         elsif account.user_admin?
           content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
         elsif account.user_moderator?
           content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
+        elsif account.user_halfmod?
+          content_tag(:div, t('accounts.roles.halfmod'), class: 'account-role halfmod')
         end
       end
     end
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index d122a2860..574b3ad5c 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -56,13 +56,15 @@ module StatusesHelper
       roles << content_tag(:div, t('accounts.roles.gently'), class: 'account-role gently') if account.gently?
       roles << content_tag(:div, t('accounts.roles.kobold'), class: 'account-role kobold') if account.kobold?
 
-      if (Setting.show_staff_badge && account.user_staff?) || all
+      if (Setting.show_staff_badge && account.user_can_moderate?) || all
         if all && !account.user_staff?
           roles << content_tag(:div, t('admin.accounts.roles.user'), class: 'account-role')
         elsif account.user_admin?
           roles << content_tag(:div, t('accounts.roles.admin'), class: 'account-role admin')
         elsif account.user_moderator?
           roles << content_tag(:div, t('accounts.roles.moderator'), class: 'account-role moderator')
+        elsif account.user_halfmod?
+          roles << content_tag(:div, t('accounts.roles.halfmod'), class: 'account-role halfmod')
         end
       end
 
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index d762e1087..553302cbc 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -193,6 +193,7 @@ class Header extends ImmutablePureComponent {
     const badge_gently    = account.get('gently') ? (<div className='account-role gently'><FormattedMessage id='account.badges.gently' defaultMessage="Gentlies kobolds" /></div>) : null;
     const badge_kobold    = account.get('kobold') ? (<div className='account-role kobold'><FormattedMessage id='account.badges.kobold' defaultMessage="Gently the kobold" /></div>) : null;
     const badge_mod       = account.get('role') == 'moderator' ? (<div className='account-role moderator'><FormattedMessage id='account.badges.moderator' defaultMessage="Moderator" /></div>) : null;
+    const badge_halfmod   = account.get('role') == 'halfmod' ? (<div className='account-role halfmod'><FormattedMessage id='account.badges.halfmod' defaultMessage="Half-moderator" /></div>) : null;
     const badge_admin     = account.get('role') == 'admin' ? (<div className='account-role admin'><FormattedMessage id='account.badges.admin' defaultMessage="Admin" /></div>) : null;
     const acct            = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
 
@@ -225,7 +226,7 @@ class Header extends ImmutablePureComponent {
             <h1>
               <span dangerouslySetInnerHTML={displayNameHtml} />
               <small>@{acct}</small>
-              <div className='roles'>{badge_admin}{badge_mod}{badge_froze}{badge_locked}{badge_limited}{badge_ac}{badge_bot}{badge_gently}{badge_kobold}</div>
+              <div className='roles'>{badge_admin}{badge_mod}{badge_halfmod}{badge_froze}{badge_locked}{badge_limited}{badge_ac}{badge_bot}{badge_gently}{badge_kobold}</div>
             </h1>
           </div>
 
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index 108fd3451..7202ea8a3 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -212,6 +212,12 @@
   background-color: rgba($ui-secondary-color, 0.1);
   border: 1px solid rgba($ui-secondary-color, 0.5);
 
+  &.halfmod {
+    color: lighten($success-green, 10%);
+    background-color: rgba(lighten($success-green, 10%), 0.1);
+    border-color: rgba(lighten(success-green, 10%), 0.5);
+  }
+
   &.moderator {
     color: $success-green;
     background-color: rgba($success-green, 0.1);
diff --git a/app/lib/bangtags.rb b/app/lib/bangtags.rb
index 59ec16a23..ac5a4fce3 100644
--- a/app/lib/bangtags.rb
+++ b/app/lib/bangtags.rb
@@ -1299,6 +1299,18 @@ class Bangtags
           @vars.delete("_media:#{media_idx}:desc")
         end
 
+      when 'op', 'oper', 'fang', 'fangs'
+        chunk = nil
+        next unless @user.can_moderate? && @user.defanged?
+        @user.fangs_out!
+        service_dm('announcements', @account, "You are now in #{@user.role} mode. This will expire after 15 minutes.", footer: '#!fangs')
+
+      when 'deop', 'deoper', 'defang'
+        chunk = nil
+        next if @user.defanged?
+        @user.defang!
+        service_dm('announcements', @account, "You are no longer in #{@user.role} mode.", footer: '#!defang')
+
       when 'admin'
         next unless @user.admin?
         next if post_cmd[1].nil?
diff --git a/app/models/account.rb b/app/models/account.rb
index b0b9e9191..6f5a11ce0 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -132,7 +132,12 @@ class Account < ApplicationRecord
            :pending?,
            :admin?,
            :moderator?,
+           :halfmod?,
            :staff?,
+           :can_moderate?,
+           :defanged?,
+           :defanged,
+           :defanged=,
            :locale,
 
            :default_language,
diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb
index 58dffdc46..2da039efd 100644
--- a/app/models/concerns/user_roles.rb
+++ b/app/models/concerns/user_roles.rb
@@ -6,6 +6,7 @@ module UserRoles
   included do
     scope :admins, -> { where(admin: true) }
     scope :moderators, -> { where(moderator: true) }
+    scope :halfmods, -> { where(halfmod: true) }
     scope :staff, -> { admins.or(moderators) }
   end
 
@@ -13,11 +14,17 @@ module UserRoles
     admin? || moderator?
   end
 
+  def can_moderate?
+    staff? || halfmod?
+  end
+
   def role
     if admin?
       'admin'
     elsif moderator?
       'moderator'
+    elsif halfmod?
+      'halfmod'
     else
       'user'
     end
@@ -27,6 +34,8 @@ module UserRoles
     case role
     when 'user'
       true
+    when 'halfmod'
+      halfmod?
     when 'moderator'
       staff?
     when 'admin'
@@ -36,19 +45,45 @@ module UserRoles
     end
   end
 
+  def has_more_authority_than?(other_user)
+    if admin?
+      !other_user&.admin?
+    elsif moderator?
+      !other_user&.staff?
+    elsif halfmod?
+      !other_user&.can_moderate?
+    else
+      false
+    end
+  end
+
   def promote!
-    if moderator?
-      update!(moderator: false, admin: true)
+    if halfmod?
+      update!(halfmod: false, moderator: true, admin: false)
+    elsif moderator?
+      update!(halfmod: false, moderator: false, admin: true)
     elsif !admin?
-      update!(moderator: true)
+      update!(halfmod: true, moderator: false, admin: false)
     end
   end
 
   def demote!
     if admin?
-      update!(admin: false, moderator: true)
+      update!(halfmod: false, moderator: true, admin: false)
     elsif moderator?
-      update!(moderator: false)
+      update!(halfmod: true, moderator: false, admin: false)
+    elsif halfmod?
+      update!(halfmod: false, moderator: false, admin: false)
     end
   end
+
+  def fangs_out!
+    update!(defanged: false, last_fanged_at: Time.now.utc)
+    LogWorker.perform_async("\u23eb <#{self.account.username}> switched to fanged #{role} mode.")
+  end
+
+  def defang!
+    update!(defanged: true, last_fanged_at: nil)
+    LogWorker.perform_async("\u23ec <#{self.account.username}> is no longer in fanged #{role} mode.")
+  end
 end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 00abb3906..c9cd3a87f 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -88,7 +88,7 @@ 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 :min_invite_role, inclusion: { in: %w(disabled user halfmod 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/user.rb b/app/models/user.rb
index 267818eff..00e2af458 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -48,6 +48,9 @@
 #  filters_enabled           :boolean          default(FALSE), not null
 #  monsterfork_api           :integer          default("full"), not null
 #  allow_unknown_follows     :boolean          default(FALSE), not null
+#  defanged                  :boolean          default(TRUE), not null
+#  halfmod                   :boolean          default(FALSE), not null
+#  last_fanged_at            :datetime
 #
 
 class User < ApplicationRecord
diff --git a/app/policies/account_moderation_note_policy.rb b/app/policies/account_moderation_note_policy.rb
index 885411a5b..781cf75ff 100644
--- a/app/policies/account_moderation_note_policy.rb
+++ b/app/policies/account_moderation_note_policy.rb
@@ -2,11 +2,11 @@
 
 class AccountModerationNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def destroy?
-    admin? || owner?
+    (!defanged? && admin?) || owner?
   end
 
   private
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index b05709183..3ac0c4c6a 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -2,90 +2,90 @@
 
 class AccountPolicy < ApplicationPolicy
   def index?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def show?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def warn?
-    staff? && !record.user&.staff?
+    !defanged? && staff? && has_more_authority_than?(record&.user)
   end
 
   def mark_known?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def mark_unknown?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def manual_only?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def auto_trust?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def suspend?
-    staff? && !record.user&.staff?
+    !defanged? && staff? && has_more_authority_than?(record&.user)
   end
 
   def unsuspend?
-    staff?
+    !defanged? && staff? && has_more_authority_than?(record&.user)
   end
 
   def silence?
-    staff? && !record.user&.staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record.user)
   end
 
   def unsilence?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def force_unlisted?
-    staff?
+    !defanged? && staff? && has_more_authority_than?(record&.user)
   end
 
   def allow_public?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def force_sensitive?
-    staff?
+    !defanged? && staff? && has_more_authority_than?(record&.user)
   end
 
   def allow_nonsensitive?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def redownload?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def sync?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def remove_avatar?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def remove_header?
-    staff?
+    !defanged? && can_moderate? && has_more_authority_than?(record&.user)
   end
 
   def subscribe?
-    admin?
+    !defanged? && admin?
   end
 
   def unsubscribe?
-    admin?
+    !defanged? && admin?
   end
 
   def memorialize?
-    admin? && !record.user&.admin?
+    !defanged? && staff? && !record.user&.staff?
   end
 end
diff --git a/app/policies/account_warning_preset_policy.rb b/app/policies/account_warning_preset_policy.rb
index bccbd33ef..4667c86b0 100644
--- a/app/policies/account_warning_preset_policy.rb
+++ b/app/policies/account_warning_preset_policy.rb
@@ -2,18 +2,18 @@
 
 class AccountWarningPresetPolicy < ApplicationPolicy
   def index?
-    staff?
+    !defanged? && staff?
   end
 
   def create?
-    staff?
+    !defanged? && staff?
   end
 
   def update?
-    staff?
+    !defanged? && staff?
   end
 
   def destroy?
-    staff?
+    !defanged? && staff?
   end
 end
diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb
index d1de5e81a..7b1332209 100644
--- a/app/policies/application_policy.rb
+++ b/app/policies/application_policy.rb
@@ -8,7 +8,7 @@ class ApplicationPolicy
     @record          = record
   end
 
-  delegate :admin?, :moderator?, :staff?, to: :current_user, allow_nil: true
+  delegate :admin?, :moderator?, :halfmod?, :staff?, :can_moderate?, :has_more_authority_than?, to: :current_user, allow_nil: true
 
   private
 
diff --git a/app/policies/custom_emoji_policy.rb b/app/policies/custom_emoji_policy.rb
index 768afc3e9..74a38e47d 100644
--- a/app/policies/custom_emoji_policy.rb
+++ b/app/policies/custom_emoji_policy.rb
@@ -10,7 +10,7 @@ class CustomEmojiPolicy < ApplicationPolicy
   end
 
   def update?
-    staff?
+    can_moderate?
   end
 
   def copy?
@@ -18,11 +18,11 @@ class CustomEmojiPolicy < ApplicationPolicy
   end
 
   def enable?
-    staff?
+    can_moderate?
   end
 
   def disable?
-    staff?
+    can_moderate?
   end
 
   def destroy?
diff --git a/app/policies/domain_block_policy.rb b/app/policies/domain_block_policy.rb
index 0ce6baccf..4cd4d550a 100644
--- a/app/policies/domain_block_policy.rb
+++ b/app/policies/domain_block_policy.rb
@@ -2,22 +2,22 @@
 
 class DomainBlockPolicy < ApplicationPolicy
   def index?
-    staff?
+    !defanged? && staff?
   end
 
   def show?
-    staff?
+    !defanged? && staff?
   end
 
   def create?
-    staff?
+    !defanged? && staff?
   end
 
   def destroy?
-    staff?
+    !defanged? && staff?
   end
 
   def update?
-    staff?
+    !defanged? && staff?
   end
 end
diff --git a/app/policies/email_domain_block_policy.rb b/app/policies/email_domain_block_policy.rb
index 5a75ee183..36d547539 100644
--- a/app/policies/email_domain_block_policy.rb
+++ b/app/policies/email_domain_block_policy.rb
@@ -2,14 +2,14 @@
 
 class EmailDomainBlockPolicy < ApplicationPolicy
   def index?
-    admin?
+    !defanged? && staff?
   end
 
   def create?
-    admin?
+    !defanged? && staff?
   end
 
   def destroy?
-    admin?
+    !defanged? && staff?
   end
 end
diff --git a/app/policies/instance_policy.rb b/app/policies/instance_policy.rb
index a73823556..f63107815 100644
--- a/app/policies/instance_policy.rb
+++ b/app/policies/instance_policy.rb
@@ -2,10 +2,10 @@
 
 class InstancePolicy < ApplicationPolicy
   def index?
-    admin?
+    !defanged? && admin?
   end
 
   def show?
-    admin?
+    !defanged? && admin?
   end
 end
diff --git a/app/policies/invite_policy.rb b/app/policies/invite_policy.rb
index 14236f78b..44fa56049 100644
--- a/app/policies/invite_policy.rb
+++ b/app/policies/invite_policy.rb
@@ -2,7 +2,7 @@
 
 class InvitePolicy < ApplicationPolicy
   def index?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def create?
@@ -10,11 +10,11 @@ class InvitePolicy < ApplicationPolicy
   end
 
   def deactivate_all?
-    admin?
+    !defanged? && admin?
   end
 
   def destroy?
-    owner? || (Setting.min_invite_role == 'admin' ? admin? : staff?)
+    owner? || (!defanged? && (Setting.min_invite_role == 'admin' ? admin? : can_moderate?))
   end
 
   private
diff --git a/app/policies/relay_policy.rb b/app/policies/relay_policy.rb
index bd75e2197..5ad61a16d 100644
--- a/app/policies/relay_policy.rb
+++ b/app/policies/relay_policy.rb
@@ -2,6 +2,6 @@
 
 class RelayPolicy < ApplicationPolicy
   def update?
-    admin?
+    !defanged? && admin?
   end
 end
diff --git a/app/policies/report_note_policy.rb b/app/policies/report_note_policy.rb
index 694bc096b..b6dde2f2b 100644
--- a/app/policies/report_note_policy.rb
+++ b/app/policies/report_note_policy.rb
@@ -2,11 +2,11 @@
 
 class ReportNotePolicy < ApplicationPolicy
   def create?
-    staff?
+    !defanged? && staff?
   end
 
   def destroy?
-    admin? || owner?
+    (!defanged? && admin?) || owner?
   end
 
   private
diff --git a/app/policies/report_policy.rb b/app/policies/report_policy.rb
index 95b5c30c8..6dbd37916 100644
--- a/app/policies/report_policy.rb
+++ b/app/policies/report_policy.rb
@@ -2,14 +2,14 @@
 
 class ReportPolicy < ApplicationPolicy
   def update?
-    staff?
+    !defanged? && staff?
   end
 
   def index?
-    staff?
+    !defanged? && staff?
   end
 
   def show?
-    staff?
+    !defanged? && staff?
   end
 end
diff --git a/app/policies/settings_policy.rb b/app/policies/settings_policy.rb
index 2dcb79f51..3b170f6e2 100644
--- a/app/policies/settings_policy.rb
+++ b/app/policies/settings_policy.rb
@@ -2,10 +2,10 @@
 
 class SettingsPolicy < ApplicationPolicy
   def update?
-    admin?
+    !defanged? && admin?
   end
 
   def show?
-    admin?
+    !defanged? && admin?
   end
 end
diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb
index c573ba7a1..8600183dc 100644
--- a/app/policies/status_policy.rb
+++ b/app/policies/status_policy.rb
@@ -8,7 +8,7 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def index?
-    staff?
+    !defanged? && staff?
   end
 
   def show?
@@ -33,13 +33,13 @@ class StatusPolicy < ApplicationPolicy
   end
 
   def destroy?
-    staff? || owned?
+    (!defanged? && staff?) || owned?
   end
 
   alias unreblog? destroy?
 
   def update?
-    staff?
+    (!defanged? && staff?) || owned?
   end
 
   private
diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb
index c63de01db..935040a21 100644
--- a/app/policies/tag_policy.rb
+++ b/app/policies/tag_policy.rb
@@ -2,14 +2,14 @@
 
 class TagPolicy < ApplicationPolicy
   def index?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def hide?
-    staff?
+    !defanged? && can_moderate?
   end
 
   def unhide?
-    staff?
+    !defanged? && can_moderate?
   end
 end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index d832bff75..aad20f366 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -2,52 +2,52 @@
 
 class UserPolicy < ApplicationPolicy
   def reset_password?
-    staff? && !record.staff?
+    !defanged? && staff? && has_more_authority_than?(record)
   end
 
   def change_email?
-    staff? && !record.staff?
+    !defanged? && staff? && has_more_authority_than?(record)
   end
 
   def disable_2fa?
-    admin? && !record.staff?
+    !defanged? && admin? && has_more_authority_than?(record)
   end
 
   def confirm?
-    staff? && !record.confirmed?
+    !defanged? && staff? && !record.confirmed?
   end
 
   def enable?
-    staff?
+    !defanged? && staff?
   end
 
   def approve?
-    staff? && !record.approved?
+    !defanged? && staff? && !record.approved?
   end
 
   def reject?
-    staff? && !record.approved?
+    !defanged? && staff? && !record.approved?
   end
 
   def disable?
-    staff? && !record.admin?
+    !defanged? && staff? && has_more_authority_than?(record)
   end
 
   def promote?
-    admin? && promoteable?
+    !defanged? && admin? && promoteable?
   end
 
   def demote?
-    admin? && !record.admin? && demoteable?
+    !defanged? && admin? && has_more_authority_than?(record) && demoteable?
   end
 
   private
 
   def promoteable?
-    record.approved? && (!record.staff? || !record.admin?)
+    record.approved? && !record.can_moderate?
   end
 
   def demoteable?
-    record.staff?
+    record.can_moderate?
   end
 end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index e22ebfd4d..5501ea040 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -49,7 +49,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers
       store[:reduce_motion]   = object.current_account.user.setting_reduce_motion
       store[:advanced_layout] = object.current_account.user.setting_advanced_layout
-      store[:is_staff]        = object.current_account.user.staff?
+      store[:is_staff]        = object.current_account.user.staff? && !object.current_account.user.defanged?
       store[:default_content_type] = object.current_account.user.setting_default_content_type
     end
 
diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb
index 43b05964e..28b9fde44 100644
--- a/app/serializers/rest/account_serializer.rb
+++ b/app/serializers/rest/account_serializer.rb
@@ -69,6 +69,7 @@ class REST::AccountSerializer < ActiveModel::Serializer
   def role
     return 'admin' if object.user_admin?
     return 'moderator' if object.user_moderator?
+    return 'halfmod' if object.user_halfmod?
     'user'
   end
 
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index b5c721589..34cd3e99d 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -68,7 +68,7 @@ class NotifyService < BaseService
   end
 
   def from_staff?
-    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff?
+    @notification.from_account.local? && @notification.from_account.user.present? && @notification.from_account.user.staff? && !@notification.from_account.user.defanged?
   end
 
   def optional_non_following_and_direct?
diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml
index 966ee0a91..fd3429d94 100644
--- a/app/views/admin/custom_emojis/_custom_emoji.html.haml
+++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml
@@ -10,21 +10,21 @@
       = link_to custom_emoji.domain, admin_custom_emojis_path(by_domain: custom_emoji.domain)
   %td
     - if custom_emoji.local?
-      - if current_user.staff?
+      - if current_user.can_moderate?
         - if custom_emoji.visible_in_picker
           = table_link_to 'eye', t('admin.custom_emojis.listed'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: false }, page: params[:page], **@filter_params), method: :patch
         - else
           = table_link_to 'eye-slash', t('admin.custom_emojis.unlisted'), admin_custom_emoji_path(custom_emoji, custom_emoji: { visible_in_picker: true }, page: params[:page], **@filter_params), method: :patch
     - else
       - if custom_emoji.local_counterpart.present?
-        - if current_user.staff?
+        - if current_user.can_moderate?
           = link_to safe_join([custom_emoji_tag(custom_emoji.local_counterpart), t('admin.custom_emojis.overwrite')]), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, class: 'table-action-link'
         - else
           %span.table-action-link=""
       - else
         = table_link_to 'copy', t('admin.custom_emojis.copy'), copy_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post
   %td
-    - if current_user.staff?
+    - if current_user.can_moderate?
       - if custom_emoji.disabled?
         = table_link_to 'power-off', t('admin.custom_emojis.enable'), enable_admin_custom_emoji_path(custom_emoji, page: params[:page], **@filter_params), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }
       - else
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index bd1250ebd..1bc581652 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -104,7 +104,7 @@
   %hr.spacer/
 
   .fields-group
-    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
+    = f.input :min_invite_role, wrapper: :with_label, collection: %i(disabled user halfmod moderator admin), label: t('admin.settings.registrations.min_invite_role.title'), label_method: lambda { |role| role == :disabled ? t('admin.settings.registrations.min_invite_role.disabled') : t("admin.accounts.roles.#{role}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
 
   .fields-group
     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 }
diff --git a/app/views/settings/notifications/show.html.haml b/app/views/settings/notifications/show.html.haml
index 6ec57b502..d7de635c6 100644
--- a/app/views/settings/notifications/show.html.haml
+++ b/app/views/settings/notifications/show.html.haml
@@ -12,7 +12,7 @@
       = ff.input :favourite, as: :boolean, wrapper: :with_label
       = ff.input :mention, as: :boolean, wrapper: :with_label
 
-      - if current_user.staff?
+      - if current_user.can_moderate?
         = ff.input :report, as: :boolean, wrapper: :with_label
         = ff.input :pending_account, as: :boolean, wrapper: :with_label
 
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index 4fabfb9f4..ba2fd7495 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -18,6 +18,12 @@
 
       = f.input :avatar, wrapper: :with_label, input_html: { accept: AccountAvatar::IMAGE_MIME_TYPES.join(',') }, hint: t('simple_form.hints.defaults.avatar', dimensions: '400x400', size: number_to_human_size(AccountAvatar::LIMIT))
 
+  - if @account.user_can_moderate?
+    %hr.spacer/
+
+    .fields-group
+      = f.input :user_defanged, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.user_defanged')
+
   %hr.spacer/
 
   .fields-group
diff --git a/app/workers/scheduler/defang_scheduler.rb b/app/workers/scheduler/defang_scheduler.rb
new file mode 100644
index 000000000..a20242a23
--- /dev/null
+++ b/app/workers/scheduler/defang_scheduler.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Scheduler::DefangScheduler
+  include Sidekiq::Worker
+  include ServiceAccountHelper
+
+  def perform
+    User.where(defanged: false, last_fanged_at: nil).or(User.where('last_fanged_at >= ?', 15.minutes.ago)) do
+      |user| user.defang!
+      next unless user&.account.present?
+      service_dm('announcements', user.account, "You are no longer in #{user.role} mode.", footer: 'auto-defang')
+    end
+  end
+end