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
@@ -32,14 +32,33 @@ module Moderate
32
32
  # means disclosure, appeal path). Different people, different rights — see
33
33
  # docs/notifications.md ("Why two decision events").
34
34
  class ResolveReport
35
- def initialize(report, by:)
35
+ # The decision notices are queued on the report and sent when the outermost
36
+ # transaction commits (Moderate::DeferredNotices). `deliver: false` leaves them
37
+ # to a caller that queues them on a record of its own (ResolveFlag, on the flag),
38
+ # via `#deliver_decision_notices`.
39
+ #
40
+ # `conduct_since:` is when the conduct this decision answers was first raised —
41
+ # the report's own creation by default; ResolveFlag passes the flag's. A ban
42
+ # decided on the same person since then already answered it (see
43
+ # #ban_reported_user!).
44
+ def initialize(report, by:, deliver: true, conduct_since: nil)
36
45
  @report = report
37
46
  @moderator = by
47
+ @deliver = deliver
48
+ @conduct_since = conduct_since
38
49
  end
39
50
 
51
+ # The report being decided.
52
+ attr_reader :report
53
+
40
54
  # Resolve WITH action (the report was valid; we acted on the content/account).
41
55
  # `resolution_basis` records the legal/contractual ground bucket; it's
42
56
  # validated against the migration's check-constraint list by the model.
57
+ #
58
+ # A resolve restricts something: `remove_content:`, `ban_user:`, or both. One with
59
+ # neither is refused (RecordInvalid): recording it would tell the affected person a
60
+ # restriction was imposed when none was. A decision that restricts nothing is a
61
+ # `dismiss!`, whose statement says so.
43
62
  def resolve!(note:, remove_content: false, ban_user: false, resolution_basis: "terms")
44
63
  note = require_note!(note)
45
64
  actions = {
@@ -47,10 +66,16 @@ module Moderate
47
66
  ban_user: truthy?(ban_user),
48
67
  resolution_basis: resolution_basis.to_s.strip.presence || "terms"
49
68
  }
69
+ require_action!(actions)
70
+
71
+ # A ban nothing would apply is refused before anything changes (see
72
+ # Moderate.ensure_ban_handler!): never "account suspended" for a suspension
73
+ # that didn't happen.
74
+ Moderate.ensure_ban_handler! if actions[:ban_user] && report.reported_user.present?
50
75
 
51
76
  transition!("actioned", note: note, actions: actions) do
52
- remove_reported_content! if actions[:remove_content]
53
- ban_reported_user!(note: note) if actions[:ban_user]
77
+ remove_reported_content!(note: note, actions: actions) if actions[:remove_content]
78
+ ban_reported_user!(note: note, actions: actions) if actions[:ban_user]
54
79
  end
55
80
  end
56
81
 
@@ -62,16 +87,44 @@ module Moderate
62
87
  transition!("dismissed", note: note, actions: { resolution_basis: "no_violation" })
63
88
  end
64
89
 
90
+ # The two decision events (see the class doc, point 4). Sent by `resolve!` /
91
+ # `dismiss!` themselves unless this service was built with `deliver: false`.
92
+ def deliver_decision_notices
93
+ notify_user_banned
94
+ notify_reporter
95
+ notify_affected_user
96
+ notify_content_removed
97
+ end
98
+
65
99
  private
66
100
 
67
- attr_reader :report, :moderator
101
+ attr_reader :moderator
68
102
 
69
103
  # The atomic core. Everything that mutates state happens under the row lock;
70
104
  # the (slow, fallible) notifications happen AFTER the lock is released, so a
71
105
  # broken mailer can't hold a database lock or roll back the decision.
