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
data/lib/moderate.rb CHANGED
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "set"
4
-
5
3
  # We lean on a couple of ActiveSupport core extensions (notably String#constantize
6
4
  # for the lazy class-name resolution that lets `config.user_class = "User"` work
7
5
  # before the User model is loaded). In a Rails host these are always present; we
@@ -9,6 +7,8 @@ require "set"
9
7
  # context (a console, a non-Rails test) without booting all of Rails. ActiveSupport
10
8
  # is a declared runtime dependency in the gemspec.
11
9
  require "active_support/core_ext/string/inflections"
10
+ require "active_support/core_ext/string/filters"
11
+ require "active_support/core_ext/object/blank"
12
12
 
13
13
  require_relative "moderate/version"
14
14
  require_relative "moderate/errors"
@@ -31,7 +31,7 @@ require_relative "moderate/macros"
31
31
  # the public DSA notice form, and registers the macros on ActiveRecord. Required
32
32
  # only when Rails is present so the value objects + macros above can still be used
33
33
  # in a plain-Ruby context (a console, a non-Rails script) without booting an engine.
34
- require_relative "moderate/engine" if defined?(::Rails::Engine)
34
+ require_relative "moderate/engine" if defined?(Rails::Engine)
35
35
 
36
36
  # `moderate` — a complete Trust & Safety layer for Rails apps with user-generated
37
37
  # content: report, block, filter, moderate, appeal, comply (EU DSA / Apple App
@@ -44,6 +44,27 @@ require_relative "moderate/engine" if defined?(::Rails::Engine)
44
44
  # The heavy lifting (the AR models, the decision services, the controllers) lives
45
45
  # in the autoloaded `app/` tree and calls back into these facade methods.
46
46
  module Moderate
47
+ # Where each of the gem's value lists is labeled for humans, under `moderate.`
48
+ # in I18n. The gem ships English and Spanish for every value it defines
49
+ # (config/locales/*.yml); a host overrides any key in its own locale files (they
50
+ # load after the gem's) and adds the languages it needs. The public DSA notice
51
+ # form has always read the legal_reasons, content_types and member_states keys.
52
+ TAXONOMY_LABEL_SCOPES = {
53
+ category: "categories", # in-app community report categories (Report#category)
54
+ legal_reason: "legal_reasons", # DSA statement-of-reasons grounds (Report#legal_reason)
55
+ member_state: "member_states", # EU member-state codes (Report#legal_country_code)
56
+ content_type: "content_types", # Report#content_type
57
+ resolution_basis: "resolution_bases", # the Art. 17 ground of a decision (Report#resolution_basis)
58
+ decision: "decisions", # the restriction imposed (Report#decision_visibility, a decision's :action)
59
+ intake_kind: "intake_kinds", # community report vs. DSA notice (Report#intake_kind)
60
+ report_status: "statuses.report",
61
+ flag_status: "statuses.flag",
62
+ appeal_status: "statuses.appeal",
63
+ flag_source: "flag_sources", # Flag#source (host adapter names fall back to humanize)
64
+ flag_mode: "flag_modes", # Flag#mode
65
+ appeal_source: "appeal_sources" # Appeal#source
66
+ }.freeze
67
+
47
68
  class << self
48
69
  # --- Configuration --------------------------------------------------------
49
70
 
@@ -56,7 +77,7 @@ module Moderate
56
77
  # Read alias. Some ecosystem gems expose `.configuration`; we keep `.config`
57
78
  # as the canonical name (matches the README's `Moderate.config`) and provide
58
79
  # `.configuration` only as a courtesy alias for muscle memory.
59
- alias_method :configuration, :config
80
+ alias configuration config
60
81
 
61
82
  # The host's entry point: `Moderate.configure do |config| ... end`.
62
83
  #
@@ -120,6 +141,8 @@ module Moderate
120
141
  #
121
142
  # Stored as a Set of STRING class names (not Class objects) so we never pin a
122
143
  # class in memory across a Zeitwerk reload in development; we constantize on read.
144
+ #
145
+ # @api private: called by Moderate::Reportable on inclusion.
123
146
  def register_reportable(klass)
