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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +96 -1
  3. data/.simplecov +2 -10
  4. data/AGENTS.md +1 -1
  5. data/Appraisals +10 -8
  6. data/CHANGELOG.md +660 -20
  7. data/CLAUDE.md +1 -1
  8. data/README.md +149 -7
  9. data/Rakefile +2 -0
  10. data/app/controllers/concerns/moderate/moderation.rb +23 -11
  11. data/app/controllers/moderate/appeals_controller.rb +9 -5
  12. data/app/controllers/moderate/application_controller.rb +3 -3
  13. data/app/controllers/moderate/notices_controller.rb +3 -6
  14. data/app/helpers/moderate/engine_helper.rb +32 -11
  15. data/app/views/moderate/appeals/new.html.erb +6 -0
  16. data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
  17. data/app/views/moderate/transparency_reports/show.html.erb +5 -5
  18. data/config/locales/en.yml +200 -0
  19. data/config/locales/es.yml +177 -0
  20. data/context7.json +4 -0
  21. data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
  22. data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
  23. data/docs/chats.md +140 -0
  24. data/docs/compliance.md +92 -3
  25. data/docs/configuration.md +30 -2
  26. data/docs/dsa-notice-form.md +1 -1
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +44 -14
  29. data/docs/notifications.md +17 -4
  30. data/examples/aws_rekognition_adapter.rb +6 -6
  31. data/examples/openai_moderation_adapter.rb +1 -1
  32. data/examples/regexp_pattern_adapter.rb +68 -0
  33. data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
  34. data/gemfiles/rails_8.1.gemfile +6 -5
  35. data/lib/generators/moderate/install_generator.rb +25 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
  37. data/lib/generators/moderate/templates/initializer.rb +9 -15
  38. data/lib/generators/moderate/views_generator.rb +3 -3
  39. data/lib/moderate/attachments.rb +137 -0
  40. data/lib/moderate/configuration.rb +101 -12
  41. data/lib/moderate/engine.rb +86 -25
  42. data/lib/moderate/event.rb +21 -0
  43. data/lib/moderate/filters/base.rb +1 -0
  44. data/lib/moderate/filters/wordlist.rb +5 -2
  45. data/lib/moderate/jobs/classify_job.rb +100 -16
  46. data/lib/moderate/label.rb +2 -2
  47. data/lib/moderate/macros.rb +8 -3
  48. data/lib/moderate/models/appeal.rb +10 -1
  49. data/lib/moderate/models/block.rb +23 -19
  50. data/lib/moderate/models/classification.rb +24 -0
  51. data/lib/moderate/models/concerns/actor.rb +17 -2
  52. data/lib/moderate/models/concerns/content_filterable.rb +241 -56
  53. data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
  54. data/lib/moderate/models/concerns/reportable.rb +84 -4
  55. data/lib/moderate/models/flag.rb +199 -3
  56. data/lib/moderate/models/report.rb +180 -16
  57. data/lib/moderate/result.rb +3 -10
  58. data/lib/moderate/retention.rb +241 -0
  59. data/lib/moderate/services/intake_notice.rb +10 -10
  60. data/lib/moderate/services/intake_report.rb +42 -13
  61. data/lib/moderate/services/resolve_appeal.rb +8 -2
  62. data/lib/moderate/services/resolve_flag.rb +183 -35
  63. data/lib/moderate/services/resolve_report.rb +288 -35
  64. data/lib/moderate/text_validator.rb +15 -14
  65. data/lib/moderate/version.rb +1 -1
  66. data/lib/moderate.rb +337 -17
  67. data/lib/tasks/moderate.rake +23 -0
  68. metadata +24 -14
  69. data/gemfiles/rails_7.2.gemfile +0 -36
  70. data/lib/moderate/text.rb +0 -38
  71. 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", "~> 7.1.0"
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"
@@ -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 connection.adapter_name.downcase.include?("mysql")
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 connection.adapter_name.downcase.include?("mysql")
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 :report_decision :affected_user_decision
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. No-op by default (the action still audits).
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
- if include?("layout")
45
- directory "layouts/moderate", "app/views/layouts/moderate" if engine_view_exists?("layouts/moderate")
46
- end
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, :ban_handler
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
- :notice_turnstile_site_key, :notice_turnstile_secret_key,
76
- :notice_captcha_verifier, :notice_guard, :notice_human_verification_skip_if,
77
- :appeal_form_enabled, :appeal_rate_limit, :appeal_guard,
78
- :appeal_human_verification_skip_if, :appeal_return_path,
79
- :transparency_report_enabled,
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
- @signed_gid_purposes = %i[appeal confirm_notice unsubscribe]
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