From ed246f0d03769e40f5632a3d31f94fcc863369e6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?KMY=EF=BC=88=E9=9B=AA=E3=81=82=E3=81=99=E3=81=8B=EF=BC=89?=
 <tt@kmycode.net>
Date: Tue, 19 Mar 2024 08:18:34 +0900
Subject: [PATCH] =?UTF-8?q?Change:=20#648=20=E3=82=BB=E3=83=B3=E3=82=B7?=
 =?UTF-8?q?=E3=83=86=E3=82=A3=E3=83=96=E3=83=AF=E3=83=BC=E3=83=89=E3=81=AE?=
 =?UTF-8?q?=E5=85=A5=E5=8A=9B=E3=83=95=E3=82=A9=E3=83=BC=E3=83=A0=20(#653)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Change: #648 センシティブワードの入力フォーム

* Wip: 行の追加削除

* Wip: 設定の保存、マイグレーション

* 不要な処理を削除

* マイグレーションコード調整
---
 .haml-lint_todo.yml                           |  1 +
 .../admin/sensitive_words_controller.rb       | 14 ++--
 app/javascript/packs/admin.tsx                | 62 ++++++++++++++
 app/javascript/styles/mastodon/forms.scss     |  4 +
 app/models/admin/sensitive_word.rb            | 32 ++------
 app/models/form/admin_settings.rb             |  8 --
 app/models/sensitive_word.rb                  | 81 +++++++++++++++++++
 .../sensitive_words/_sensitive_word.html.haml | 12 +++
 .../admin/sensitive_words/show.html.haml      | 30 +++++--
 config/locales/en.yml                         | 12 +--
 config/locales/ja.yml                         | 13 +--
 .../20240312230204_create_sensitive_words.rb  | 71 ++++++++++++++++
 db/schema.rb                                  | 11 ++-
 spec/fabricators/sensitive_word_fabricator.rb |  5 ++
 spec/lib/activitypub/activity/create_spec.rb  |  3 +-
 spec/models/admin/sensitive_word_spec.rb      | 75 +++++++++++++++++
 .../process_status_update_service_spec.rb     |  4 +-
 17 files changed, 377 insertions(+), 61 deletions(-)
 create mode 100644 app/models/sensitive_word.rb
 create mode 100644 app/views/admin/sensitive_words/_sensitive_word.html.haml
 create mode 100644 db/migrate/20240312230204_create_sensitive_words.rb
 create mode 100644 spec/fabricators/sensitive_word_fabricator.rb
 create mode 100644 spec/models/admin/sensitive_word_spec.rb

diff --git a/.haml-lint_todo.yml b/.haml-lint_todo.yml
index f66a314470..040045566b 100644
--- a/.haml-lint_todo.yml
+++ b/.haml-lint_todo.yml
@@ -29,3 +29,4 @@ linters:
     exclude:
       - 'app/views/application/_sidebar.html.haml'
       - 'app/views/admin/ng_rules/_ng_rule_fields.html.haml'
+      - 'app/views/admin/sensitive_words/_sensitive_word.html.haml'
diff --git a/app/controllers/admin/sensitive_words_controller.rb b/app/controllers/admin/sensitive_words_controller.rb
index f98fc4202d..5fff9394e9 100644
--- a/app/controllers/admin/sensitive_words_controller.rb
+++ b/app/controllers/admin/sensitive_words_controller.rb
@@ -6,6 +6,7 @@ module Admin
       authorize :sensitive_words, :show?
 
       @admin_settings = Form::AdminSettings.new
+      @sensitive_words = ::SensitiveWord.caches.presence || [::SensitiveWord.new]
     end
 
     def create
@@ -21,7 +22,7 @@ module Admin
 
       @admin_settings = Form::AdminSettings.new(settings_params)
 
-      if @admin_settings.save
+      if @admin_settings.save && ::SensitiveWord.save_from_raws(settings_params_test)
         flash[:notice] = I18n.t('generic.changes_saved_msg')
         redirect_to after_update_redirect_path
       else
@@ -32,11 +33,8 @@ module Admin
     private
 
     def test_words
-      sensitive_words = settings_params['sensitive_words'].split(/\r\n|\r|\n/)
-      sensitive_words_for_full = settings_params['sensitive_words_for_full'].split(/\r\n|\r|\n/)
-      sensitive_words_all = settings_params['sensitive_words_all'].split(/\r\n|\r|\n/)
-      sensitive_words_all_for_full = settings_params['sensitive_words_all_for_full'].split(/\r\n|\r|\n/)
-      Admin::NgWord.reject_with_custom_words?('Sample text', sensitive_words + sensitive_words_for_full + sensitive_words_all + sensitive_words_all_for_full)
+      sensitive_words = settings_params_test['keywords'].compact.uniq
+      Admin::NgWord.reject_with_custom_words?('Sample text', sensitive_words)
     end
 
     def after_update_redirect_path
@@ -46,5 +44,9 @@ module Admin
     def settings_params
       params.require(:form_admin_settings).permit(*Form::AdminSettings::KEYS)
     end
+
+    def settings_params_test
+      params.require(:form_admin_settings)[:sensitive_words_test]
+    end
   end
 end
diff --git a/app/javascript/packs/admin.tsx b/app/javascript/packs/admin.tsx
index a9a99d59e8..206c872b4a 100644
--- a/app/javascript/packs/admin.tsx
+++ b/app/javascript/packs/admin.tsx
@@ -272,6 +272,68 @@ Rails.delegate(
   },
 );
 