124
147
  name = klass.is_a?(Class) ? klass.name : klass.to_s
125
148
  return if name.nil? || name.empty?
@@ -139,6 +162,74 @@ module Moderate
139
162
  end
140
163
  end
141
164
 
165
+ # --- Reportable field labels ----------------------------------------------
166
+
167
+ # The human name of a reported field — what the queue, the statement of
168
+ # reasons, and the appeal page print instead of the raw column name. A
169
+ # moderator reading «Body» on a chat-message report has to guess what was
170
+ # reported; «Chat message» they can act on.
171
+ #
172
+ # Moderate.reportable_field_label(message, :body) # => "Chat message"
173
+ # Moderate.reportable_field_label(Message, "body") # same, from the class
174
+ # Moderate.reportable_field_label("Message", "body") # same, from a stored type
175
+ #
176
+ # Resolution, first non-blank answer wins:
177
+ #
178
+ # 1. the `labels:` declared next to the field
179
+ # (`has_reportable_content :body, labels: { body: "Chat message" }`) —
180
+ # a String, or a callable evaluated now (so a lambda can use I18n);
181
+ # 2. i18n `moderate.reportable_fields.<model>.<field>`, where <model> is the
182
+ # model's `model_name.i18n_key` (`chats/message` for `Chats::Message`),
183
+ # walking up to STI parents that are themselves reportable;
184
+ # 3. i18n `moderate.reportable_fields.<field>` — one label for every model
185
+ # that has a field by that name (the gem ships a few generic ones);
186
+ # 4. `field.to_s.humanize`, so an unlabeled field still renders something.
187
+ #
188
+ # Returns nil for a blank field: a whole-record report has no field to name,
189
+ # and callers decide how to say that in their own UI.
190
+ #
191
+ # `record_or_class` may be a record, a class, or a class-name String (a
192
+ # report's `reportable_type`), so a label still resolves after the reported
193
+ # record is gone. An unknown class name skips straight to steps 3–4.
194
+ def reportable_field_label(record_or_class, field)
195
+ field_s = field.to_s.squish
196
+ return if field_s.empty?
197
+
198
+ klass, class_name = reportable_label_class(record_or_class)
199
+
200
+ translated = reportable_label_i18n_keys(klass, class_name, field_s)
201
+ .lazy.filter_map { |key| reportable_label_i18n(key) }
202
+
203
+ declared_reportable_field_label(klass, field_s) || translated.first || field_s.humanize
204
+ end
205
+
206
+ # --- Taxonomy labels ------------------------------------------------------
207
+
208
+ # The human label for one value of one of the gem's taxonomies, in the current
209
+ # locale — what a report form, a moderation queue or a statement of reasons
210
+ # prints instead of the stored value:
211
+ #
212
+ # Moderate.taxonomy_label(:category, "harassment") # => "Harassment" ("Acoso" in :es)
213
+ # Moderate.taxonomy_label(:legal_reason, :scams_fraud) # => "Scams and fraud"
214
+ # Moderate.taxonomy_label(:flag_status, flag.status) # => "Awaiting review"
215
+ #
216
+ # Reads `moderate.<scope>.<value>` (TAXONOMY_LABEL_SCOPES), falling back to the
217
+ # value humanized (a member-state code falls back to itself), so a host's own
218
+ # category or adapter name still renders. nil/blank in, nil out. An unknown kind
219
+ # raises: a typo'd kind would otherwise silently print raw values forever.
220
+ def taxonomy_label(kind, value)
221
+ scope = TAXONOMY_LABEL_SCOPES.fetch(kind.to_sym) do
222
+ raise ArgumentError, "unknown taxonomy #{kind.inspect}; one of: #{TAXONOMY_LABEL_SCOPES.keys.join(", ")}"
223
+ end
224
+ value_s = value.to_s.strip
225
+ return if value_s.empty?
226
+
227
+ fallback = kind.to_sym == :member_state ? value_s : value_s.tr("/", " ").humanize
228
+ return fallback unless defined?(I18n)
229
+
230
+ I18n.t("moderate.#{scope}.#{value_s}", default: fallback)
231
+ end
232
+
142
233
  # --- Notify / audit hooks -------------------------------------------------
