moderate 1.0.0.beta2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +96 -1
- data/.simplecov +2 -10
- data/AGENTS.md +1 -1
- data/Appraisals +10 -8
- data/CHANGELOG.md +654 -52
- data/CLAUDE.md +1 -1
- data/README.md +147 -7
- data/Rakefile +2 -0
- data/app/controllers/concerns/moderate/moderation.rb +23 -11
- data/app/controllers/moderate/appeals_controller.rb +9 -5
- data/app/controllers/moderate/application_controller.rb +3 -3
- data/app/controllers/moderate/notices_controller.rb +3 -6
- data/app/helpers/moderate/engine_helper.rb +32 -11
- data/app/views/moderate/appeals/new.html.erb +6 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
- data/app/views/moderate/transparency_reports/show.html.erb +5 -5
- data/config/locales/en.yml +200 -0
- data/config/locales/es.yml +177 -0
- data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
- data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
- data/docs/chats.md +140 -0
- data/docs/compliance.md +92 -3
- data/docs/configuration.md +30 -2
- data/docs/dsa-notice-form.md +1 -1
- data/docs/madmin.md +44 -14
- data/docs/notifications.md +17 -4
- data/examples/aws_rekognition_adapter.rb +6 -6
- data/examples/openai_moderation_adapter.rb +1 -1
- data/examples/regexp_pattern_adapter.rb +68 -0
- data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
- data/gemfiles/rails_8.1.gemfile +6 -5
- data/lib/generators/moderate/install_generator.rb +25 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
- data/lib/generators/moderate/templates/initializer.rb +9 -15
- data/lib/generators/moderate/views_generator.rb +3 -3
- data/lib/moderate/attachments.rb +137 -0
- data/lib/moderate/configuration.rb +87 -12
- data/lib/moderate/engine.rb +86 -25
- data/lib/moderate/event.rb +21 -0
- data/lib/moderate/filters/base.rb +1 -0
- data/lib/moderate/filters/wordlist.rb +5 -2
- data/lib/moderate/jobs/classify_job.rb +96 -16
- data/lib/moderate/label.rb +2 -2
- data/lib/moderate/macros.rb +8 -3
- data/lib/moderate/models/appeal.rb +10 -1
- data/lib/moderate/models/block.rb +23 -19
- data/lib/moderate/models/classification.rb +24 -0
- data/lib/moderate/models/concerns/actor.rb +17 -2
- data/lib/moderate/models/concerns/content_filterable.rb +181 -55
- data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
- data/lib/moderate/models/concerns/reportable.rb +84 -4
- data/lib/moderate/models/flag.rb +182 -19
- data/lib/moderate/models/report.rb +180 -16
- data/lib/moderate/result.rb +3 -10
- data/lib/moderate/retention.rb +241 -0
- data/lib/moderate/services/intake_notice.rb +10 -10
- data/lib/moderate/services/intake_report.rb +42 -13
- data/lib/moderate/services/resolve_appeal.rb +8 -2
- data/lib/moderate/services/resolve_flag.rb +183 -35
- data/lib/moderate/services/resolve_report.rb +288 -35
- data/lib/moderate/text_validator.rb +15 -14
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +337 -17
- data/lib/tasks/moderate.rake +23 -0
- metadata +22 -14
- data/gemfiles/rails_7.2.gemfile +0 -36
- data/lib/moderate/text.rb +0 -38
- 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?(
|
|
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
|
-
|
|
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.
|
|
210
|
-
#
|
|
211
|
-
#
|
|
212
|
-
|
|
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 ||
|
|
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
|
-
|
|
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.
|
|
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
|
-
@
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
82
|
+
version: 8.0.5.1
|
|
83
83
|
- - "<"
|
|
84
84
|
- !ruby/object:Gem::Version
|
|
85
85
|
version: '9.0'
|
|
@@ -133,10 +133,15 @@ 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
|
|
139
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
|
|
140
145
|
- docs/compliance.md
|
|
141
146
|
- docs/configuration.md
|
|
142
147
|
- docs/dsa-notice-form.md
|
|
@@ -145,14 +150,15 @@ files:
|
|
|
145
150
|
- docs/notifications.md
|
|
146
151
|
- examples/aws_rekognition_adapter.rb
|
|
147
152
|
- examples/openai_moderation_adapter.rb
|
|
148
|
-
-
|
|
149
|
-
- gemfiles/
|
|
153
|
+
- examples/regexp_pattern_adapter.rb
|
|
154
|
+
- gemfiles/rails_8.0.gemfile
|
|
150
155
|
- gemfiles/rails_8.1.gemfile
|
|
151
156
|
- lib/generators/moderate/install_generator.rb
|
|
152
157
|
- lib/generators/moderate/templates/create_moderate_tables.rb.erb
|
|
153
158
|
- lib/generators/moderate/templates/initializer.rb
|
|
154
159
|
- lib/generators/moderate/views_generator.rb
|
|
155
160
|
- lib/moderate.rb
|
|
161
|
+
- lib/moderate/attachments.rb
|
|
156
162
|
- lib/moderate/configuration.rb
|
|
157
163
|
- lib/moderate/engine.rb
|
|
158
164
|
- lib/moderate/errors.rb
|
|
@@ -165,22 +171,24 @@ files:
|
|
|
165
171
|
- lib/moderate/models/appeal.rb
|
|
166
172
|
- lib/moderate/models/application_record.rb
|
|
167
173
|
- lib/moderate/models/block.rb
|
|
174
|
+
- lib/moderate/models/classification.rb
|
|
168
175
|
- lib/moderate/models/concerns/actor.rb
|
|
169
176
|
- lib/moderate/models/concerns/content_filterable.rb
|
|
177
|
+
- lib/moderate/models/concerns/deferred_notices.rb
|
|
170
178
|
- lib/moderate/models/concerns/reportable.rb
|
|
171
179
|
- lib/moderate/models/flag.rb
|
|
172
180
|
- lib/moderate/models/report.rb
|
|
173
181
|
- lib/moderate/result.rb
|
|
182
|
+
- lib/moderate/retention.rb
|
|
174
183
|
- lib/moderate/services/intake_appeal.rb
|
|
175
184
|
- lib/moderate/services/intake_notice.rb
|
|
176
185
|
- lib/moderate/services/intake_report.rb
|
|
177
186
|
- lib/moderate/services/resolve_appeal.rb
|
|
178
187
|
- lib/moderate/services/resolve_flag.rb
|
|
179
188
|
- lib/moderate/services/resolve_report.rb
|
|
180
|
-
- lib/moderate/text.rb
|
|
181
189
|
- lib/moderate/text_validator.rb
|
|
182
190
|
- lib/moderate/version.rb
|
|
183
|
-
- lib/moderate
|
|
191
|
+
- lib/tasks/moderate.rake
|
|
184
192
|
- log/development.log
|
|
185
193
|
- log/test.log
|
|
186
194
|
- sig/moderate.rbs
|
|
@@ -201,14 +209,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
201
209
|
requirements:
|
|
202
210
|
- - ">="
|
|
203
211
|
- !ruby/object:Gem::Version
|
|
204
|
-
version: 3.
|
|
212
|
+
version: 3.3.0
|
|
205
213
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
206
214
|
requirements:
|
|
207
215
|
- - ">="
|
|
208
216
|
- !ruby/object:Gem::Version
|
|
209
217
|
version: '0'
|
|
210
218
|
requirements: []
|
|
211
|
-
rubygems_version: 3.6.
|
|
219
|
+
rubygems_version: 3.6.9
|
|
212
220
|
specification_version: 4
|
|
213
221
|
summary: Let your Rails users report content and block each other (Trust & Safety)
|
|
214
222
|
test_files: []
|
data/gemfiles/rails_7.2.gemfile
DELETED
|
@@ -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: "../"
|