Implement Instance Moderation Notes (#31529)

This commit is contained in:
Emelia Smith 2025-06-25 10:15:44 +02:00 committed by GitHub
parent 0f9f27972d
commit 72f2f35bfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 295 additions and 15 deletions

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
class Admin::Instances::ModerationNotesController < Admin::BaseController
before_action :set_instance, only: [:create]
before_action :set_instance_note, only: [:destroy]
def create
authorize :instance_moderation_note, :create?
@instance_moderation_note = current_account.instance_moderation_notes.new(content: resource_params[:content], domain: @instance.domain)
if @instance_moderation_note.save
redirect_to admin_instance_path(@instance.domain, anchor: helpers.dom_id(@instance_moderation_note)), notice: I18n.t('admin.instances.moderation_notes.created_msg')
else
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(5)
render 'admin/instances/show'
end
end
def destroy
authorize @instance_moderation_note, :destroy?
@instance_moderation_note.destroy!
redirect_to admin_instance_path(@instance_moderation_note.domain, anchor: 'instance-notes'), notice: I18n.t('admin.instances.moderation_notes.destroyed_msg')
end
private
def resource_params
params
.expect(instance_moderation_note: [:content])
end
def set_instance
domain = params[:instance_id]&.strip
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
end
def set_instance_note
@instance_moderation_note = InstanceModerationNote.find(params[:id])
end
end

View File

@ -14,6 +14,9 @@ module Admin
def show
authorize :instance, :show?
@instance_moderation_note = @instance.moderation_notes.new
@instance_moderation_notes = @instance.moderation_notes.includes(:account).chronological
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
@action_logs = Admin::ActionLogFilter.new(target_domain: @instance.domain).results.limit(LOGS_LIMIT)
end
@ -52,7 +55,8 @@ module Admin
private
def set_instance
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
domain = params[:id]&.strip
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
end
def set_instances

View File

@ -1632,6 +1632,17 @@ a.sparkline {
}
}
a.timestamp {
color: $darker-text-color;
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
time {
margin-inline-start: 5px;
vertical-align: baseline;

View File

@ -18,6 +18,7 @@ module Account::Associations
has_many :favourites
has_many :featured_tags, -> { includes(:tag) }
has_many :list_accounts
has_many :instance_moderation_notes
has_many :media_attachments
has_many :mentions
has_many :migrations, class_name: 'AccountMigration'

View File

@ -21,6 +21,7 @@ class Instance < ApplicationRecord
belongs_to :unavailable_domain
has_many :accounts, dependent: nil
has_many :moderation_notes, class_name: 'InstanceModerationNote', dependent: :destroy
end
scope :searchable, -> { where.not(domain: DomainBlock.select(:domain)) }

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: instance_moderation_notes
#
# id :bigint(8) not null, primary key
# content :text
# domain :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint(8) not null
#
class InstanceModerationNote < ApplicationRecord
include DomainNormalizable
include DomainMaterializable
CONTENT_SIZE_LIMIT = 2_000
belongs_to :account
belongs_to :instance, inverse_of: :moderation_notes, foreign_key: :domain, primary_key: :domain, optional: true
scope :chronological, -> { reorder(id: :asc) }
validates :content, presence: true, length: { maximum: CONTENT_SIZE_LIMIT }
validates :domain, presence: true, domain: true
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class InstanceModerationNotePolicy < ApplicationPolicy
def create?
role.can?(:manage_federation)
end
def destroy?
owner? || (role.can?(:manage_federation) && role.overrides?(record.account.user_role))
end
private
def owner?
record.account_id == current_account&.id
end
end

View File

@ -6,7 +6,7 @@
= date_range(@time_period)
- if @instance.persisted?
= render 'dashboard', instance_domain: @instance.domain, period_end_at: @time_period.last, period_start_at: @time_period.first
= render 'admin/instances/dashboard', instance_domain: @instance.domain, period_end_at: @time_period.last, period_start_at: @time_period.first
- else
%p
= t('admin.instances.unknown_instance')
@ -55,6 +55,24 @@
= render partial: 'admin/action_logs/action_log', collection: @action_logs
= link_to t('admin.instances.audit_log.view_all'), admin_action_logs_path(target_domain: @instance.domain), class: 'button'
%hr.spacer/
- if @instance.domain.present?
%h3#instance-notes= t('admin.instances.moderation_notes.title')
%p= t('admin.instances.moderation_notes.description_html')
.report-notes
= render partial: 'admin/report_notes/report_note', collection: @instance_moderation_notes
= simple_form_for @instance_moderation_note, url: admin_instance_moderation_notes_path(instance_id: @instance.domain) do |form|
= render 'shared/error_messages', object: @instance_moderation_note
.field-group
= form.input :content, input_html: { placeholder: t('admin.instances.moderation_notes.placeholder'), maxlength: InstanceModerationNote::CONTENT_SIZE_LIMIT, rows: 6, autofocus: @instance_moderation_note.errors.any? }
.actions
= form.button :button, t('admin.instances.moderation_notes.create'), type: :submit
- if @instance.persisted?
%hr.spacer/
%h3= t('admin.instances.availability.title')

View File

@ -1,11 +1,12 @@
.report-notes__item
.report-notes__item{ id: dom_id(report_note) }
= image_tag report_note.account.avatar.url, class: 'report-notes__item__avatar'
.report-notes__item__header
%span.username
= link_to report_note.account.username, admin_account_path(report_note.account_id)
%time.relative-formatted{ datetime: report_note.created_at.iso8601, title: report_note.created_at }
= l report_note.created_at.to_date
%a.timestamp{ href: "##{dom_id(report_note)}" }
%time.relative-formatted{ datetime: report_note.created_at.iso8601, title: report_note.created_at }
= l report_note.created_at.to_date
.report-notes__item__content
= linkify(report_note.content)
@ -14,5 +15,7 @@
.report-notes__item__actions
- if report_note.is_a?(AccountModerationNote)
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_account_moderation_note_path(report_note), method: :delete
- elsif report_note.is_a?(InstanceModerationNote)
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_instance_moderation_note_path(instance_id: report_note.domain, id: report_note.id), method: :delete
- else
= table_link_to 'delete', t('admin.reports.notes.delete'), admin_report_note_path(report_note), method: :delete

View File

@ -578,6 +578,13 @@ en:
all: All
limited: Limited
title: Moderation
moderation_notes:
create: Add Moderation Note
created_msg: Instance moderation note successfully created!
description_html: View and leave notes for other moderators and your future self
destroyed_msg: Instance moderation note successfully deleted!
placeholder: Information about this instance, actions taken, or anything else that will help you moderate this instance in the future.
title: Moderation Notes
private_comment: Private comment
public_comment: Public comment
purge: Purge

View File

@ -91,6 +91,8 @@ namespace :admin do
post :restart_delivery
post :stop_delivery
end
resources :moderation_notes, controller: 'instances/moderation_notes', only: [:create, :destroy]
end
resources :rules, only: [:index, :new, :create, :edit, :update, :destroy] do

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateInstanceModerationNotes < ActiveRecord::Migration[8.0]
def change
create_table :instance_moderation_notes do |t|
t.string :domain, null: false
t.belongs_to :account, foreign_key: { on_delete: :cascade }, index: false, null: false
t.text :content
t.timestamps
t.index ['domain'], name: 'index_instance_moderation_notes_on_domain'
end
end
end

View File

@ -191,8 +191,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
t.boolean "hide_collections"
t.integer "avatar_storage_schema_version"
t.integer "header_storage_schema_version"
t.datetime "sensitized_at", precision: nil
t.integer "suspension_origin"
t.datetime "sensitized_at", precision: nil
t.boolean "trendable"
t.datetime "reviewed_at", precision: nil
t.datetime "requested_review_at", precision: nil
@ -580,6 +580,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
t.index ["user_id"], name: "index_identities_on_user_id"
end
create_table "instance_moderation_notes", force: :cascade do |t|
t.string "domain", null: false
t.bigint "account_id", null: false
t.text "content"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["domain"], name: "index_instance_moderation_notes_on_domain"
end
create_table "invites", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "code", default: "", null: false
@ -595,12 +604,12 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
end
create_table "ip_blocks", force: :cascade do |t|
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.datetime "expires_at", precision: nil
t.text "comment", default: "", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.datetime "expires_at", precision: nil
t.inet "ip", default: "0.0.0.0", null: false
t.integer "severity", default: 0, null: false
t.text "comment", default: "", null: false
t.index ["ip"], name: "index_ip_blocks_on_ip", unique: true
end
@ -1372,6 +1381,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_06_05_110215) do
add_foreign_key "follows", "accounts", name: "fk_32ed1b5560", on_delete: :cascade
add_foreign_key "generated_annual_reports", "accounts"
add_foreign_key "identities", "users", name: "fk_bea040f377", on_delete: :cascade
add_foreign_key "instance_moderation_notes", "accounts", on_delete: :cascade
add_foreign_key "invites", "users", on_delete: :cascade
add_foreign_key "list_accounts", "accounts", on_delete: :cascade
add_foreign_key "list_accounts", "follow_requests", on_delete: :cascade

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
Fabricator(:instance_moderation_note) do
domain { sequence(:domain) { |i| "#{i}#{Faker::Internet.domain_name}" } }
account { Fabricate.build(:account) }
content { Faker::Lorem.sentence }
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InstanceModerationNote do
describe 'chronological' do
it 'returns the instance notes sorted by oldest first' do
instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain('mastodon.example'))
note1 = Fabricate(:instance_moderation_note, domain: instance.domain)
note2 = Fabricate(:instance_moderation_note, domain: instance.domain)
expect(instance.moderation_notes.chronological).to eq [note1, note2]
end
end
describe 'validations' do
it 'is invalid if the content is empty' do
note = Fabricate.build(:instance_moderation_note, domain: 'mastodon.example', content: '')
expect(note.valid?).to be false
end
it 'is invalid if content is longer than character limit' do
note = Fabricate.build(:instance_moderation_note, domain: 'mastodon.example', content: comment_over_limit)
expect(note.valid?).to be false
end
it 'is valid even if the instance does not exist yet' do
note = Fabricate.build(:instance_moderation_note, domain: 'non-existent.example', content: 'test comment')
expect(note.valid?).to be true
end
def comment_over_limit
Faker::Lorem.paragraph_by_chars(number: described_class::CONTENT_SIZE_LIMIT * 2)
end
end
end