143
234
 
144
235
  # Dispatch a notifiable moment to the host's `config.notify` hook.
@@ -159,6 +250,8 @@ module Moderate
159
250
  # We never let a host hook's exception bubble into a moderation action (a slow
160
251
  # or broken mailer must not roll back a decision). On error we audit the failure
161
252
  # and return false.
253
+ #
254
+ # @api private: the gem's dispatch into config.notify; hosts wire the hook, they don't call this.
162
255
  def notify(event_or_name, **payload)
163
256
  event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload)
164
257
 
@@ -167,7 +260,7 @@ module Moderate
167
260
  # A lambda may legitimately return a delivery handle, a job, true, etc.
168
261
  # Anything truthy counts as delivered; nil/false counts as not-delivered.
169
262
  result ? true : false
170
- rescue => error
263
+ rescue StandardError => error
171
264
  audit(
172
265
  name: :notify_failed,
173
266
  subject: event.subject,
@@ -187,11 +280,13 @@ module Moderate
187
280
  # same `case`. Like notify, an audit hook exception is swallowed (turned into a
188
281
  # logged warning) so it can never roll back the action it's recording — audit is
189
282
  # observational, never load-bearing.
283
+ #
284
+ # @api private: the gem's dispatch into config.audit.
190
285
  def audit(event_or_name = nil, **payload)
191
286
  event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload)
192
287
  config.audit.call(event)
193
288
  true
194
- rescue => error
289
+ rescue StandardError => error
195
290
  logger&.warn("[moderate] audit hook failed for #{event&.name}: #{error.class}: #{error.message}")
196
291
  false
197
292
  end
@@ -201,27 +296,135 @@ module Moderate
201
296
  # block row's creation time so hosts can apply time-aware teardown without
202
297
  # reaching back into the database. Kept as a facade method so Moderate::Block has
203
298
  # one call site and doesn't reach into config internals. No-op by default.
299
+ #
300
+ # @api private: called by Moderate::Block.
204
301
  def run_on_block(blocker:, blocked:, at:)
205
302
  config.on_block.call(blocker: blocker, blocked: blocked, at: at)
206
303
  end
207
304
 
208
305
  # Apply a ban via the host's `ban_handler` (suspend!, soft-delete, flip a flag,
209
- # whatever "banned" means in the host's domain). Keyword-arg signature. No-op by
210
- # default — the surrounding decision still audits and notifies even if no ban is
211
- # wired, so the action is never silently dropped (docs/configuration.md).
212
- def apply_ban(user:, by:, reason:)
306
+ # whatever "banned" means in the host's domain). Keyword-arg signature.
307
+ #
308
+ # Raises ConfigurationError when no ban_handler is wired: auditing and announcing
309
+ # `user_banned` for a ban nothing applied would put a false statement in the
310
+ # audit trail and in the person's inbox.
311
+ #
312
+ # `announce: false` (the decision services) audits now but leaves the
313
+ # `user_banned` notice to the caller: it returns the event's keywords for
314
+ # `Moderate.announce_ban`, so the notice can wait for the decision's commit.
315
+ #
316
+ # @api private: decisions ban through `report.resolve!(ban_user: true)` or
317
+ # `flag.action!(ban_user: true)`, which also send the statement of reasons.
318
+ def apply_ban(user:, by:, reason:, announce: true)
319
+ ensure_ban_handler!
213
320
  result = config.ban_handler.call(user: user, by: by, reason: reason)
214
321
  payload = {
215
322
  user_id: user&.id,
216
323
  reason: reason,
217
- summary: "user #{user&.id || '(unknown)'} banned"
324
+ summary: "user #{user&.id || "(unknown)"} banned"
218
325
  }.compact
326
+ event = { subject: user, actor: by, recipients: [user].compact, payload: payload }
327
+
328
+ audit(:user_banned, **event)
329
+ return event unless announce
219
330
 