72
- def transition!(status, note:, actions:)
106
+ def transition!(status, note:, actions:, &enforcement)
107
+ # The report's connection, captured before anything is saved: every notice of
108
+ # this decision joins ITS transaction, whatever a host callback switches to.
109
+ @connection = Moderate::DeferredNotices.connection_for(report.class)
110
+
111
+ # One lock order everywhere: content, then (for a ban) the person, then the
112
+ # report — the order a flag escalation takes too, so they never deadlock. The
113
+ # person's lock serializes two bans of one account from different content.
114
+ Moderate.with_row_lock(report.reportable) do
115
+ Moderate.with_row_lock(actions[:ban_user] ? report.reported_user : nil) do
116
+ lock_and_transition!(status, note: note, actions: actions, &enforcement)
117
+ end
118
+ end
119
+
120
+ report
121
+ end
122
+
123
+ def lock_and_transition!(status, note:, actions:)
73
124
  report.with_lock do
74
- report.reload
125
+ # `with_lock` already re-read the row under the lock (SELECT … FOR UPDATE):
126
+ # no plain reload here — under MySQL's REPEATABLE READ a plain read pins the
127
+ # transaction's snapshot, and every later check must see what committed.
75
128
 
76
129
  # Re-check inside the lock — see class doc point (1). A closed report is a
77
130
  # no-op error, never a silent re-apply of enforcement.
@@ -100,57 +153,235 @@ module Moderate
100
153
  )
101
154
 
102
155
  audit_decision(status, actions, note)
103
- end
104
156
 
105
- deliver_decision_notices
106
- report
157
+ # Sent when the outermost transaction commits, never on a rollback.
158
+ report.after_commit_notice(on: @connection) { deliver_decision_notices } if @deliver
159
+ end
107
160
  end
108
161
 
109
162
  # --- Enforcement (host contracts only) ----------------------------------
110
163
 