View File

@ -3,9 +3,9 @@
require 'rails_helper'
RSpec.describe Instance do
describe 'Scopes' do
before { described_class.refresh }
before { described_class.refresh }
describe 'Scopes' do
describe '#searchable' do
let(:expected_domain) { 'host.example' }
let(:blocked_domain) { 'other.example' }

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin Report Notes' do
describe 'POST /admin/instance/moderation_notes' do
before { sign_in Fabricate(:admin_user) }
it 'gracefully handles invalid nested params' do
post admin_instance_moderation_notes_path(instance_id: 'mastodon.test', instance_note: 'invalid')
expect(response)
.to have_http_status(400)
end
end
end

View File

@ -4,9 +4,9 @@ require 'rails_helper'
RSpec.describe 'Admin Instances' do
describe 'GET /admin/instances/:id' do
context 'with an unknown domain' do
before { sign_in Fabricate(:admin_user) }
before { sign_in Fabricate(:admin_user) }
context 'with an unknown domain' do
it 'returns http success' do
get admin_instance_path(id: 'unknown.example')
@ -14,5 +14,14 @@ RSpec.describe 'Admin Instances' do
.to have_http_status(200)
end
end
context 'with an invalid domain' do
it 'returns http success' do
get admin_instance_path(id: ' ')
expect(response)
.to have_http_status(200)
end
end
end
end