220
- audit(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
221
- notify(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload)
331
+ announce_ban(**event)
222
332
  result
223
333
  end
224
334
 
335
+ # The `user_banned` notice for a ban applied with `apply_ban(announce: false)`.
336
+ #
337
+ # @api private: used by the decision services.
338
+ def announce_ban(**event)
339
+ notify(:user_banned, **event)
340
+ end
341
+
342
+ # --- Responsibility -------------------------------------------------------
343
+
344
+ # The PERSON responsible for `record` — the account a decision notifies and a
345
+ # ban reaches — or nil when nobody is. It asks the record's `reported_owner`
346
+ # (the Reportable contract) and keeps the answer only if it is an instance of
347
+ # the configured `user_class`.
348
+ #
349
+ # Why the type check lives here: content is often spoken by something that
350
+ # isn't a person — a support desk, a bot, an organization. `chats` answers
351
+ # `author || sender` for a message, which is a desk for an unsigned support
352
+ # reply. Every place moderate stores an owner is a `user_class` association
353
+ # (Report#reported_user, Flag#owner), so a desk there would raise
354
+ # ActiveRecord::AssociationTypeMismatch and take the report down with it.
355
+ # "Nobody to reach" is the honest answer: the report still files, the evidence
356
+ # is still kept, content can still be removed; only a decision notice or a ban
357
+ # has no addressee.
358
+ #
359
+ # A reportable that doesn't define `reported_owner` still raises (loudly, at
360
+ # the first report) — that omission is a bug in the host, not "nobody".
361
+ def responsible_user(record)
362
+ return if record.nil? || !record.respond_to?(:reported_owner)
363
+
364
+ owner = record.reported_owner
365
+ owner if owner.is_a?(user_class)
366
+ end
367
+
368
+ # Raised before anything changes when a ban is asked for and nothing would apply
369
+ # it. Public so a decision service can check before it takes any lock.
370
+ #
371
+ # @api private: used by the decision services.
372
+ def ensure_ban_handler!
373
+ return true if config.ban_handler_configured?
374
+
375
+ raise ConfigurationError,
376
+ "a ban was requested but no config.ban_handler is set, so nothing would suspend the account " \
377
+ "while the audit trail and the statement of reasons said it was. Wire one in " \
378
+ "config/initializers/moderate.rb, e.g. " \
379
+ "`config.ban_handler = ->(user:, by:, reason:) { user.suspend!(reason: reason) }`."
380
+ end
381
+
382
+ # --- Transactions -----------------------------------------------------------
383
+
384
+ # Hold `record`'s row lock (SELECT … FOR UPDATE) for the block, or just run the
385
+ # block when there's no saved row to lock. The lock is taken by query, not with
386
+ # `record.with_lock`, so the caller's object is neither reloaded nor refused for
387
+ # carrying unsaved changes.
388
+ #
389
+ # THE LOCK ORDER — one, everywhere; a path may skip a step, never reverse two:
390
+ #
391
+ # flag → content → person → report
392
+ #
393
+ # - Flag#action!/#dismiss! (ResolveFlag): the flag; to enforce, the content;
394
+ # to ban, the person; then the report it resolves.
395
+ # - Report#resolve!/#dismiss! (ResolveReport): the content; to ban, the person;
396
+ # then the report.
397
+ # - Flag.flag! / Flag.flag_for_review!: no lock first. The database allows one
398
+ # pending flag per content + field (the unique pending_key), so they insert,
399
+ # and only on the collision lock a row: the waiting flag's. Holding no content
400
+ # lock, they never hold content while waiting on a flag (the inversion against
401
+ # ResolveFlag); the read of the waiting flag is an equality match on a unique
402
+ # index, a lock on that one row while it exists.
403
+ # - "The flag" is, for a pending flag, its pending_key index entry and then its
404
+ # row (Flag#lock!), the order a colliding insert takes them in on InnoDB.
405
+ # - The person's lock is taken only when a decision can ban: removal-only
406
+ # enforcement never waits on, or holds, an account's row.
407
+ #
408
+ # Under the lock, every check that decides "already done" (a covering decision, a
409
+ # prior ban, whether content is gone, the flag waiting after a collision) is
410
+ # itself a LOCKING read (`.lock`, `reload(lock: true)`). Under MySQL's REPEATABLE
411
+ # READ a plain read returns the transaction's snapshot, which may predate the lock,
412
+ # and would miss what the decision we waited for committed; a locking read returns
413
+ # the latest committed row on every database. Those reads come after the locks that serialize them
414
+ # (content, and person for a ban), so two decisions never hold gap locks on the
415
+ # reports table at once.
416
+ #
417
+ # @api private: the lock order the decision services rely on.
418
+ def with_row_lock(record)
419
+ model = record.class
420
+ return yield unless record.respond_to?(:persisted?) && record.persisted? && model.respond_to?(:lock)
421
+
422
+ model.transaction do
423
+ model.unscoped.lock.where(model.primary_key => record.id).ids
424
+ yield
425
+ end
426
+ end
427
+
225
428
  # --- Blocking SSOT --------------------------------------------------------
226
429
 
227
430
  # The single source-of-truth list of user ids "related to" `user` via a block
@@ -232,10 +435,14 @@ module Moderate
232
435
  # Post.where.not(user_id: Moderate.blocked_ids_for(current_user))
233
436
  #
234
437
  # Delegates to Moderate::Block (the model that owns the block SQL) so the join
235
- # logic lives in exactly one place. Returns an empty array for a blank user.
438
+ # logic lives in exactly one place.
439
+ #
440
+ # ALWAYS a relation selecting user ids (`user_class.where(...).select(:id)`), for
441
+ # a blank user too (then an empty one): compose it into `where.not(user_id: …)`
442
+ # and it stays one subquery. Call `.ids` for an Array of id values. (beta2 gave
443
+ # `[]` for nil and a relation otherwise; a host composing the two got an Array of
444
+ # User records from `relation | array`, and `include?(id)` silently missed.)
236
445
  def blocked_ids_for(user)
237
- return [] if user.nil?
238
-
239
446
  Block.related_user_ids(user)
240
447
  end
241
448
 
@@ -296,6 +503,8 @@ module Moderate
296
503
  # The locale for copy the gem generates itself, falling back to the app's
297
504
  # default. Read lazily so a host setting I18n.default_locale after our boot
298
505
  # still wins.
506
+ #
507
+ # @api private: read config.locale.
299
508
  def locale
300
509
  config.locale || (defined?(I18n) ? I18n.default_locale : :en)
301
510
  end
@@ -325,6 +534,44 @@ module Moderate
325
534
  }