111
- def remove_reported_content!
112
- return if report.reportable.blank?
113
- return unless report.reportable.respond_to?(:remove_reported_field!)
164
+ # The reportable's own removal (`remove_reported_field!`). When it says it
165
+ # removed something, the removal is audited here, inside the decision's
166
+ # transaction, and announced as `content_removed` with the decision notices.
167
+ def remove_reported_content!(note:, actions:)
168
+ # Already removed by an earlier decision on the same content and field: close
169
+ # against it (like a flag does), the reporter told it's gone, nothing removed
170
+ # or announced a second time.
171
+ if (prior = covering_removal)
172
+ actions[:content_already_removed_by] = prior.id
173
+ return true
174
+ end
175
+
176
+ # Nothing to remove from (the content was deleted, its report nullified) or
177
+ # no way to remove it: refused like a removal that removed nothing.
178
+ removable = report.reportable.present? && report.reportable.respond_to?(:remove_reported_field!)
179
+ removed_text = text_about_to_be_removed if removable
180
+ removed = removable && report.reportable.remove_reported_field!(report.reported_field)
181
+ # Verified, not taken on trust: the host said it removed something, so the field
182
+ # must read as gone now (re-read under the same lock). A host method that rewrote
183
+ # the field instead removed nothing: refused, nobody stamped, no digest recorded.
184
+ refuse_unremoved! unless removed && content_gone?
185
+
186
+ actions[:removed_text_digest] = text_digest(removed_text) if removed_text.present?
187
+ @content_removed = content_removed_payload(note)
188
+ stamp_covered_reports!(removed_text)
189
+ Moderate.audit(:content_removed, **content_removed_event)
190
+ true
191
+ end
192
+
193
+ def covered_note
194
+ prior = report.resolution_actions.to_h["content_already_removed_by"]
195
+ prior ? " (already removed by the decision on Report ##{prior})" : ""
196
+ end
197
+
198
+ # The decision that removed this report's content while this report was pending:
199
+ # the one that stamped it (covered_by_report_id, see #stamp_covered_reports!),
200
+ # provided it did remove the content, it still stands (no appeal against it was
201
+ # upheld: an overturned removal covers nothing, whatever the stamp says), and the
202
+ # content is gone now (read fresh, under the content's lock). No clocks are
203
+ # compared: a report that wasn't pending when the content was removed (a repost,
204
+ # a removal that didn't take, a late report) was never stamped.
205
+ def covering_removal
206
+ prior_id = report.covered_by_report_id
207
+ return if prior_id.blank? || !content_gone?
208
+
209
+ # Locking reads (see Moderate.with_row_lock), like every "already done" check.
210
+ prior = Moderate::Report.actioned.lock.find_by(id: prior_id)
211
+ return unless prior && truthy?(prior.resolution_actions.to_h["remove_content"])
212
+ return if Moderate::Appeal.upheld.where(report_id: prior.id).lock.exists?
213
+
214
+ prior
215
+ end
216
+
217
+ # The field's text right before this decision removes it, read under the
218
+ # content's lock with a locking read: what is actually being removed, which may
219
+ # differ from what this report saw at intake. nil when the content exposes no
220
+ # text (no moderation_snapshot) or reads blank.
221
+ def text_about_to_be_removed
222
+ content = report.reportable.reload(lock: true)
223
+ return unless content.respond_to?(:moderation_snapshot)
224
+
225
+ content.moderation_snapshot(report.reported_field).presence
226
+ rescue ActiveRecord::RecordNotFound
227
+ nil
228
+ end
229
+
230
+ def text_digest(text)
231
+ Digest::SHA256.hexdigest(text.to_s)
232
+ end
233
+
234
+ # Just removed this report's content, under its lock: stamp the OTHER reports
235
+ # pending on the same content and field whose intake snapshot holds the text that
236
+ # was ACTUALLY removed (`removed_text`, read right before the removal, not this
237
+ # report's own intake text), so each can later be closed as "already removed by"
238
+ # this decision. Nobody is stamped when that text is blank or unreadable. A report
239
+ # filed concurrently either committed before this read (then its snapshot is the
240
+ # text it saw: the removed text if it saw the content we removed) or after it
241
+ # (then it isn't stamped, and a removal on it is refused): never a false cover.
242
+ def stamp_covered_reports!(text)
243
+ return if text.blank? || report.reportable_type.blank? || report.reportable_id.blank?
244
+
245
+ pending = Moderate::Report.open
246
+ .where(reportable_type: report.reportable_type, reportable_id: report.reportable_id)
247
+ .where(reported_field: report.reported_field, covered_by_report_id: nil)
248
+ .where.not(id: report.id).lock
249
+ ids = pending.select { |other| other.snapshot.to_h.with_indifferent_access[:content_text] == text }.map(&:id)
250
+ Moderate::Report.where(id: ids).update_all(covered_by_report_id: report.id) if ids.any?
251
+ end
252
+
253
+ # Is the reported field's content gone, read fresh with a locking read? The
254
+ # record deleted, the record saying it is (`deleted?`, a soft-delete tombstone),
255
+ # or the field reading as empty through BOTH the snapshot accessor (what a report
256
+ # captures) and the field itself (an attachment, a non-text field).
257
+ def content_gone?
258
+ content = report.reportable&.reload(lock: true)
259
+ return true if content.nil?
260
+ return true if content.respond_to?(:deleted?) && content.deleted?
261
+
262
+ field = report.reported_field
263
+ return false if field.blank?
264
+ return false if content.respond_to?(:moderation_snapshot) && content.moderation_snapshot(field).present?
265
+
266
+ value = content.respond_to?(:moderation_field_value, true) ? content.send(:moderation_field_value, field) : content.try(field)
267
+ value.respond_to?(:attached?) ? !value.attached? : value.blank?
268
+ rescue ActiveRecord::RecordNotFound
269
+ true
270
+ end
271
+
272
+ # "Remove the content" was asked and the reportable removed nothing (the field
273
+ # isn't removable, or it's already gone). Recording the decision anyway would
274
+ # tell the affected person "content removed" for content that's still there, so
275
+ # the whole decision is refused (and rolled back, a ban with it).
276
+ def refuse_unremoved!
277
+ message = I18n.t(
278
+ "moderate.errors.nothing_removed",
279
+ content: report.reportable_label,
280
+ default: "Nothing was removed: #{report.reportable_label} has nothing removable there (already " \
281
+ "removed, deleted, or not removable). Resolve without removing content, or dismiss."
282
+ )
283
+ report.errors.add(:base, message)
284
+ raise ActiveRecord::RecordInvalid, report
285
+ end
286
+
287
+ def content_removed_payload(note)
288
+ {
289
+ report_id: report.id,
290
+ reportable_type: report.reportable_type,
291
+ reportable_id: report.reportable_id,
292
+ reported_field: report.reported_field,
293
+ reported_field_label: report.reported_field_label,
294
+ reason: note,
295
+ summary: ["Content removed: #{report.reportable_label}", report.reported_field_label].compact.join(" · ")
296
+ }.compact
297
+ end
114
298
 