+const addTableRow = (tableId: string) => {
+  const templateElement = document.querySelector(`#${tableId} .template-row`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
+  const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
+
+  if (
+    typeof templateElement === 'undefined' ||
+    typeof tableElement === 'undefined'
+  )
+    return;
+
+  let temporaryId = 0;
+  tableElement
+    .querySelectorAll<HTMLInputElement>('.temporary_id')
+    .forEach((input) => {
+      if (parseInt(input.value) + 1 > temporaryId) {
+        temporaryId = parseInt(input.value) + 1;
+      }
+    });
+
+  const cloned = templateElement.cloneNode(true) as HTMLTableRowElement;
+  cloned.className = '';
+  cloned.querySelector<HTMLInputElement>('.temporary_id')!.value = // eslint-disable-line @typescript-eslint/no-non-null-assertion
+    temporaryId.toString();
+  cloned
+    .querySelectorAll<HTMLInputElement>('input[type=checkbox]')
+    .forEach((input) => {
+      input.value = temporaryId.toString();
+    });
+  tableElement.appendChild(cloned);
+};
+
+const removeTableRow = (target: EventTarget | null, tableId: string) => {
+  const tableRowElement = (target as HTMLElement).closest('tr') as Node;
+  const tableElement = document.querySelector(`#${tableId} tbody`)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
+
+  if (
+    typeof tableRowElement === 'undefined' ||
+    typeof tableElement === 'undefined'
+  )
+    return;
+
+  tableElement.removeChild(tableRowElement);
+};
+
+Rails.delegate(
+  document,
+  '#sensitive-words-table .add-row-button',
+  'click',
+  () => {
+    addTableRow('sensitive-words-table');
+  },
+);
+
+Rails.delegate(
+  document,
+  '#sensitive-words-table .delete-row-button',
+  'click',
+  ({ target }) => {
+    removeTableRow(target, 'sensitive-words-table');
+  },
+);
+
 async function mountReactComponent(element: Element) {
   const componentName = element.getAttribute('data-admin-component');
   const stringProps = element.getAttribute('data-props');
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 90bf3f80c0..6ebaace499 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -1135,6 +1135,10 @@ code {
     margin-top: 10px;
     white-space: nowrap;
   }
+
+  .template-row {
+    display: none;
+  }
 }
 
 .progress-tracker {
diff --git a/app/models/admin/sensitive_word.rb b/app/models/admin/sensitive_word.rb
index 8f9421f5b9..a766a3174d 100644
--- a/app/models/admin/sensitive_word.rb
+++ b/app/models/admin/sensitive_word.rb
@@ -5,12 +5,12 @@ class Admin::SensitiveWord
     def sensitive?(text, spoiler_text, local: true)
       exposure_text = spoiler_text.presence || text
 
-      sensitive = (spoiler_text.blank? && sensitive_words_all.any? { |word| include?(text, word) }) ||
-                  sensitive_words_all_for_full.any? { |word| include?(exposure_text, word) }
-      return sensitive if sensitive || !local
+      sensitive_words = ::SensitiveWord.caches
+      sensitive_words.select!(&:remote) unless local
 
-      (spoiler_text.blank? && sensitive_words.any? { |word| include?(text, word) }) ||
-        sensitive_words_for_full.any? { |word| include?(exposure_text, word) }
+      return sensitive_words.filter(&:spoiler).any? { |word| include?(spoiler_text, word) } if spoiler_text.present?
+
+      sensitive_words.any? { |word| include?(exposure_text, word) }
     end
 
     def modified_text(text, spoiler_text)
@@ -24,27 +24,11 @@ class Admin::SensitiveWord
     private
 
     def include?(text, word)
-      if word.start_with?('?') && word.size >= 2
-        text =~ /#{word[1..]}/i
+      if word.regexp
+        text =~ /#{word.keyword}/
       else
-        text.include?(word)
+        text.include?(word.keyword)
       end
     end
-
-    def sensitive_words
-      Setting.sensitive_words || []
-    end
-
-    def sensitive_words_for_full
-      Setting.sensitive_words_for_full || []
-    end
-
-    def sensitive_words_all
-      Setting.sensitive_words_all || []
-    end
-
-    def sensitive_words_all_for_full
-      Setting.sensitive_words_all_for_full || []
-    end
   end
 end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index 8a823fa1b7..15c29b759d 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -51,10 +51,6 @@ class Form::AdminSettings
     post_hash_tags_max
     post_mentions_max
     post_stranger_mentions_max
-    sensitive_words
-    sensitive_words_for_full
-    sensitive_words_all
-    sensitive_words_all_for_full
     auto_warning_text
     authorized_fetch
     receive_other_servers_emoji_reaction
@@ -128,10 +124,6 @@ class Form::AdminSettings
   STRING_ARRAY_KEYS = %i(
     ng_words
     ng_words_for_stranger_mention
-    sensitive_words
-    sensitive_words_for_full
-    sensitive_words_all
-    sensitive_words_all_for_full
     emoji_reaction_disallow_domains
     permit_new_account_domains
   ).freeze
diff --git a/app/models/sensitive_word.rb b/app/models/sensitive_word.rb
new file mode 100644
index 0000000000..f88680aaae
--- /dev/null
+++ b/app/models/sensitive_word.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: sensitive_words
+#
+#  id         :bigint(8)        not null, primary key
+#  keyword    :string           not null
+#  regexp     :boolean          default(FALSE), not null
+#  remote     :boolean          default(FALSE), not null
+#  spoiler    :boolean          default(TRUE), not null
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class SensitiveWord < ApplicationRecord
+  attr_accessor :keywords, :regexps, :remotes, :spoilers
+
+  class << self
+    def caches
+      Rails.cache.fetch('sensitive_words') { SensitiveWord.where.not(id: 0).order(:keyword).to_a }
+    end
+
+    def save_from_hashes(rows)
+      unmatched = caches
+      matched = []
+
+      SensitiveWord.transaction do
+        rows.filter { |item| item[:keyword].present? }.each do |item|
+          exists = unmatched.find { |i| i.keyword == item[:keyword] }
+
+          if exists.present?
+            unmatched.delete(exists)
+            matched << exists
+
+            next if exists.regexp == item[:regexp] && exists.remote == item[:remote] && exists.spoiler == item[:spoiler]
+
+            exists.update!(regexp: item[:regexp], remote: item[:remote], spoiler: item[:spoiler])
+          elsif matched.none? { |i| i.keyword == item[:keyword] }
+            SensitiveWord.create!(
+              keyword: item[:keyword],
+              regexp: item[:regexp],
+              remote: item[:remote],
+              spoiler: item[:spoiler]
+            )
+          end
+        end
+
+        SensitiveWord.destroy(unmatched.map(&:id))
+      end
+
+      true
+      # rescue
+      # false
+    end
+
+    def save_from_raws(rows)
+      regexps = rows['regexps'] || []
+      remotes = rows['remotes'] || []
+      spoilers = rows['spoilers'] || []
+
+      hashes = (rows['keywords'] || []).zip(rows['temporary_ids'] || []).map do |item|
+        temp_id = item[1]
+        {
+          keyword: item[0],
+          regexp: regexps.include?(temp_id),
+          remote: remotes.include?(temp_id),
+          spoiler: spoilers.include?(temp_id),
+        }
+      end
+
+      save_from_hashes(hashes)
+    end
+  end
+
+  private
+
+  def invalidate_cache!
+    Rails.cache.delete('sensitive_words')
+  end
+end
diff --git a/app/views/admin/sensitive_words/_sensitive_word.html.haml b/app/views/admin/sensitive_words/_sensitive_word.html.haml
new file mode 100644
index 0000000000..de9c65714e
--- /dev/null
+++ b/app/views/admin/sensitive_words/_sensitive_word.html.haml
@@ -0,0 +1,12 @@
+- temporary_id = defined?(@temp_id) ? @temp_id += 1 : @temp_id = 1
+%tr{ class: template ? 'template-row' : nil }
+  %td= f.input :keywords, as: :string, input_html: { multiple: true, value: sensitive_word.keyword }
+  %td
+    .label_input__wrapper= f.check_box :regexps, { multiple: true, checked: sensitive_word.regexp }, temporary_id, nil
+  %td
+    .label_input__wrapper= f.check_box :remotes, { multiple: true, checked: sensitive_word.remote }, temporary_id, nil
+  %td
+    .label_input__wrapper= f.check_box :spoilers, { multiple: true, checked: sensitive_word.spoiler }, temporary_id, nil
+  %td
+    = hidden_field_tag :'form_admin_settings[sensitive_words_test][temporary_ids][]', temporary_id, class: 'temporary_id'
+    = link_to safe_join([fa_icon('times'), t('filters.index.delete')]), '#', class: 'table-action-link delete-row-button'
diff --git a/app/views/admin/sensitive_words/show.html.haml b/app/views/admin/sensitive_words/show.html.haml
index e9a896532e..6f51419cc1 100644
--- a/app/views/admin/sensitive_words/show.html.haml
+++ b/app/views/admin/sensitive_words/show.html.haml
@@ -9,17 +9,31 @@
 
   %p.lead= t 'admin.sensitive_words.hint'
 
-  .fields-group
-    = f.input :sensitive_words_for_full, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint')
+  %p= t 'admin.sensitive_words.phrases.regexp_html'
+  %p= t 'admin.sensitive_words.phrases.remote_html'
+  %p= t 'admin.sensitive_words.phrases.spoiler_html'
 
-  .fields-group
-    = f.input :sensitive_words, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords'), hint: t('admin.sensitive_words.keywords_hint')
+  %hr/
 
-  .fields-group
-    = f.input :sensitive_words_all_for_full, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords_all_for_all'), hint: t('admin.sensitive_words.keywords_for_all_hint')
+  .table-wrapper
+    %table.table.keywords-table#sensitive-words-table
+      %thead
+        %tr
+          %th= t('simple_form.labels.defaults.phrase')
+          %th= t('admin.sensitive_words.phrases.regexp_short')
+          %th= t('admin.sensitive_words.phrases.remote_short')
+          %th= t('admin.sensitive_words.phrases.spoiler_short')
+          %th
+      %tbody
+        = f.simple_fields_for :sensitive_words_test, @sensitive_words do |keyword|
+          = render partial: 'sensitive_word', collection: @sensitive_words, locals: { f: keyword, template: false }
 
-  .fields-group
-    = f.input :sensitive_words_all, wrapper: :with_label, as: :text, input_html: { rows: 8 }, label: t('admin.sensitive_words.keywords_all'), hint: t('admin.sensitive_words.keywords_hint')
+        = f.simple_fields_for :sensitive_words_test, @sensitive_words do |keyword|
+          = render partial: 'sensitive_word', collection: [SensitiveWord.new], locals: { f: keyword, template: true }
+      %tfoot
+        %tr
+          %td{ colspan: 4 }
+            = link_to safe_join([fa_icon('plus'), t('filters.edit.add_keyword')]), '#', class: 'table-action-link add-row-button'
 
   .fields-group
     = f.input :auto_warning_text, wrapper: :with_label, input_html: { placeholder: t('admin.sensitive_words.alert') }, label: t('admin.sensitive_words.auto_warning_text'), hint: t('admin.sensitive_words.auto_warning_text_hint')
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 4748200f55..ef20d689c6 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -963,11 +963,13 @@ en:
       auto_warning_text: Custom warning text
       auto_warning_text_hint: If not specified, the default warning text is used.
       hint: This keywords is applied to public posts only..
-      keywords: Sensitive keywords for local only
-      keywords_all: Sensitive keywords for local+remote
-      keywords_all_for_all: Sensitive keywords for local+remote (Contains CW alert)
-      keywords_for_all: Sensitive keywords for local only (Contains CW alert)
-      keywords_for_all_hint: The first character of the line is "?". to use regular expressions
+      phrases:
+        remote_html: <strong>Rem</strong> ote にチェックの入っている項目は、リモートからの投稿にも適用されます。
+        remote_short: Rem
+        regexp_html: <strong>Reg</strong> Exp にチェックの入っている項目は、正規表現を用いての比較となります。
+        regexp_short: Reg
+        spoiler_html: <strong>War</strong> ning にチェックの入っている項目は、コンテンツ警告文にも適用されます。
+        spoiler_short: War
       title: Sensitive words and moderation options
     settings:
       about:
diff --git a/config/locales/ja.yml b/config/locales/ja.yml
index f7e1fb855d..b1b5b4ca5f 100644
--- a/config/locales/ja.yml
+++ b/config/locales/ja.yml
@@ -959,12 +959,13 @@ ja:
       auto_warning_text: カスタム警告文
       auto_warning_text_hint: 指定しなかった場合は、各言語のデフォルト警告文が使用されます
       hint: センシティブなキーワードの設定は、当サーバーのローカルユーザーによる公開範囲「公開」「ローカル公開」「ログインユーザーのみ」に対して適用されます。
-      keywords: ローカルの投稿に適用するセンシティブなキーワード(警告文は除外)
-      keywords_all: ローカル・リモートの投稿に適用するセンシティブなキーワード(警告文は除外)
-      keywords_all_for_all: ローカル・リモートの投稿に適用するセンシティブなキーワード(警告文にも適用)
-      keywords_for_all: ローカルの投稿に適用するセンシティブなキーワード(警告文にも適用)
-      keywords_for_all_hint: ここで指定したキーワードを含む投稿は強制的にCWになります。警告文にも含まれていればCWになります。行が「?」で始まっていれば正規表現が使えます
-      keywords_hint: ここで指定したキーワードを含む投稿は強制的にCWになります。ただし警告文に使用していた場合は無視されます
+      phrases:
+        remote_html: <strong>リモ</strong> ート にチェックの入っている項目は、リモートからの投稿にも適用されます。
+        remote_short: リモ
+        regexp_html: <strong>正規</strong> 表現 にチェックの入っている項目は、正規表現を用いての比較となります。
+        regexp_short: 正規
+        spoiler_html: <strong>警告</strong> 文 にチェックの入っている項目は、コンテンツ警告文にも適用されます。
+        spoiler_short: 警告
       title: センシティブ単語と設定
     settings:
       about:
diff --git a/db/migrate/20240312230204_create_sensitive_words.rb b/db/migrate/20240312230204_create_sensitive_words.rb
new file mode 100644
index 0000000000..a33f492088
--- /dev/null
+++ b/db/migrate/20240312230204_create_sensitive_words.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class CreateSensitiveWords < ActiveRecord::Migration[7.1]
+  class Setting < ApplicationRecord
+    def value
+      YAML.safe_load(self[:value], permitted_classes: [ActiveSupport::HashWithIndifferentAccess, Symbol]) if self[:value].present?
+    end
+
+    def value=(new_value)
+      self[:value] = new_value.to_yaml
+    end
+  end
+
+  class SensitiveWord < ApplicationRecord; end
+
+  def normalized_keyword(keyword)
+    if regexp?(keyword)
+      keyword[1..]
+    else
+      keyword
+    end
+  end
+
+  def regexp?(keyword)
+    keyword.start_with?('?') && keyword.size >= 2
+  end
+
+  def up
+    create_table :sensitive_words do |t|
+      t.string :keyword, null: false
+      t.boolean :regexp, null: false, default: false
+      t.boolean :remote, null: false, default: false
+      t.boolean :spoiler, null: false, default: true
+
+      t.timestamps
+    end
+
+    settings = Setting.where(var: %i(sensitive_words sensitive_words_for_full sensitive_words_all sensitive_words_all_for_full))
+    sensitive_words = settings.find { |s| s.var == 'sensitive_words' }&.value&.compact_blank&.uniq || []
+    sensitive_words_for_full = settings.find { |s| s.var == 'sensitive_words_for_full' }&.value&.compact_blank&.uniq || []
+    sensitive_words_all = settings.find { |s| s.var == 'sensitive_words_all' }&.value&.compact_blank&.uniq || []
+    sensitive_words_all_for_full = settings.find { |s| s.var == 'sensitive_words_all_for_full' }&.value&.compact_blank&.uniq || []
+
+    (sensitive_words + sensitive_words_for_full + sensitive_words_all + sensitive_words_all_for_full).compact.uniq.each do |word|
+      SensitiveWord.create!(
+        keyword: normalized_keyword(word),
+        regexp: regexp?(word),
+        remote: (sensitive_words_all + sensitive_words_all_for_full).include?(word),
+        spoiler: (sensitive_words_for_full + sensitive_words_all_for_full).include?(word)
+      )
+    end
+
+    settings.destroy_all
+  end
+
+  def down
+    sensitive_words = SensitiveWord.where(remote: false, spoiler: false).map { |s| s.regexp ? "?#{s.keyword}" : s.keyword }
+    sensitive_words_for_full = SensitiveWord.where(remote: false, spoiler: true).map { |s| s.regexp ? "?#{s.keyword}" : s.keyword }
+    sensitive_words_all = SensitiveWord.where(remote: true, spoiler: false).map { |s| s.regexp ? "?#{s.keyword}" : s.keyword }
+    sensitive_words_all_for_full = SensitiveWord.where(remote: true, spoiler: true).map { |s| s.regexp ? "?#{s.keyword}" : s.keyword }
+
+    Setting.where(var: %i(sensitive_words sensitive_words_for_full sensitive_words_all sensitive_words_all_for_full)).destroy_all
+
+    Setting.new(var: :sensitive_words).tap { |s| s.value = sensitive_words }.save!
+    Setting.new(var: :sensitive_words_for_full).tap { |s| s.value = sensitive_words_for_full }.save!
+    Setting.new(var: :sensitive_words_all).tap { |s| s.value = sensitive_words_all }.save!
+    Setting.new(var: :sensitive_words_all_for_full).tap { |s| s.value = sensitive_words_all_for_full }.save!
+
+    drop_table :sensitive_words
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index b9bca2c02f..e90f1af244 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do
+ActiveRecord::Schema[7.1].define(version: 2024_03_12_230204) do
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
 
@@ -1233,6 +1233,15 @@ ActiveRecord::Schema[7.1].define(version: 2024_03_10_123453) do
     t.index ["scheduled_at"], name: "index_scheduled_statuses_on_scheduled_at"
   end
 
+  create_table "sensitive_words", force: :cascade do |t|
+    t.string "keyword", null: false
+    t.boolean "regexp", default: false, null: false
+    t.boolean "remote", default: false, null: false
+    t.boolean "spoiler", default: true, null: false
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+  end
+
   create_table "session_activations", force: :cascade do |t|
     t.string "session_id", null: false
     t.datetime "created_at", precision: nil, null: false
diff --git a/spec/fabricators/sensitive_word_fabricator.rb b/spec/fabricators/sensitive_word_fabricator.rb
new file mode 100644
index 0000000000..0339f015b9
--- /dev/null
+++ b/spec/fabricators/sensitive_word_fabricator.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+Fabricator(:sensitive_word) do
+  keyword { sequence(:keyword) { |i| "keyword_#{i}" } }
+end
diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb
index 7c91f55203..414b967380 100644
--- a/spec/lib/activitypub/activity/create_spec.rb
+++ b/spec/lib/activitypub/activity/create_spec.rb
@@ -2071,7 +2071,8 @@ RSpec.describe ActivityPub::Activity::Create do
         end
 
         before do
-          Form::AdminSettings.new(sensitive_words_all: sensitive_words_all, sensitive_words: 'ipsum').save
+          Fabricate(:sensitive_word, keyword: sensitive_words_all, remote: true, spoiler: false) if sensitive_words_all.present?
+          Fabricate(:sensitive_word, keyword: 'ipsum')
           subject.perform
         end
 
diff --git a/spec/models/admin/sensitive_word_spec.rb b/spec/models/admin/sensitive_word_spec.rb
new file mode 100644
index 0000000000..b07127dafa
--- /dev/null
+++ b/spec/models/admin/sensitive_word_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Admin::SensitiveWord do
+  describe '#sensitive?' do
+    subject { described_class.sensitive?(text, spoiler_text, local: local) }
+
+    let(:text) { 'This is a ohagi.' }
+    let(:spoiler_text) { '' }
+    let(:local) { true }
+
+    context 'when a local post' do
+      it 'local word hits' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', remote: false)
+        expect(subject).to be true
+      end
+
+      it 'remote word hits' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', remote: true)
+        expect(subject).to be true
+      end
+    end
+
+    context 'when a remote post' do
+      let(:local) { false }
+
+      it 'local word does not hit' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', remote: false)
+        expect(subject).to be false
+      end
+
+      it 'remote word hits' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', remote: true)
+        expect(subject).to be true
+      end
+    end
+
+    context 'when using regexp' do
+      it 'regexp hits with enable' do
+        Fabricate(:sensitive_word, keyword: 'oha[ghi]i', regexp: true)
+        expect(subject).to be true
+      end
+
+      it 'regexp does not hit without enable' do
+        Fabricate(:sensitive_word, keyword: 'oha[ghi]i', regexp: false)
+        expect(subject).to be false
+      end
+    end
+
+    context 'when spoiler text is set' do
+      let(:spoiler_text) { 'amy' }
+
+      it 'sensitive word in content is escaped' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', spoiler: false)
+        expect(subject).to be false
+      end
+
+      it 'sensitive word in content is escaped even if spoiler is true' do
+        Fabricate(:sensitive_word, keyword: 'ohagi', spoiler: true)
+        expect(subject).to be false
+      end
+
+      it 'non-spoiler word does not hit' do
+        Fabricate(:sensitive_word, keyword: 'amy', spoiler: false)
+        expect(subject).to be false
+      end
+
+      it 'spoiler word hits' do
+        Fabricate(:sensitive_word, keyword: 'amy', spoiler: true)
+        expect(subject).to be true
+      end
+    end
+  end
+end
diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb
index 13b93a8acf..5df7a2c96c 100644
--- a/spec/services/activitypub/process_status_update_service_spec.rb
+++ b/spec/services/activitypub/process_status_update_service_spec.rb
@@ -758,7 +758,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
         let(:content) { 'ng word aiueo' }
 
         it 'update status' do
-          Form::AdminSettings.new(sensitive_words_all: 'test').save
+          Fabricate(:sensitive_word, keyword: 'test', remote: true, spoiler: false)
 
           subject.call(status, json, json)
           expect(status.reload.text).to eq content
@@ -770,7 +770,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
         let(:content) { 'ng word test' }
 
         it 'update status' do
-          Form::AdminSettings.new(sensitive_words_all: 'test').save
+          Fabricate(:sensitive_word, keyword: 'test', remote: true, spoiler: false)
 
           subject.call(status, json, json)
           expect(status.reload.text).to eq content