326
535
  end
327
536
 
537
+ # Opt-in retention: redact the personal data in reports, flags and appeals whose
538
+ # decision is final and older than `older_than` (default 1 year, never less than
539
+ # the 6-month appeal window), keeping every row and its decision. Nothing calls
540
+ # it unless the host schedules it. See Moderate::Retention and docs/compliance.md.
541
+ #
542
+ # Moderate.purge(older_than: 1.year) # => { reports: 12, flags: 40, appeals: 1 }
543
+ def purge(**)
544
+ Retention.purge(**)
545
+ end
546
+
547
+ # The host's notifier confirms that a decision notice ACTUALLY went out — call it
548
+ # from the mailer's delivery (Action Mailer's `after_deliver`), not when the mail
549
+ # was queued:
550
+ #
551
+ # Moderate.delivered!(report, :affected_user) # the statement of reasons
552
+ # Moderate.delivered!(report, :decision) # the reporter's / notifier's outcome
553
+ #
554
+ # Stamps `affected_user_notified_at` / `decision_notified_at` once and starts the
555
+ # six-month appeal window from that moment (DSA Art. 20(2)) if that is later than
556
+ # the decision-time deadline; never earlier, and a second call changes nothing.
557
+ # Without it the window stays decision + 6 months.
558
+ def delivered!(report, recipient, at: Time.current)
559
+ report.record_delivery!(recipient, at: at)
560
+ end
561
+
562
+ # Keep `report` (its appeals, the flags on its content) out of the purge until
563
+ # `until:`: an out-of-court dispute, a court case, an authority's request.
564
+ #
565
+ # Moderate.hold(report, until: 1.year.from_now, by: admin, reason: "ODS case 2026-114")
566
+ # Moderate.release_hold(report, by: admin)
567
+ def hold(report, **)
568
+ Retention.hold(report, **)
569
+ end
570
+
571
+ def release_hold(report, **)
572
+ Retention.release_hold(report, **)
573
+ end
574
+
328
575
  private