View File

@ -35,7 +35,7 @@ RSpec.describe 'Admin::AccountModerationNotes' do
end
def delete_note
within('.report-notes__item__actions') do
within('.report-notes__item:first-child .report-notes__item__actions') do
click_on I18n.t('admin.reports.notes.delete')
end
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Admin::Instances::ModerationNotesController' do
let(:current_user) { Fabricate(:admin_user) }
let(:instance_domain) { 'mastodon.example' }
before { sign_in current_user }
describe 'Managing instance moderation notes' do
it 'saves and then deletes a record' do
visit admin_instance_path(instance_domain)
fill_in 'instance_moderation_note_content', with: ''
expect { submit_form }
.to not_change(InstanceModerationNote, :count)
expect(page)
.to have_content(/error below/)
fill_in 'instance_moderation_note_content', with: 'Test message ' * InstanceModerationNote::CONTENT_SIZE_LIMIT
expect { submit_form }
.to not_change(InstanceModerationNote, :count)
expect(page)
.to have_content(/error below/)
fill_in 'instance_moderation_note_content', with: 'Test message'
expect { submit_form }
.to change(InstanceModerationNote, :count).by(1)
expect(page)
.to have_current_path(admin_instance_path(instance_domain))
expect(page)
.to have_content(I18n.t('admin.instances.moderation_notes.created_msg'))
expect { delete_note }
.to change(InstanceModerationNote, :count).by(-1)
expect(page)
.to have_content(I18n.t('admin.instances.moderation_notes.destroyed_msg'))
end
def submit_form
click_on I18n.t('admin.instances.moderation_notes.create')
end
def delete_note
within('.report-notes__item:first-child .report-notes__item__actions') do
click_on I18n.t('admin.reports.notes.delete')
end
end
end
end