diff --git a/app/controllers/admin/instances/moderation_notes_controller.rb b/app/controllers/admin/instances/moderation_notes_controller.rb new file mode 100644 index 000000000..635c09734 --- /dev/null +++ b/app/controllers/admin/instances/moderation_notes_controller.rb @@ -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 diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index a48c4773e..6ab4acab9 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -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 diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index bc51163f1..3709a4f50 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -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; diff --git a/app/models/concerns/account/associations.rb b/app/models/concerns/account/associations.rb index cafb2d151..62c55da5d 100644 --- a/app/models/concerns/account/associations.rb +++ b/app/models/concerns/account/associations.rb @@ -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' diff --git a/app/models/instance.rb b/app/models/instance.rb index 3bd4b924a..01d258281 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -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)) } diff --git a/app/models/instance_moderation_note.rb b/app/models/instance_moderation_note.rb new file mode 100644 index 000000000..fedb571fb --- /dev/null +++ b/app/models/instance_moderation_note.rb @@ -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 diff --git a/app/policies/instance_moderation_note_policy.rb b/app/policies/instance_moderation_note_policy.rb new file mode 100644 index 000000000..f99a25fb5 --- /dev/null +++ b/app/policies/instance_moderation_note_policy.rb @@ -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 diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index 812a9c887..c977011e1 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -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') diff --git a/app/views/admin/report_notes/_report_note.html.haml b/app/views/admin/report_notes/_report_note.html.haml index 9c8e267d6..7a51fdab2 100644 --- a/app/views/admin/report_notes/_report_note.html.haml +++ b/app/views/admin/report_notes/_report_note.html.haml @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index 5fcdbe4c9..6633ffa4a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/config/routes/admin.rb b/config/routes/admin.rb index e7439f66b..3d9f24ae8 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -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 diff --git a/db/migrate/20250328153843_create_instance_moderation_notes.rb b/db/migrate/20250328153843_create_instance_moderation_notes.rb new file mode 100644 index 000000000..eff0ad3bd --- /dev/null +++ b/db/migrate/20250328153843_create_instance_moderation_notes.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index b7a8154f9..1064d8862 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/spec/fabricators/instance_moderation_note_fabricator.rb b/spec/fabricators/instance_moderation_note_fabricator.rb new file mode 100644 index 000000000..6a8fd3dc6 --- /dev/null +++ b/spec/fabricators/instance_moderation_note_fabricator.rb @@ -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 diff --git a/spec/models/instance_moderation_note_spec.rb b/spec/models/instance_moderation_note_spec.rb new file mode 100644 index 000000000..4d77d4971 --- /dev/null +++ b/spec/models/instance_moderation_note_spec.rb @@ -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 diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb index 3e811d332..f1035816d 100644 --- a/spec/models/instance_spec.rb +++ b/spec/models/instance_spec.rb @@ -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' } diff --git a/spec/requests/admin/instances/moderation_notes_spec.rb b/spec/requests/admin/instances/moderation_notes_spec.rb new file mode 100644 index 000000000..18c9464bb --- /dev/null +++ b/spec/requests/admin/instances/moderation_notes_spec.rb @@ -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 diff --git a/spec/requests/admin/instances_spec.rb b/spec/requests/admin/instances_spec.rb index afb6602a2..651f0bb6c 100644 --- a/spec/requests/admin/instances_spec.rb +++ b/spec/requests/admin/instances_spec.rb @@ -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 diff --git a/spec/system/admin/account_moderation_notes_spec.rb b/spec/system/admin/account_moderation_notes_spec.rb index fa930cea2..728d17147 100644 --- a/spec/system/admin/account_moderation_notes_spec.rb +++ b/spec/system/admin/account_moderation_notes_spec.rb @@ -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 diff --git a/spec/system/admin/instance/moderation_notes_spec.rb b/spec/system/admin/instance/moderation_notes_spec.rb new file mode 100644 index 000000000..f8bf575b8 --- /dev/null +++ b/spec/system/admin/instance/moderation_notes_spec.rb @@ -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