329
576
 
330
577
  # Median seconds between paired (created_at, resolved_at) timestamps; 0 when empty.
@@ -338,7 +585,80 @@ module Moderate
338
585
  # The internal reportable-name set. Set (not Array) so re-including the concern
339
586
  # is idempotent.
340
587
  def reportable_registry
341
- @reportable_classes ||= Set.new
588
+ @reportable_registry ||= Set.new
589
+ end
590
+
591
+ # [Class or nil, class name or nil] for whatever reportable_field_label got.
592
+ def reportable_label_class(record_or_class)
593
+ case record_or_class
594
+ when nil then [nil, nil]
595
+ when Class then [record_or_class, record_or_class.name]
596
+ when String, Symbol
597
+ name = record_or_class.to_s
598
+ [name.safe_constantize, name.presence]
599
+ else
600
+ [record_or_class.class, record_or_class.class.name]
601
+ end
602
+ end
603
+
604
+ # A label is display copy, so it must never take a page down: a host lambda
605
+ # that raises is logged and treated as "no declared label" (the i18n/humanize
606
+ # fallbacks still answer), the same way a view should treat a nil.
607
+ def declared_reportable_field_label(klass, field_s)
608
+ return unless klass.respond_to?(:moderation_reportable_field_labels)
609
+
610
+ label = klass.moderation_reportable_field_labels[field_s]
611
+ label = label.call if label.respond_to?(:call)
612
+ label.to_s.presence
613
+ rescue StandardError => error
614
+ logger&.warn("[moderate] label for #{klass.name}##{field_s} raised #{error.class}: #{error.message}")
615
+ nil
616
+ end
617
+
618
+ # Model-scoped keys first (the class, then its reportable STI parents), then
619
+ # the field-wide key. A class name that no longer constantizes still gets its
620
+ # own model key, derived the way ActiveModel::Name would.
621
+ def reportable_label_i18n_keys(klass, class_name, field_s)
622
+ model_keys =
623
+ if klass
624
+ chain = klass.ancestors.select { |ancestor| ancestor.is_a?(Class) && reportable_label_model?(ancestor) }
625
+ chain = [klass] if chain.empty?
626
+ chain.filter_map { |ancestor| reportable_label_model_key(ancestor) }
627
+ elsif class_name
628
+ [class_name.underscore]
629
+ else
630
+ []
631
+ end
632
+
633
+ model_keys.uniq.map { |model_key| "moderate.reportable_fields.#{model_key}.#{field_s}" } +
634
+ ["moderate.reportable_fields.#{field_s}"]
635
+ end
636
+
637
+ def reportable_label_model?(klass)
638
+ defined?(Moderate::Reportable) && klass.include?(Moderate::Reportable)
639
+ end
640
+
641
+ # `model_name` constantizes the class's namespace, which can raise for a class
642
+ # whose name doesn't match a loaded module; the underscored name is the same
643
+ # key ActiveModel would derive for a non-isolated namespace.
644
+ def reportable_label_model_key(klass)
645
+ return klass.model_name.i18n_key.to_s if klass.respond_to?(:model_name)
646
+
647
+ klass.name&.underscore
648
+ rescue NameError
649
+ klass.name&.underscore
650
+ end
651
+
652
+ # A translated String, or nil. Two guards: `I18n.t` with no default returns a
653
+ # "Translation missing" string rather than nil, so we ask `exists?` first;
654
+ # and the model-scoped and field-wide keys share one namespace, so a field
655
+ # called `listing` on a host that also has a `Listing` model would read the
656
+ # model's Hash of labels — anything that isn't a String is not a label.
657
+ def reportable_label_i18n(key)
658
+ return unless defined?(I18n) && I18n.exists?(key)
659
+
660
+ value = I18n.t(key)
661
+ value.presence if value.is_a?(String)
342
662
  end
343
663
 
