moderate 1.0.0.beta1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +96 -1
- data/.simplecov +2 -10
- data/AGENTS.md +1 -1
- data/Appraisals +10 -8
- data/CHANGELOG.md +660 -20
- data/CLAUDE.md +1 -1
- data/README.md +149 -7
- data/Rakefile +2 -0
- data/app/controllers/concerns/moderate/moderation.rb +23 -11
- data/app/controllers/moderate/appeals_controller.rb +9 -5
- data/app/controllers/moderate/application_controller.rb +3 -3
- data/app/controllers/moderate/notices_controller.rb +3 -6
- data/app/helpers/moderate/engine_helper.rb +32 -11
- data/app/views/moderate/appeals/new.html.erb +6 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
- data/app/views/moderate/transparency_reports/show.html.erb +5 -5
- data/config/locales/en.yml +200 -0
- data/config/locales/es.yml +177 -0
- data/context7.json +4 -0
- data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
- data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
- data/docs/chats.md +140 -0
- data/docs/compliance.md +92 -3
- data/docs/configuration.md +30 -2
- data/docs/dsa-notice-form.md +1 -1
- data/docs/images/moderate-user-report-block-actions.webp +0 -0
- data/docs/madmin.md +44 -14
- data/docs/notifications.md +17 -4
- data/examples/aws_rekognition_adapter.rb +6 -6
- data/examples/openai_moderation_adapter.rb +1 -1
- data/examples/regexp_pattern_adapter.rb +68 -0
- data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
- data/gemfiles/rails_8.1.gemfile +6 -5
- data/lib/generators/moderate/install_generator.rb +25 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
- data/lib/generators/moderate/templates/initializer.rb +9 -15
- data/lib/generators/moderate/views_generator.rb +3 -3
- data/lib/moderate/attachments.rb +137 -0
- data/lib/moderate/configuration.rb +101 -12
- data/lib/moderate/engine.rb +86 -25
- data/lib/moderate/event.rb +21 -0
- data/lib/moderate/filters/base.rb +1 -0
- data/lib/moderate/filters/wordlist.rb +5 -2
- data/lib/moderate/jobs/classify_job.rb +100 -16
- data/lib/moderate/label.rb +2 -2
- data/lib/moderate/macros.rb +8 -3
- data/lib/moderate/models/appeal.rb +10 -1
- data/lib/moderate/models/block.rb +23 -19
- data/lib/moderate/models/classification.rb +24 -0
- data/lib/moderate/models/concerns/actor.rb +17 -2
- data/lib/moderate/models/concerns/content_filterable.rb +241 -56
- data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
- data/lib/moderate/models/concerns/reportable.rb +84 -4
- data/lib/moderate/models/flag.rb +199 -3
- data/lib/moderate/models/report.rb +180 -16
- data/lib/moderate/result.rb +3 -10
- data/lib/moderate/retention.rb +241 -0
- data/lib/moderate/services/intake_notice.rb +10 -10
- data/lib/moderate/services/intake_report.rb +42 -13
- data/lib/moderate/services/resolve_appeal.rb +8 -2
- data/lib/moderate/services/resolve_flag.rb +183 -35
- data/lib/moderate/services/resolve_report.rb +288 -35
- data/lib/moderate/text_validator.rb +15 -14
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +337 -17
- data/lib/tasks/moderate.rake +23 -0
- metadata +24 -14
- data/gemfiles/rails_7.2.gemfile +0 -36
- data/lib/moderate/text.rb +0 -38
- data/lib/moderate/word_list.rb +0 -90
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
# REFERENCE ADAPTER — NOT shipped, NOT loaded, NOT a dependency of `moderate`.
|
|
5
|
+
#
|
|
6
|
+
# Patterns a word list can't express: phone numbers, links, e-mail addresses,
|
|
7
|
+
# "contact me on…" — the off-platform contact and spam shapes a marketplace or a
|
|
8
|
+
# chat wants to see. The idea comes from @killondark's PR #1 (2025), written
|
|
9
|
+
# against the 0.x validator; this is the same idea in 1.0's adapter contract.
|
|
10
|
+
#
|
|
11
|
+
# ── How to use it ─────────────────────────────────────────────────────────────
|
|
12
|
+
# 1. Copy this file into your app (e.g. app/adapters/regexp_pattern_adapter.rb).
|
|
13
|
+
# 2. Register it with your patterns, each under the category it means, and point a
|
|
14
|
+
# field at it. It's synchronous, so :block works as well as :flag:
|
|
15
|
+
# Moderate.configure do |config|
|
|
16
|
+
# config.register_adapter :patterns, RegexpPatternAdapter.new(
|
|
17
|
+
# spam: [%r{https?://}i, /\bwhats\s*app\b/i],
|
|
18
|
+
# privacy: [/\b\d{3}[\s.-]?\d{3}[\s.-]?\d{3}\b/] # a 9-digit phone number
|
|
19
|
+
# )
|
|
20
|
+
# config.filter "Message", :body, with: :patterns, mode: :flag
|
|
21
|
+
# end
|
|
22
|
+
# 3. Categories are yours: any Symbol works. Choose names your queue and your
|
|
23
|
+
# `config.report_categories` already speak (an enforced flag files its report
|
|
24
|
+
# under the first category the community list also has).
|
|
25
|
+
#
|
|
26
|
+
# ── What it returns ───────────────────────────────────────────────────────────
|
|
27
|
+
# A Moderate::Result with one Label per category that matched (score 1.0, input
|
|
28
|
+
# :text), `allowed: false` when anything matched, and the matched fragments in
|
|
29
|
+
# `raw[:matches]` so the queue can show WHAT tripped it.
|
|
30
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
class RegexpPatternAdapter
|
|
32
|
+
# @param patterns [Hash{Symbol=>Regexp, Array<Regexp>}] category => pattern(s)
|
|
33
|
+
def initialize(patterns = {}, **keyword_patterns)
|
|
34
|
+
@patterns = patterns.to_h.merge(keyword_patterns).to_h do |category, list|
|
|
35
|
+
list = Array(list)
|
|
36
|
+
unless list.all?(Regexp)
|
|
37
|
+
given = list.map(&:class).uniq.join(", ")
|
|
38
|
+
raise ArgumentError, "RegexpPatternAdapter: #{category.inspect} needs Regexp patterns, got #{given}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
[category.to_sym, list.freeze]
|
|
42
|
+
end.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A regexp runs in-process: safe for :block (a validation) as well as :flag.
|
|
46
|
+
def synchronous? = true
|
|
47
|
+
|
|
48
|
+
def classify(value)
|
|
49
|
+
matches = matches_in(value.to_s)
|
|
50
|
+
|
|
51
|
+
Moderate::Result.new(
|
|
52
|
+
allowed: matches.empty?,
|
|
53
|
+
labels: matches.keys.map { |category| Moderate::Label.new(category: category, score: 1.0, input: :text) },
|
|
54
|
+
source: "patterns",
|
|
55
|
+
raw: { matches: matches }
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# category => the distinct fragments of `text` its patterns matched
|
|
62
|
+
def matches_in(text)
|
|
63
|
+
@patterns.each_with_object({}) do |(category, list), found|
|
|
64
|
+
hits = list.flat_map { |pattern| text.scan(pattern).map { |hit| Array(hit).first.to_s } }.reject(&:empty?)
|
|
65
|
+
found[category] = hits.uniq if hits.any?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -3,30 +3,32 @@
|
|
|
3
3
|
source "https://rubygems.org"
|
|
4
4
|
|
|
5
5
|
gem "rake", "~> 13.0"
|
|
6
|
-
gem "rails", "~>
|
|
6
|
+
gem "rails", "~> 8.0.5", ">= 8.0.5.1"
|
|
7
|
+
gem "json", ">= 2.19.9", "< 3"
|
|
7
8
|
|
|
8
9
|
group :development do
|
|
9
10
|
gem "appraisal"
|
|
11
|
+
gem "bundler-audit", require: false
|
|
10
12
|
gem "web-console"
|
|
11
|
-
gem "standard"
|
|
12
13
|
gem "rubocop", "~> 1.0"
|
|
13
14
|
gem "rubocop-minitest", "~> 0.35"
|
|
14
15
|
gem "rubocop-performance", "~> 1.0"
|
|
16
|
+
gem "standard"
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
group :test do
|
|
18
20
|
gem "minitest", "~> 5.0"
|
|
19
21
|
gem "mocha"
|
|
20
22
|
gem "simplecov", require: false
|
|
21
|
-
gem "activejob"
|
|
22
23
|
gem "actionmailer"
|
|
24
|
+
gem "activejob"
|
|
23
25
|
gem "activestorage"
|
|
24
|
-
gem "sqlite3"
|
|
25
|
-
gem "pg"
|
|
26
26
|
gem "mysql2"
|
|
27
|
+
gem "pg"
|
|
28
|
+
gem "sqlite3"
|
|
27
29
|
gem "bootsnap", require: false
|
|
28
|
-
gem "puma"
|
|
29
30
|
gem "importmap-rails"
|
|
31
|
+
gem "puma"
|
|
30
32
|
gem "sprockets-rails"
|
|
31
33
|
gem "stimulus-rails"
|
|
32
34
|
gem "turbo-rails"
|
data/gemfiles/rails_8.1.gemfile
CHANGED
|
@@ -7,26 +7,27 @@ gem "rails", "~> 8.1.0"
|
|
|
7
7
|
|
|
8
8
|
group :development do
|
|
9
9
|
gem "appraisal"
|
|
10
|
+
gem "bundler-audit", require: false
|
|
10
11
|
gem "web-console"
|
|
11
|
-
gem "standard"
|
|
12
12
|
gem "rubocop", "~> 1.0"
|
|
13
13
|
gem "rubocop-minitest", "~> 0.35"
|
|
14
14
|
gem "rubocop-performance", "~> 1.0"
|
|
15
|
+
gem "standard"
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
group :test do
|
|
18
19
|
gem "minitest", "~> 5.0"
|
|
19
20
|
gem "mocha"
|
|
20
21
|
gem "simplecov", require: false
|
|
21
|
-
gem "activejob"
|
|
22
22
|
gem "actionmailer"
|
|
23
|
+
gem "activejob"
|
|
23
24
|
gem "activestorage"
|
|
24
|
-
gem "sqlite3"
|
|
25
|
-
gem "pg"
|
|
26
25
|
gem "mysql2"
|
|
26
|
+
gem "pg"
|
|
27
|
+
gem "sqlite3"
|
|
27
28
|
gem "bootsnap", require: false
|
|
28
|
-
gem "puma"
|
|
29
29
|
gem "importmap-rails"
|
|
30
|
+
gem "puma"
|
|
30
31
|
gem "sprockets-rails"
|
|
31
32
|
gem "stimulus-rails"
|
|
32
33
|
gem "turbo-rails"
|
|
@@ -5,6 +5,7 @@ require "rails/generators/active_record"
|
|
|
5
5
|
|
|
6
6
|
module Moderate
|
|
7
7
|
module Generators
|
|
8
|
+
# `rails generate moderate:install`: the adaptive migration and the initializer.
|
|
8
9
|
class InstallGenerator < Rails::Generators::Base
|
|
9
10
|
include ActiveRecord::Generators::Migration
|
|
10
11
|
|
|
@@ -19,6 +20,23 @@ module Moderate
|
|
|
19
20
|
migration_template "create_moderate_tables.rb.erb", File.join(db_migrate_path, "create_moderate_tables.rb")
|
|
20
21
|
end
|
|
21
22
|
|
|
23
|
+
# The gem's own migrations (db/migrate: what changed since the tables above were
|
|
24
|
+
# first shipped), copied AFTER create_moderate_tables and in their order, the way
|
|
25
|
+
# `bin/rails moderate:install:migrations` copies them: "<number>_<name>.moderate.rb".
|
|
26
|
+
# An app holding copies runs those, and the engine then leaves the gem's
|
|
27
|
+
# db/migrate out of its paths (see the "moderate.migrations" initializer), so no
|
|
28
|
+
# migration is ever found twice. One already in the app, by name, is skipped.
|
|
29
|
+
def copy_gem_migrations
|
|
30
|
+
dir = File.expand_path(db_migrate_path, destination_root)
|
|
31
|
+
Dir[Moderate::Engine.root.join("db/migrate/[0-9]*_*.rb").to_s].each do |source|
|
|
32
|
+
version, name = File.basename(source, ".rb").split("_", 2)
|
|
33
|
+
next if Dir.glob(File.join(dir, "[0-9]*_#{name}{,.*}.rb")).any?
|
|
34
|
+
|
|
35
|
+
number = self.class.next_migration_number(dir)
|
|
36
|
+
create_file File.join(db_migrate_path, "#{number}_#{name}.moderate.rb"), copied_migration(source, version)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
22
40
|
def create_initializer
|
|
23
41
|
template "initializer.rb", "config/initializers/moderate.rb"
|
|
24
42
|
end
|
|
@@ -48,6 +66,13 @@ module Moderate
|
|
|
48
66
|
|
|
49
67
|
private
|
|
50
68
|
|
|
69
|
+
# As Rails' engine copy writes it: magic comments first, then where it came from.
|
|
70
|
+
def copied_migration(source, version)
|
|
71
|
+
body = File.read(source)
|
|
72
|
+
magic = body[/\A(?:#.*\b(?:coding|frozen_string_literal):.*\n)+/].to_s
|
|
73
|
+
"#{magic}# This migration comes from moderate (originally #{version})\n#{body.delete_prefix(magic)}"
|
|
74
|
+
end
|
|
75
|
+
|
|
51
76
|
def migration_version
|
|
52
77
|
"[#{ActiveRecord::VERSION::STRING.to_f}]"
|
|
53
78
|
end
|
|
@@ -151,6 +151,11 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
151
151
|
add_index :moderate_flags, [:owner_id, :status], name: "index_moderate_flags_on_owner_id_and_status"
|
|
152
152
|
add_index :moderate_flags, [:status, :created_at], name: "index_moderate_flags_on_status_and_created_at"
|
|
153
153
|
|
|
154
|
+
# moderate_flags.pending_key (at most one PENDING flag per content + field) is NOT
|
|
155
|
+
# here: the gem's own AddPendingKeyToModerateFlags creates it, for a fresh install
|
|
156
|
+
# (moderate:install copies it right after this migration) and an upgrade alike, so
|
|
157
|
+
# one migration creates it everywhere and its `down` drops only what it created.
|
|
158
|
+
|
|
154
159
|
# Flag's mode/source/status vocabularies are validated in Moderate::Flag
|
|
155
160
|
# (constants + inclusion validations), not by DB CHECK constraints — same
|
|
156
161
|
# migration-free-taxonomy rationale as moderate_reports above.
|
|
@@ -187,6 +192,11 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
187
192
|
add_index :moderate_appeals, [:status, :created_at], name: "index_moderate_appeals_on_status_and_created_at"
|
|
188
193
|
add_index :moderate_appeals, :appellant_email, name: "index_moderate_appeals_on_appellant_email"
|
|
189
194
|
|
|
195
|
+
# The 1.0 retention columns (redacted_at, held_until) and moderate_classifications
|
|
196
|
+
# are NOT here: the gem's own AddRetentionAndClassificationsToModerateTables creates
|
|
197
|
+
# them, for a fresh install (moderate:install copies it after this migration) and
|
|
198
|
+
# an upgrade alike, so its `down` drops only what it created.
|
|
199
|
+
|
|
190
200
|
# Appeal's source/status vocabularies are validated in Moderate::Appeal
|
|
191
201
|
# (constants + inclusion validations), not by DB CHECK constraints — same
|
|
192
202
|
# migration-free-taxonomy rationale as moderate_reports above.
|
|
@@ -212,7 +222,7 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
212
222
|
# Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
|
|
213
223
|
# Models handle nil metadata gracefully by defaulting to {} in their accessors.
|
|
214
224
|
def json_column_default
|
|
215
|
-
return nil if
|
|
225
|
+
return nil if mysql?
|
|
216
226
|
|
|
217
227
|
{}
|
|
218
228
|
end
|
|
@@ -220,11 +230,16 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
|
|
|
220
230
|
# Same MySQL caveat as `json_column_default`, but for list-shaped columns,
|
|
221
231
|
# which default to an empty array on SQLite/PostgreSQL.
|
|
222
232
|
def json_array_default
|
|
223
|
-
return nil if
|
|
233
|
+
return nil if mysql?
|
|
224
234
|
|
|
225
235
|
[]
|
|
226
236
|
end
|
|
227
237
|
|
|
238
|
+
# MySQL under either of Rails' adapters: mysql2, or Trilogy (Rails 7.1+).
|
|
239
|
+
def mysql?
|
|
240
|
+
connection.adapter_name.downcase.match?(/mysql|trilogy/)
|
|
241
|
+
end
|
|
242
|
+
|
|
228
243
|
# The SQL function for COUNTING CHARACTERS in a check constraint, chosen per
|
|
229
244
|
# adapter so the message-length guard is portable:
|
|
230
245
|
# - SQLite has no `char_length`; its `length(text)` already counts CHARACTERS.
|
|
@@ -104,7 +104,8 @@ Moderate.configure do |config|
|
|
|
104
104
|
# same place. No-op by default.
|
|
105
105
|
#
|
|
106
106
|
# The full event vocabulary:
|
|
107
|
-
# :report_received :
|
|
107
|
+
# :report_received :notice_received
|
|
108
|
+
# :report_decision :affected_user_decision
|
|
108
109
|
# :appeal_received :appeal_decision
|
|
109
110
|
# :user_blocked :user_unblocked :user_banned
|
|
110
111
|
# :content_flagged :content_removed
|
|
@@ -139,9 +140,15 @@ Moderate.configure do |config|
|
|
|
139
140
|
# `moderate` doesn't own your user lifecycle, so it never bans a user itself.
|
|
140
141
|
# When a moderator resolves a report with `ban_user: true`, this proc decides
|
|
141
142
|
# what "banned" means in your domain — suspend, soft-delete, flip a flag, etc.
|
|
142
|
-
# Signature uses keyword args.
|
|
143
|
+
# Signature uses keyword args. Required before any ban: with none set,
|
|
144
|
+
# `ban_user: true` raises instead of claiming a suspension that never happened.
|
|
143
145
|
#
|
|
144
146
|
# config.ban_handler = ->(user:, by:, reason:) { user.suspend!(reason: reason) }
|
|
147
|
+
#
|
|
148
|
+
# If your app can lift a ban, tell moderate whether a person is banned NOW, so a
|
|
149
|
+
# second ban after an unban is applied again instead of counted as already done:
|
|
150
|
+
#
|
|
151
|
+
# config.banned = ->(user) { user.suspended? }
|
|
145
152
|
|
|
146
153
|
# ==========================================================================
|
|
147
154
|
# PUBLIC FORM HUMAN VERIFICATION — optional per-request skips
|
|
@@ -173,19 +180,6 @@ Moderate.configure do |config|
|
|
|
173
180
|
#
|
|
174
181
|
# config.transparency_report_enabled = true
|
|
175
182
|
|
|
176
|
-
# ==========================================================================
|
|
177
|
-
# SIGNED LINKS — purposes for the signed Global IDs in emails & notices
|
|
178
|
-
# ==========================================================================
|
|
179
|
-
#
|
|
180
|
-
# `moderate` mints signed, single-purpose links (e.g. an appeal link in a
|
|
181
|
-
# decision email, a confirm-receipt link in a DSA notice). These purposes
|
|
182
|
-
# scope each signature so a link minted for one action can't be replayed for
|
|
183
|
-
# another. The defaults cover the built-in flows; add your own if you mint
|
|
184
|
-
# custom signed links against moderate records.
|
|
185
|
-
#
|
|
186
|
-
# Default: [:appeal, :confirm_notice, :unsubscribe]
|
|
187
|
-
# config.signed_gid_purposes = [:appeal, :confirm_notice, :unsubscribe]
|
|
188
|
-
|
|
189
183
|
# ==========================================================================
|
|
190
184
|
# LOCALE
|
|
191
185
|
# ==========================================================================
|
|
@@ -41,9 +41,9 @@ module Moderate
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
# The engine layout (only if one ships under layouts/moderate).
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
return unless include?("layout")
|
|
45
|
+
|
|
46
|
+
directory "layouts/moderate", "app/views/layouts/moderate" if engine_view_exists?("layouts/moderate")
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
private
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# What the filter and ClassifyJob need to know about an Active Storage value
|
|
5
|
+
# before they hand it to an adapter, and the claim protocol that makes each set of
|
|
6
|
+
# files classified once per record without holding a lock during the adapter call.
|
|
7
|
+
#
|
|
8
|
+
# ── Claim, classify, complete ───────────────────────────────────────────────
|
|
9
|
+
# `classify_once` runs in three steps:
|
|
10
|
+
#
|
|
11
|
+
# 1. under the CONTENT record's row lock (content first, like every lock in the
|
|
12
|
+
# gem): stop if this record's field is already classified for exactly these
|
|
13
|
+
# files, or if another worker holds a live claim on them; else write our
|
|
14
|
+
# claim (a moderate_classifications row) and release the lock;
|
|
15
|
+
# 2. call the adapter, outside any lock — a slow provider never blocks writes to
|
|
16
|
+
# the content's row (a user's, for an avatar);
|
|
17
|
+
# 3. under the lock again: if the field still holds the same files and the claim
|
|
18
|
+
# is still ours, file the flag, stamp the row classified and clear the
|
|
19
|
+
# claim; otherwise discard the result (the files changed — the new set has
|
|
20
|
+
# its own classification — or our claim expired and another worker took it).
|
|
21
|
+
#
|
|
22
|
+
# A claim expires after `config.classification_claim_ttl` (10 minutes by default,
|
|
23
|
+
# at most a day), so a worker that died mid-call doesn't block the files forever:
|
|
24
|
+
# the next look after expiry overrides it. An adapter that RAISES also leaves its
|
|
25
|
+
# claim until expiry, and nothing here schedules a retry for it: a later attempt
|
|
26
|
+
# takes it over — the after-upload guard job, a job's one recheck at the claim's
|
|
27
|
+
# expiry, a new save of the field, or your job backend retrying the same job (same
|
|
28
|
+
# job id, so the claim is still its own).
|
|
29
|
+
#
|
|
30
|
+
# ── Where the claims live ──────────────────────────────────────────────────────
|
|
31
|
+
# In the gem's own table, `moderate_classifications` (Moderate::Classification):
|
|
32
|
+
# one row per record, field and exact set of files (a digest of their ordered blob
|
|
33
|
+
# ids), UNIQUE on those four, holding the claim (`claimed_by`, `claimed_at`) and
|
|
34
|
+
# the completion (`classified_at`). Rows are written only under the content
|
|
35
|
+
# record's lock, so nothing else — another record sharing the blob, Active
|
|
36
|
+
# Storage's analyzer rewriting `blob.metadata` — can touch them. The same files on
|
|
37
|
+
# another record, or a changed set on this one, are a different row: classified
|
|
38
|
+
# again.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
module Attachments
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def attachment?(value)
|
|
45
|
+
value.respond_to?(:attached?) && defined?(::ActiveStorage::Blob) ? true : false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def blobs(value)
|
|
49
|
+
return [] unless attachment?(value)
|
|
50
|
+
|
|
51
|
+
found = value.respond_to?(:blobs) ? value.blobs.to_a : [value.try(:blob)]
|
|
52
|
+
found.grep(::ActiveStorage::Blob).sort_by(&:id)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def blob_ids(value)
|
|
56
|
+
blobs(value).map(&:id)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Every file behind the value is on its service (a value with no blobs, or one
|
|
60
|
+
# that isn't an attachment at all, has nothing to wait for).
|
|
61
|
+
def uploaded?(value)
|
|
62
|
+
blobs(value).all? { |blob| blob.service.exist?(blob.key) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A stored blob whose file isn't on its service: a direct upload still in flight.
|
|
66
|
+
# A blob this save is about to upload (not persisted yet) has its bytes in hand.
|
|
67
|
+
def upload_unfinished?(value)
|
|
68
|
+
blobs(value).any? { |blob| blob.persisted? && !blob.service.exist?(blob.key) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def classified?(record, field, value)
|
|
72
|
+
found = blobs(value)
|
|
73
|
+
found.any? && Classification.for_set(record, field, digest(found)).where.not(classified_at: nil).exists?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The whole protocol (see the module comment). `read` returns the field's value
|
|
77
|
+
# as committed now (it is called under the lock, twice); `classify` calls the
|
|
78
|
+
# adapter; `file` files the flag for a flagged result. Returns one of:
|
|
79
|
+
# :classified — classified by us, marked
|
|
80
|
+
# :done — already classified for these files
|
|
81
|
+
# :claimed — another worker holds a live claim (with its expiry, see #claim_expires_at)
|
|
82
|
+
# :discarded — the files changed, or the claim was lost, while the adapter ran
|
|
83
|
+
# :empty — nothing attached any more
|
|
84
|
+
def classify_once(record, field, owner:, read:, classify:, file:, now: Time.current)
|
|
85
|
+
claim = Moderate.with_row_lock(record) { claim!(record, field, read.call, owner: owner, now: now) }
|
|
86
|
+
return claim[:status] unless claim[:status] == :claimed_by_us
|
|
87
|
+
|
|
88
|
+
result = classify.call(claim[:value])
|
|
89
|
+
|
|
90
|
+
Moderate.with_row_lock(record) do
|
|
91
|
+
value = read.call
|
|
92
|
+
found = blobs(value)
|
|
93
|
+
row = Classification.find_by(id: claim[:row].id)
|
|
94
|
+
if row && found.map(&:id) == claim[:blob_ids] && row.claimed_by == owner
|
|
95
|
+
file.call(value, result) if result.flagged?
|
|
96
|
+
row.update!(classified_at: Time.current, claimed_by: nil, claimed_at: nil,
|
|
97
|
+
adapter: result.source.to_s.presence, flagged: result.flagged?)
|
|
98
|
+
:classified
|
|
99
|
+
else
|
|
100
|
+
Classification.where(id: claim[:row].id, claimed_by: owner).update_all(claimed_by: nil, claimed_at: nil)
|
|
101
|
+
:discarded
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# When a live claim held by someone else expires (nil if there isn't one).
|
|
107
|
+
def claim_expires_at(record, field, value)
|
|
108
|
+
found = blobs(value)
|
|
109
|
+
claimed_at = found.any? && Classification.for_set(record, field, digest(found)).pick(:claimed_at)
|
|
110
|
+
claimed_at && (claimed_at + claim_ttl)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def claim_ttl
|
|
114
|
+
Moderate.config.classification_claim_ttl.to_i
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def digest(found)
|
|
118
|
+
Digest::SHA256.hexdigest(found.map(&:id).sort.join(","))[0, 16]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Under the lock ------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def claim!(record, field, value, owner:, now:)
|
|
124
|
+
found = blobs(value)
|
|
125
|
+
return { status: :empty } if found.empty?
|
|
126
|
+
|
|
127
|
+
row = Classification.for_set(record, field, digest(found)).first_or_initialize
|
|
128
|
+
return { status: :done } if row.classified?
|
|
129
|
+
return { status: :claimed } if row.claimed_by.present? && row.claimed_by != owner && row.claimed_at > now - claim_ttl
|
|
130
|
+
|
|
131
|
+
row.update!(claimed_by: owner, claimed_at: now)
|
|
132
|
+
{ status: :claimed_by_us, row: row, blob_ids: found.map(&:id), value: value }
|
|
133
|
+
rescue ActiveRecord::RecordNotUnique
|
|
134
|
+
{ status: :claimed } # another worker inserted the row first (only without row locks)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -15,7 +15,8 @@ module Moderate
|
|
|
15
15
|
# plain-English ArgumentError on a bad value — failing fast at the assignment
|
|
16
16
|
# line. `validate!` runs once more at the end of `configure` for cross-field
|
|
17
17
|
# checks (e.g. a :block filter pointed at an async adapter).
|
|
18
|
-
# - Every hook (`audit`/`notify`/`on_block`/`ban_handler`) defaults to a no-op
|
|
18
|
+
# - Every hook (`audit`/`notify`/`on_block`/`ban_handler`) defaults to a no-op
|
|
19
|
+
# (`ban_handler` refuses a ban it can't apply rather than faking one),
|
|
19
20
|
# so the gem works untouched and a host wires hooks only as needed.
|
|
20
21
|
#
|
|
21
22
|
# This mirrors the validating-setter convention across the ecosystem
|
|
@@ -57,11 +58,51 @@ module Moderate
|
|
|
57
58
|
attr_accessor :report_categories
|
|
58
59
|
|
|
59
60
|
# --- Hooks (all no-op by default) ----------------------------------------
|
|
60
|
-
attr_accessor :audit, :notify, :on_block
|
|
61
|
+
attr_accessor :audit, :notify, :on_block
|
|
62
|
+
attr_reader :ban_handler # its writer (below) refuses a non-callable and tracks configured?
|
|
61
63
|
|
|
62
64
|
# --- Misc -----------------------------------------------------------------
|
|
63
65
|
attr_accessor :locale
|
|
64
66
|
|
|
67
|
+
# --- Attachment classification ----------------------------------------------
|
|
68
|
+
# How long a worker's claim on a set of attached files lasts (seconds, or an
|
|
69
|
+
# ActiveSupport::Duration) before another worker may take it over: longer than
|
|
70
|
+
# your slowest adapter call, short enough that a worker that died mid-call doesn't
|
|
71
|
+
# leave the files unclassified for long. See Moderate::Attachments.
|
|
72
|
+
# Capped at a day (MAX_CLASSIFICATION_CLAIM_TTL) so a claim always ends, and a
|
|
73
|
+
# purge — which keeps rows with a live claim — can't be held off forever.
|
|
74
|
+
attr_reader :classification_claim_ttl
|
|
75
|
+
|
|
76
|
+
# What the boot check does when the app holds copies of the gem's migrations
|
|
77
|
+
# (*.moderate.rb) but not every one this version ships: :raise (the default; under
|
|
78
|
+
# a rake task it logs instead, so `bin/rails moderate:install:migrations` can run),
|
|
79
|
+
# :log, or :off. See Moderate::Engine.check_migration_copies!.
|
|
80
|
+
MIGRATION_COPIES_CHECKS = %i[raise log off].freeze
|
|
81
|
+
attr_reader :migration_copies_check
|
|
82
|
+
|
|
83
|
+
def migration_copies_check=(mode)
|
|
84
|
+
mode = mode.to_s.strip.downcase.to_sym
|
|
85
|
+
unless MIGRATION_COPIES_CHECKS.include?(mode)
|
|
86
|
+
raise ArgumentError, "migration_copies_check must be one of: #{MIGRATION_COPIES_CHECKS.join(", ")}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@migration_copies_check = mode
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
MAX_CLASSIFICATION_CLAIM_TTL = 24 * 60 * 60 # 1 day, in seconds
|
|
93
|
+
|
|
94
|
+
def classification_claim_ttl=(value)
|
|
95
|
+
seconds = value.respond_to?(:to_f) ? value.to_f : 0.0
|
|
96
|
+
unless seconds.positive? && seconds <= MAX_CLASSIFICATION_CLAIM_TTL
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"classification_claim_ttl must be more than 0 and at most a day (#{MAX_CLASSIFICATION_CLAIM_TTL} seconds), " \
|
|
99
|
+
"got #{value.inspect}: longer than your slowest adapter call, short enough that files a dead worker " \
|
|
100
|
+
"claimed are taken over"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@classification_claim_ttl = value
|
|
104
|
+
end
|
|
105
|
+
|
|
65
106
|
# --- DSA notice form ------------------------------------------------------
|
|
66
107
|
# Documented in docs/dsa-notice-form.md. Held here so the engine/controller
|
|
67
108
|
# (written by other components) read them off the same Configuration object.
|
|
@@ -72,12 +113,11 @@ module Moderate
|
|
|
72
113
|
# the controller auto-uses Turnstile instead and this proc is bypassed.
|
|
73
114
|
attr_reader :parent_controller
|
|
74
115
|
attr_accessor :notice_form_enabled, :notice_rate_limit,
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
:signed_gid_purposes
|
|
116
|
+
:notice_turnstile_site_key, :notice_turnstile_secret_key,
|
|
117
|
+
:notice_captcha_verifier, :notice_guard, :notice_human_verification_skip_if,
|
|
118
|
+
:appeal_form_enabled, :appeal_rate_limit, :appeal_guard,
|
|
119
|
+
:appeal_human_verification_skip_if, :appeal_return_path,
|
|
120
|
+
:transparency_report_enabled
|
|
81
121
|
|
|
82
122
|
def initialize
|
|
83
123
|
# Identity. "User" is the overwhelmingly common case; the host overrides it
|
|
@@ -123,6 +163,9 @@ module Moderate
|
|
|
123
163
|
# Misc. nil locale ⇒ follow I18n.default_locale at use time.
|
|
124
164
|
@locale = nil
|
|
125
165
|
|
|
166
|
+
@classification_claim_ttl = 600 # 10 minutes, in seconds
|
|
167
|
+
@migration_copies_check = :raise
|
|
168
|
+
|
|
126
169
|
# Engine controller defaults. The parent controller defaults to a stock base so
|
|
127
170
|
# the engine works even on API-only apps — the same `parent_controller`
|
|
128
171
|
# indirection Devise and api_keys use.
|
|
@@ -159,8 +202,42 @@ module Moderate
|
|
|
159
202
|
# off, the mounted `/transparency` route 404s; the aggregation stays queryable in
|
|
160
203
|
# code so a host can still build/publish its own report.
|
|
161
204
|
@transparency_report_enabled = false
|
|
205
|
+
end
|
|
162
206
|
|
|
163
|
-
|
|
207
|
+
# How a "ban" is applied in the host. Unlike the other hooks, leaving it unset is
|
|
208
|
+
# not a silent no-op when a ban is actually asked for: `ban_user: true` (and
|
|
209
|
+
# Moderate.apply_ban) raise until one is wired, because "we suspended your
|
|
210
|
+
# account" must never be sent for a suspension that didn't happen. Assigning nil
|
|
211
|
+
# puts the default back.
|
|
212
|
+
def ban_handler=(handler)
|
|
213
|
+
if handler.nil?
|
|
214
|
+
@ban_handler = ->(user:, by:, reason:) {}
|
|
215
|
+
@ban_handler_configured = false
|
|
216
|
+
else
|
|
217
|
+
raise ArgumentError, "ban_handler must respond to #call" unless handler.respond_to?(:call)
|
|
218
|
+
|
|
219
|
+
@ban_handler = handler
|
|
220
|
+
@ban_handler_configured = true
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def ban_handler_configured?
|
|
225
|
+
@ban_handler_configured == true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Is this person banned right now, in your app? Optional: `->(user) { user.banned? }`.
|
|
229
|
+
# A ban decision on someone a decision of this same episode already banned is
|
|
230
|
+
# normally recorded as covered by that one (one ban per person per episode). That
|
|
231
|
+
# is only true while the account is still banned: set this, and a person your app
|
|
232
|
+
# has unbanned since is banned again by your ban_handler, so a "suspended"
|
|
233
|
+
# statement of reasons is never sent for an account nothing suspended. Unset
|
|
234
|
+
# (nil), the earlier ban counts, as before.
|
|
235
|
+
attr_reader :banned
|
|
236
|
+
|
|
237
|
+
def banned=(check)
|
|
238
|
+
raise ArgumentError, "banned must respond to #call (e.g. ->(user) { user.banned? })" unless check.nil? || check.respond_to?(:call)
|
|
239
|
+
|
|
240
|
+
@banned = check
|
|
164
241
|
end
|
|
165
242
|
|
|
166
243
|
def parent_controller=(value)
|
|
@@ -230,6 +307,20 @@ module Moderate
|
|
|
230
307
|
@adapters.key?(normalize_name(name))
|
|
231
308
|
end
|
|
232
309
|
|
|
310
|
+
# Is the adapter registered under `name` background-only? Same probe the
|
|
311
|
+
# :block validator uses (`validate_block_mode_adapter!`): an adapter is
|
|
312
|
+
# async ONLY if it explicitly answers `synchronous? == false`; adapters
|
|
313
|
+
# that don't expose the predicate are assumed synchronous — the
|
|
314
|
+
# conservative default that keeps simple adapters working. The :flag
|
|
315
|
+
# enforcement path reads this to decide inline-classify vs routing the
|
|
316
|
+
# work through Moderate::ClassifyJob (see ContentFilterable).
|
|
317
|
+
def adapter_async?(name)
|
|
318
|
+
adapter = adapter_for(name)
|
|
319
|
+
return false if adapter.nil?
|
|
320
|
+
|
|
321
|
+
adapter.respond_to?(:synchronous?) && !adapter.synchronous?
|
|
322
|
+
end
|
|
323
|
+
|
|
233
324
|
# --- Filters --------------------------------------------------------------
|
|
234
325
|
|
|
235
326
|
# Declare a per-field filter policy in the initializer — the twin of
|
|
@@ -276,9 +367,7 @@ module Moderate
|
|
|
276
367
|
# the ecosystem-wide convention.
|
|
277
368
|
def normalize_mode(value)
|
|
278
369
|
mode = value.to_s.strip.downcase.to_sym
|
|
279
|
-
unless FILTER_MODES.include?(mode)
|
|
280
|
-
raise ArgumentError, "default_filter_mode must be one of: #{FILTER_MODES.join(', ')}"
|
|
281
|
-
end
|
|
370
|
+
raise ArgumentError, "default_filter_mode must be one of: #{FILTER_MODES.join(", ")}" unless FILTER_MODES.include?(mode)
|
|
282
371
|
|
|
283
372
|
mode
|
|
284
373
|
end
|