115
- report.reportable.remove_reported_field!(report.reported_field)
299
+ def content_removed_event
300
+ { subject: report, actor: moderator, recipients: [report.reported_user].compact, payload: @content_removed }
116
301
  end
117
302
 
118
- def ban_reported_user!(note:)
119
- user = report.reported_user
303
+ # The gem never bans directly — it asks the host's ban_handler what "banned"
304
+ # means (suspend, soft-delete, flip a flag…). ONE ban per person per episode: if
305
+ # a decision on this same person, on any content or field, already banned them
306
+ # since this conduct was raised, the account is suspended already — this decision
307
+ # records which one (`ban_already_applied_by`) instead of banning twice, unless
308
+ # your `config.banned` says the account isn't banned any more (unbanned in your
309
+ # app since), in which case the ban_handler runs again. (An older ban, before
310
+ # this conduct, doesn't count: the person may have been unbanned since, and new
311
+ # conduct deserves its own decision.)
312
+ def ban_reported_user!(note:, actions:)
313
+ # The person, read again with a locking read now that we hold their lock: a
314
+ # concurrent decision that banned them while we waited has committed, and
315
+ # `config.banned` and the ban_handler must see THAT state, not the copy loaded
316
+ # before the wait.
317
+ user = locked_person
120
318
  return if user.blank?
121
319
 
122
- # The gem never bans directly — it asks the host's ban_handler what "banned"
123
- # means (suspend, soft-delete, flip a flag…). No-op by default, so the
124
- # decision still completes and audits even if no ban is wired.
125
- Moderate.apply_ban(user: user, by: moderator, reason: note)
320
+ if (prior = prior_ban_decision(user)) && still_banned?(user)
321
+ actions[:ban_already_applied_by] = prior.id
322
+ return
323
+ end
324
+
325
+ @user_banned = Moderate.apply_ban(user: user, by: moderator, reason: note, announce: false)
326
+ end
327
+
328
+ def locked_person
329
+ person = report.reported_user
330
+ person && person.class.unscoped.lock.find_by(person.class.primary_key => person.id)
331
+ end
332
+
333
+ # An earlier ban of this episode covers this one only while the account is still
334
+ # banned. The host knows that (`config.banned`); without it, the earlier ban counts.
335
+ def still_banned?(user)
336
+ check = Moderate.config.banned
337
+ check.nil? || check.call(user) ? true : false
338
+ end
339
+
340
+ def prior_ban_decision(user)
341
+ since = @conduct_since || report.created_at
342
+ decided = Moderate::Report.actioned.where(reported_user_id: user.id, resolved_at: since..)
343
+ .where.not(id: report.id)
344
+ # A locking read: a ban committed by a concurrent decision while we waited for
345
+ # the person's lock is seen, whatever the isolation level.
346
+ decided.recent_first.lock.find { |prior| truthy?(prior.resolution_actions.to_h["ban_user"]) }
126
347
  end
127
348
 
128
349
  # --- Notifications (after the lock) -------------------------------------
129
350
 