344
664
  # Coerce whatever an adapter returned into a Moderate::Result, stamping the
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded into the host's `bin/rails -T` by Moderate::Engine (every engine loads
4
+ # its lib/tasks/**/*.rake).
5
+ namespace :moderate do
6
+ desc "Redact personal data from moderation records decided more than OLDER_THAN_DAYS " \
7
+ "(default 365, at least the 6-month appeal window) ago. DRY_RUN=1 only counts. " \
8
+ "See docs/compliance.md#retention."
9
+ task purge: :environment do
10
+ days = ENV.fetch("OLDER_THAN_DAYS", "365")
11
+ raise ArgumentError, "OLDER_THAN_DAYS must be a whole number of days, got #{days.inspect}" unless days.match?(/\A\d+\z/)
12
+
13
+ dry_run = %w[1 true yes].include?(ENV["DRY_RUN"].to_s.downcase)
14
+ counts = Moderate.purge(older_than: days.to_i.days, dry_run: dry_run)
15
+ verb = dry_run ? "would redact" : "redacted"
16
+ held = counts[:held]
17
+ puts "moderate:purge #{verb} #{counts[:reports]} reports, #{counts[:flags]} flags, " \
18
+ "#{counts[:appeals]} appeals decided more than #{days} days ago, " \
19
+ "#{dry_run ? "would delete" : "deleted"} #{counts[:classifications]} classification rows " \
20
+ "(#{counts[:claims_kept]} kept for a live claim); kept for a legal hold: " \
21
+ "#{held[:reports]} reports, #{held[:flags]} flags, #{held[:appeals]} appeals"
22
+ end
23
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moderate
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0.beta1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-06-03 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -15,7 +15,7 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 7.1.0
18
+ version: 8.0.5.1
19
19
  - - "<"
20
20
  - !ruby/object:Gem::Version
21
21
  version: '9.0'
@@ -25,7 +25,7 @@ dependencies:
25
25
  requirements:
26
26
  - - ">="
27
27
  - !ruby/object:Gem::Version
28
- version: 7.1.0
28
+ version: 8.0.5.1
29
29
  - - "<"
30
30
  - !ruby/object:Gem::Version
31
31
  version: '9.0'
@@ -35,7 +35,7 @@ dependencies:
35
35
  requirements:
36
36
  - - ">="
37
37
  - !ruby/object:Gem::Version
38
- version: 7.1.0
38
+ version: 8.0.5.1
39
39
  - - "<"
40
40
  - !ruby/object:Gem::Version
41
41
  version: '9.0'
@@ -45,7 +45,7 @@ dependencies:
45
45
  requirements:
46
46
  - - ">="
47
47
  - !ruby/object:Gem::Version
48
- version: 7.1.0
48
+ version: 8.0.5.1
49
49
  - - "<"
50
50
  - !ruby/object:Gem::Version
51
51
  version: '9.0'
@@ -69,7 +69,7 @@ dependencies:
69
69
  requirements:
70
70
  - - ">="
71
71
  - !ruby/object:Gem::Version
72
- version: 7.1.0
72
+ version: 8.0.5.1
73
73
  - - "<"
74
74
  - !ruby/object:Gem::Version
75
75
  version: '9.0'
@@ -79,7 +79,7 @@ dependencies:
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: 7.1.0
82
+ version: 8.0.5.1
83
83
  - - "<"
84
84
  - !ruby/object:Gem::Version
85
85
  version: '9.0'
@@ -133,24 +133,32 @@ files:
133
133
  - app/views/moderate/notices/new.html.erb
134
134
  - app/views/moderate/transparency_reports/_summary_card.html.erb
135
135
  - app/views/moderate/transparency_reports/show.html.erb
136
+ - config/locales/en.yml
137
+ - config/locales/es.yml
136
138
  - config/moderate/blocklists/en.yml
137
139
  - config/moderate/blocklists/es.yml
138
140
  - config/routes.rb
141
+ - context7.json
142
+ - db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb
143
+ - db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb
144
+ - docs/chats.md
139
145
  - docs/compliance.md
140
146
  - docs/configuration.md
141
147
  - docs/dsa-notice-form.md
148
+ - docs/images/moderate-user-report-block-actions.webp
142
149
  - docs/madmin.md
143
150
  - docs/notifications.md
144
151
  - examples/aws_rekognition_adapter.rb
145
152
  - examples/openai_moderation_adapter.rb
146
- - gemfiles/rails_7.1.gemfile
147
- - gemfiles/rails_7.2.gemfile
153
+ - examples/regexp_pattern_adapter.rb
154
+ - gemfiles/rails_8.0.gemfile
148
155
  - gemfiles/rails_8.1.gemfile
149
156
  - lib/generators/moderate/install_generator.rb
150
157
  - lib/generators/moderate/templates/create_moderate_tables.rb.erb
151
158
  - lib/generators/moderate/templates/initializer.rb
152
159
  - lib/generators/moderate/views_generator.rb
153
160
  - lib/moderate.rb
161
+ - lib/moderate/attachments.rb
154
162
  - lib/moderate/configuration.rb
155
163
  - lib/moderate/engine.rb
156
164
  - lib/moderate/errors.rb
@@ -163,22 +171,24 @@ files:
163
171
  - lib/moderate/models/appeal.rb
164
172
  - lib/moderate/models/application_record.rb
165
173
  - lib/moderate/models/block.rb
174
+ - lib/moderate/models/classification.rb
166
175
  - lib/moderate/models/concerns/actor.rb
167
176
  - lib/moderate/models/concerns/content_filterable.rb
177
+ - lib/moderate/models/concerns/deferred_notices.rb
168
178
  - lib/moderate/models/concerns/reportable.rb
169
179
  - lib/moderate/models/flag.rb
170
180
  - lib/moderate/models/report.rb
171
181
  - lib/moderate/result.rb
182
+ - lib/moderate/retention.rb
172
183
  - lib/moderate/services/intake_appeal.rb
173
184
  - lib/moderate/services/intake_notice.rb
174
185
  - lib/moderate/services/intake_report.rb
175
186
  - lib/moderate/services/resolve_appeal.rb
176
187
  - lib/moderate/services/resolve_flag.rb
177
188
  - lib/moderate/services/resolve_report.rb
178
- - lib/moderate/text.rb
179
189
  - lib/moderate/text_validator.rb
180
190
  - lib/moderate/version.rb
181
- - lib/moderate/word_list.rb
191
+ - lib/tasks/moderate.rake
182
192
  - log/development.log
183
193
  - log/test.log
184
194
  - sig/moderate.rbs
@@ -199,14 +209,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
199
209
  requirements:
200
210
  - - ">="
201
211
  - !ruby/object:Gem::Version
202
- version: 3.2.0
212
+ version: 3.3.0
203
213
  required_rubygems_version: !ruby/object:Gem::Requirement
204
214
  requirements:
205
215
  - - ">="
206
216
  - !ruby/object:Gem::Version
207
217
  version: '0'
208
218
  requirements: []
209
- rubygems_version: 3.6.2
219
+ rubygems_version: 3.6.9
210
220
  specification_version: 4
211
221
  summary: Let your Rails users report content and block each other (Trust & Safety)
212
222
  test_files: []
@@ -1,36 +0,0 @@
1
- # This file was generated by Appraisal
2
-
3
- source "https://rubygems.org"
4
-
5
- gem "rake", "~> 13.0"
6
- gem "rails", "~> 7.2.0"
7
-
8
- group :development do
9
- gem "appraisal"
10
- gem "web-console"
11
- gem "standard"
12
- gem "rubocop", "~> 1.0"
13
- gem "rubocop-minitest", "~> 0.35"
14
- gem "rubocop-performance", "~> 1.0"
15
- end
16
-
17
- group :test do
18
- gem "minitest", "~> 5.0"
19
- gem "mocha"
20
- gem "simplecov", require: false
21
- gem "activejob"
22
- gem "actionmailer"
23
- gem "activestorage"
24
- gem "sqlite3"
25
- gem "pg"
26
- gem "mysql2"
27
- gem "bootsnap", require: false
28
- gem "puma"
29
- gem "importmap-rails"
30
- gem "sprockets-rails"
31
- gem "stimulus-rails"
32
- gem "turbo-rails"
33
- gem "rdoc", ">= 7.0"
34
- end
35
-
36
- gemspec path: "../"