130
- def deliver_decision_notices
131
- notify_reporter
132
- notify_affected_user
351
+ # user_banned → the banned person (Moderate.apply_ban, announced here so it waits
352
+ # for the commit like the rest).
353
+ def notify_user_banned
354
+ return unless @user_banned
355
+
356
+ Moderate.announce_ban(**@user_banned)
357
+ end
358
+
359
+ # content_removed → the person whose content it was (when there is one) and the
360
+ # host's admin channels. Only when the removal actually removed something.
361
+ def notify_content_removed
362
+ return unless @content_removed
363
+
364
+ Moderate.notify(:content_removed, **content_removed_event)
133
365
  end
134
366
 
135
367
  # report_decision → the reporter (Art. 16(5): inform the notice provider of the
136
- # decision + redress). We stamp `decision_notified_at` ONLY when the hook
137
- # reported a delivery — that timestamp is the record that the legal
138
- # communication actually went out, so it must reflect reality, not intent.
139
- # `Moderate.notify` returns the delivered boolean precisely for this gate.
368
+ # decision + redress). `decision_notified_at` is stamped only when the host's
369
+ # notifier confirms the message actually went out (Moderate.delivered!): a
370
+ # queued mail is not a delivered one, and the timestamp starts the appeal
371
+ # window (Art. 20(2)).
140
372
  def notify_reporter
141
373
  return if report.notifier_email.blank?
142
374
 
143
- delivered = Moderate.notify(
375
+ Moderate.notify(
144
376
  :report_decision,
145
377
  subject: report,
146
378
  actor: moderator,
147
379
  recipients: [report.reporter].compact,
148
380
  payload: decision_payload.merge(
149
- summary: "Decision on Report ##{report.id}: #{report.status}"
381
+ summary: "Decision on Report ##{report.id}: #{report.status}" \
382
+ "#{covered_note}"
150
383
  )
151
384
  )
152
-
153
- report.update_column(:decision_notified_at, Time.now) if delivered
154
385
  end
155
386
 
156
387
  # affected_user_decision → the content owner: the DSA Art. 17 statement of
@@ -160,11 +391,16 @@ module Moderate
160
391
  def notify_affected_user
161
392
  return unless report.actioned?
162
393
 
394
+ # Its removal was already announced by the decision that made it; a second
395
+ # statement only when this one also bans.
396
+ actions = report.resolution_actions.to_h
397
+ return if actions["content_already_removed_by"] && !truthy?(actions["ban_user"])
398
+
163
399
  affected = report.reported_user
164
400
  return if affected.blank?
165
401
  return unless affected.respond_to?(:email) && affected.email.present?
166
402
 
167
- delivered = Moderate.notify(
403
+ Moderate.notify(
168
404
  :affected_user_decision,
169
405
  subject: report,
170
406
  actor: moderator,
@@ -173,8 +409,6 @@ module Moderate
173
409
  summary: "Statement of reasons for Report ##{report.id}"
174
410
  )
175
411
  )
176
-
177
- report.update_column(:affected_user_notified_at, Time.now) if delivered
178
412
  end
179
413
 
180
414
  # The shared statement-of-reasons payload (Art. 17(3)). Every field the law
@@ -186,6 +420,8 @@ module Moderate
186
420
  # - automated: whether automated means participated in detection/decision
187
421
  # (Art. 17(3)(c)) — read off the report's recorded automated_processing
188
422
  # - reason: the moderator's human-readable note
423
+ # - reported_field_label: WHICH content the decision is about, in words the
424
+ # affected user recognizes ("Chat message", never the column name "body")
189
425
  # The HOST renders the redress/appeal copy (it names the jurisdiction); the gem
190
426
  # supplies the data + the report so the host can mint the signed appeal link.
191
427
  def decision_payload
@@ -193,6 +429,8 @@ module Moderate
193
429
  report_id: report.id,
194
430
  status: report.status,
195
431
  action: action_label,
432
+ reported_field: report.reported_field,
433
+ reported_field_label: report.reported_field_label,
196
434
  resolution_basis: report.resolution_basis,
197
435
  # The contractual ground (in-app reports) vs. the legal ground (DSA notices).
198
436
  category: report.category,
@@ -203,6 +441,10 @@ module Moderate
203
441
  # already participated.
204
442
  automated: automated_processing_used?,
205
443
  reason: report.resolution_note,
444
+ # "Already actioned by decision X": the content was gone when this was decided.
445
+ content_already_removed_by: report.resolution_actions.to_h["content_already_removed_by"],
446
+ # A minimum ("at least until"): confirmed delivery (Moderate.delivered!)
447
+ # can move it later (Art. 20(2)), never earlier.
206
448
  appeal_deadline_at: report.appeal_deadline_at
207
449
  }.compact
208
450
  end
@@ -218,6 +460,7 @@ module Moderate
218
460
  reported_user_id: report.reported_user_id,
219
461
  reportable_type: report.reportable_type,
220
462
  reportable_id: report.reportable_id,
463
+ reported_field: report.reported_field,
221
464
  actions: actions,
222
465
  resolution_basis: report.resolution_basis,
223
466
  automated: automated_processing_used?,
@@ -247,9 +490,19 @@ module Moderate
247
490
  def decision_visibility_for(status, actions)
248
491
  return "no_restriction" unless status == "actioned"
249
492
  return "account_suspended" if actions[:ban_user]
250
- return "content_removed" if actions[:remove_content]
251
493
 
252
- "other_restriction"
494
+ "content_removed" # resolve! requires one of the two (require_action!)
495
+ end
496
+
497
+ def require_action!(actions)
498
+ return if actions[:remove_content] || actions[:ban_user]
499
+
500
+ report.errors.add(:base, I18n.t(
501
+ "moderate.errors.resolve_needs_action",
502
+ default: "Resolving a report restricts something: remove the content, ban the account, or both. " \
503
+ "To close it without a restriction, dismiss it."
504
+ ))
505
+ raise ActiveRecord::RecordInvalid, report
253
506
  end
254
507
 
255
508
  def automated_processing_used?
@@ -1,27 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_model"
4
+
3
5
  module ActiveModel
4
6
  module Validations
7
+ # `validates :body, moderate: true` — the 0.x one-liner, kept for compatibility.
8
+ # It is the same check as `moderates :body, with: :wordlist, mode: :block`: the
9
+ # built-in offline :wordlist classifies the value, and an objectionable one adds
10
+ # the same `:objectionable_content` error (or your `message:`). No other filter
11
+ # policy applies to the field through it.
12
+ #
13
+ # Required by Moderate::Engine (it reopens ActiveModel::Validations, so Zeitwerk
14
+ # doesn't manage it).
5
15
  class ModerateValidator < EachValidator
6
16
  def validate_each(record, attribute, value)
7
17
  return if value.blank?
8
18
 
9
- begin
10
- text = value.to_s
11
- return if text.nil?
19
+ policy = Moderate::Configuration::FilterPolicy.new(
20
+ class_name: record.class.name, field: attribute.to_s, adapter: :wordlist, mode: :block
21
+ )
22
+ return unless Moderate.classify(value, policy: policy).flagged?
12
23
 
13
- if Moderate::Text.bad_words?(text)
14
- record.errors.add(attribute, options[:message] || Moderate.configuration.error_message)
15
- end
16
- rescue Moderate::Error => e
17
- Rails.logger.error "Moderate validation error: #{e.message}"
18
- record.errors.add(attribute, "Moderation check failed: #{e.message}")
19
- rescue StandardError => e
20
- Rails.logger.error "Unexpected error in moderate validation: #{e.message}"
21
- record.errors.add(attribute, "Moderation check failed")
22
- end
24
+ record.errors.add(attribute, options[:message] || :objectionable_content)
23
25
  end
24
26
  end
25
-
26
27
  end
27
28
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moderate
4
- VERSION = "1.0.0.beta1"
4
+ VERSION = "1.0.0"
5
5
  end