moderate 1.0.0.beta1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +96 -1
- data/.simplecov +2 -10
- data/AGENTS.md +1 -1
- data/Appraisals +10 -8
- data/CHANGELOG.md +660 -20
- data/CLAUDE.md +1 -1
- data/README.md +149 -7
- data/Rakefile +2 -0
- data/app/controllers/concerns/moderate/moderation.rb +23 -11
- data/app/controllers/moderate/appeals_controller.rb +9 -5
- data/app/controllers/moderate/application_controller.rb +3 -3
- data/app/controllers/moderate/notices_controller.rb +3 -6
- data/app/helpers/moderate/engine_helper.rb +32 -11
- data/app/views/moderate/appeals/new.html.erb +6 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +2 -2
- data/app/views/moderate/transparency_reports/show.html.erb +5 -5
- data/config/locales/en.yml +200 -0
- data/config/locales/es.yml +177 -0
- data/context7.json +4 -0
- data/db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb +139 -0
- data/db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb +103 -0
- data/docs/chats.md +140 -0
- data/docs/compliance.md +92 -3
- data/docs/configuration.md +30 -2
- data/docs/dsa-notice-form.md +1 -1
- data/docs/images/moderate-user-report-block-actions.webp +0 -0
- data/docs/madmin.md +44 -14
- data/docs/notifications.md +17 -4
- data/examples/aws_rekognition_adapter.rb +6 -6
- data/examples/openai_moderation_adapter.rb +1 -1
- data/examples/regexp_pattern_adapter.rb +68 -0
- data/gemfiles/{rails_7.1.gemfile → rails_8.0.gemfile} +8 -6
- data/gemfiles/rails_8.1.gemfile +6 -5
- data/lib/generators/moderate/install_generator.rb +25 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +17 -2
- data/lib/generators/moderate/templates/initializer.rb +9 -15
- data/lib/generators/moderate/views_generator.rb +3 -3
- data/lib/moderate/attachments.rb +137 -0
- data/lib/moderate/configuration.rb +101 -12
- data/lib/moderate/engine.rb +86 -25
- data/lib/moderate/event.rb +21 -0
- data/lib/moderate/filters/base.rb +1 -0
- data/lib/moderate/filters/wordlist.rb +5 -2
- data/lib/moderate/jobs/classify_job.rb +100 -16
- data/lib/moderate/label.rb +2 -2
- data/lib/moderate/macros.rb +8 -3
- data/lib/moderate/models/appeal.rb +10 -1
- data/lib/moderate/models/block.rb +23 -19
- data/lib/moderate/models/classification.rb +24 -0
- data/lib/moderate/models/concerns/actor.rb +17 -2
- data/lib/moderate/models/concerns/content_filterable.rb +241 -56
- data/lib/moderate/models/concerns/deferred_notices.rb +90 -0
- data/lib/moderate/models/concerns/reportable.rb +84 -4
- data/lib/moderate/models/flag.rb +199 -3
- data/lib/moderate/models/report.rb +180 -16
- data/lib/moderate/result.rb +3 -10
- data/lib/moderate/retention.rb +241 -0
- data/lib/moderate/services/intake_notice.rb +10 -10
- data/lib/moderate/services/intake_report.rb +42 -13
- data/lib/moderate/services/resolve_appeal.rb +8 -2
- data/lib/moderate/services/resolve_flag.rb +183 -35
- data/lib/moderate/services/resolve_report.rb +288 -35
- data/lib/moderate/text_validator.rb +15 -14
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +337 -17
- data/lib/tasks/moderate.rake +23 -0
- metadata +24 -14
- data/gemfiles/rails_7.2.gemfile +0 -36
- data/lib/moderate/text.rb +0 -38
- data/lib/moderate/word_list.rb +0 -90
|
@@ -16,19 +16,88 @@ module Moderate
|
|
|
16
16
|
# Configuration validates that invariant (README: ":block requires a
|
|
17
17
|
# synchronous adapter").
|
|
18
18
|
# :flag — an AFTER_COMMIT side effect. The save SUCCEEDS, then (only if the
|
|
19
|
-
# field actually changed
|
|
20
|
-
# `Moderate::Flag` is filed
|
|
19
|
+
# field actually changed) the value is classified and a
|
|
20
|
+
# `Moderate::Flag` is filed when it trips. HOW it's classified
|
|
21
|
+
# depends on the adapter: a synchronous adapter (the wordlist)
|
|
22
|
+
# classifies inline right here; an ASYNC adapter (`synchronous? ==
|
|
23
|
+
# false` — any remote moderation API) is routed through
|
|
24
|
+
# `Moderate::ClassifyJob`, because blocking network I/O must never
|
|
25
|
+
# run inside the request that saved the content. The job re-reads
|
|
26
|
+
# the current value and files the Flag itself.
|
|
21
27
|
#
|
|
22
28
|
# WHY :flag lives in after_commit and not in a validator (this is the whole
|
|
23
29
|
# reason `:flag` is a `moderates` mode you can't hand-roll with `validates`):
|
|
24
30
|
# validators must be side-effect-free, and a Flag created inside a transaction
|
|
25
31
|
# that later rolls back would silently vanish — you'd think you flagged something
|
|
26
32
|
# you didn't. `after_commit` guarantees the surrounding transaction committed
|
|
27
|
-
# before we write the Flag
|
|
28
|
-
# validator").
|
|
33
|
+
# before we write the Flag (and that a ClassifyJob never races a rollback). See
|
|
34
|
+
# docs/configuration.md ("`:flag` never lives in a validator").
|
|
35
|
+
#
|
|
36
|
+
# ACTIVE STORAGE ATTACHMENTS work out of the box: `moderates :avatar, with:
|
|
37
|
+
# :your_image_adapter, mode: :flag` on a `has_one_attached :avatar` model needs
|
|
38
|
+
# no extra wiring. AR dirty tracking can't see attachment writes — and Active
|
|
39
|
+
# Storage drops `attachment_changes` in its own after_commit — so the concern
|
|
40
|
+
# snapshots "these filtered attachments changed" in a before_save and consumes
|
|
41
|
+
# the snapshot at commit time. The overridable seam below still exists for
|
|
42
|
+
# richer cases (derived values, non-AS blobs).
|
|
43
|
+
#
|
|
44
|
+
# An attachment is classified only once its file is on the storage service, and
|
|
45
|
+
# it is classified even when the upload fails. Active Storage uploads a newly
|
|
46
|
+
# attached file in ITS after_commit (in whichever order the two after_commits were
|
|
47
|
+
# declared), so the concern hooks that upload itself, from before_save:
|
|
48
|
+
#
|
|
49
|
+
# 1. just before the upload starts, a ClassifyJob is enqueued UPLOAD_WAIT seconds
|
|
50
|
+
# out: the durable intent. If the upload raises, or the process dies, that job
|
|
51
|
+
# still runs, waits (bounded) for the file, and classifies it or audits
|
|
52
|
+
# `classification_skipped`;
|
|
53
|
+
# 2. once the upload returns, the field is classified right away when its files
|
|
54
|
+
# are on the service: inline for a synchronous adapter, by an immediate
|
|
55
|
+
# ClassifyJob for an async one. A direct upload (an attached blob or signed
|
|
56
|
+
# id, whose `upload` is a no-op) may not be there yet; then the job from step
|
|
57
|
+
# 1 takes it.
|
|
58
|
+
#
|
|
59
|
+
# Whichever classifies first claims the files (a moderate_classifications row,
|
|
60
|
+
# Moderate::Attachments), calls the adapter outside any lock and records the
|
|
61
|
+
# result; the rest find the claim or the record and stop, so a field is classified
|
|
62
|
+
# once per set of files. An adapter that raises leaves its claim until
|
|
63
|
+
# `config.classification_claim_ttl` runs out and schedules no retry itself: the
|
|
64
|
+
# guard job, a job's recheck at the claim's expiry, or a new save takes it over.
|
|
29
65
|
module ContentFilterable
|
|
30
66
|
extend ActiveSupport::Concern
|
|
31
67
|
|
|
68
|
+
# Extended onto one pending Active Storage attachment change (the object
|
|
69
|
+
# `has_one_attached` / `has_many_attached` upload from their after_commit), so
|
|
70
|
+
# the record hears just before and just after that change uploads its files.
|
|
71
|
+
# Per object, never a global patch. A failure of ours never breaks the upload.
|
|
72
|
+
module AfterUpload
|
|
73
|
+
def upload(...)
|
|
74
|
+
moderate_notify(:moderate_upload_starting)
|
|
75
|
+
result = super
|
|
76
|
+
moderate_notify(:moderate_upload_finished)
|
|
77
|
+
result
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def moderate_watch(record, field)
|
|
81
|
+
(@moderate_watchers ||= {})[field.to_s] = record
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def moderate_notify(event)
|
|
87
|
+
Array(@moderate_watchers).each do |field, record|
|
|
88
|
+
record.send(event, field)
|
|
89
|
+
rescue StandardError => error
|
|
90
|
+
Moderate.audit(
|
|
91
|
+
:classification_skipped,
|
|
92
|
+
subject: record,
|
|
93
|
+
payload: { field: field, reason: "filter_error", error_class: error.class.name,
|
|
94
|
+
summary: "#{record.class.name}##{field}: #{event} raised #{error.class}" }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_constant :AfterUpload
|
|
100
|
+
|
|
32
101
|
included do
|
|
33
102
|
# The set of filtered field names (Strings), inherited via class_attribute so
|
|
34
103
|
# STI subclasses keep their parent's filtered fields. Accumulates across
|
|
@@ -36,6 +105,10 @@ module Moderate
|
|
|
36
105
|
class_attribute :moderation_filtered_fields, instance_writer: false, default: [].freeze
|
|
37
106
|
|
|
38
107
|
validate :moderate_blocked_fields_must_be_allowed
|
|
108
|
+
# Snapshot attachment writes BEFORE Active Storage's own save callbacks
|
|
109
|
+
# clear `attachment_changes` — by after_commit they're gone (see
|
|
110
|
+
# activestorage's Attached::Model). One-shot; consumed + cleared below.
|
|
111
|
+
before_save :moderate_snapshot_attachment_changes
|
|
39
112
|
after_commit :moderate_flag_filtered_fields
|
|
40
113
|
end
|
|
41
114
|
|
|
@@ -55,87 +128,167 @@ module Moderate
|
|
|
55
128
|
|
|
56
129
|
# :block enforcement — a validation. We deliberately skip :flag fields here
|
|
57
130
|
# (their work happens after_commit) and blank values (nothing to classify).
|
|
131
|
+
#
|
|
132
|
+
# `:block` lets nothing in unverified: an attachment that points at a stored blob
|
|
133
|
+
# whose file isn't on the service (a direct upload that hasn't finished) fails
|
|
134
|
+
# with `:upload_not_finished` instead of being classified without its file.
|
|
58
135
|
def moderate_blocked_fields_must_be_allowed
|
|
59
136
|
moderation_filtered_fields.each do |field|
|
|
60
137
|
policy = Moderate.filter_policy_for(self, field)
|
|
61
138
|
next unless policy.block?
|
|
62
139
|
|
|
63
140
|
value = moderation_field_value(field)
|
|
64
|
-
next if value
|
|
141
|
+
next if moderation_value_blank?(value)
|
|
142
|
+
next errors.add(field, :upload_not_finished) if Moderate::Attachments.upload_unfinished?(value)
|
|
65
143
|
|
|
66
144
|
result = Moderate.classify(value, policy: policy)
|
|
67
145
|
errors.add(field, :objectionable_content) if result.flagged?
|
|
68
146
|
end
|
|
69
147
|
end
|
|
70
148
|
|
|
71
|
-
# :flag enforcement — an after_commit side effect that files a Moderate::Flag
|
|
149
|
+
# :flag enforcement — an after_commit side effect that files a Moderate::Flag
|
|
150
|
+
# (inline for synchronous adapters; via Moderate::ClassifyJob for async ones).
|
|
72
151
|
#
|
|
73
152
|
# We only act when the field actually CHANGED on this commit (re-saving an
|
|
74
|
-
# untouched record must not re-flag it and spam the queue)
|
|
75
|
-
#
|
|
76
|
-
# always runs even if classification raises — important for the attachment
|
|
77
|
-
# seam below, where a host sets a one-shot "changed" flag it must clear.
|
|
153
|
+
# untouched record must not re-flag it and spam the queue). A field whose upload
|
|
154
|
+
# the concern is watching is left to that upload's hooks (see AfterUpload).
|
|
78
155
|
def moderate_flag_filtered_fields
|
|
79
156
|
moderation_filtered_fields.each do |field|
|
|
157
|
+
next if @moderate_watched_uploads&.include?(field)
|
|
158
|
+
|
|
80
159
|
policy = Moderate.filter_policy_for(self, field)
|
|
81
160
|
next unless policy.flag?
|
|
82
161
|
next unless moderation_field_changed_for_commit?(field)
|
|
83
162
|
|
|
84
|
-
|
|
85
|
-
value = moderation_field_value(field)
|
|
86
|
-
next if value.blank?
|
|
87
|
-
|
|
88
|
-
result = Moderate.classify(value, policy: policy)
|
|
89
|
-
next unless result.flagged?
|
|
90
|
-
|
|
91
|
-
Moderate::Flag.flag!(
|
|
92
|
-
flaggable: self,
|
|
93
|
-
field: field,
|
|
94
|
-
# `reported_owner` comes from Moderate::Reportable. A filterable model
|
|
95
|
-
# is usually also reportable; if it isn't, the owner is simply nil
|
|
96
|
-
# (the flag still lands in the queue, just unattributed).
|
|
97
|
-
owner: (reported_owner if respond_to?(:reported_owner)),
|
|
98
|
-
source: result.source,
|
|
99
|
-
mode: policy.mode,
|
|
100
|
-
# Cap the stored excerpt so we never balloon a row with a huge body;
|
|
101
|
-
# 500 chars is plenty of context for a reviewer.
|
|
102
|
-
excerpt: value.to_s.truncate(500),
|
|
103
|
-
categories: result.categories,
|
|
104
|
-
scores: result.scores,
|
|
105
|
-
# Keep the policy that produced the flag alongside the adapter's raw
|
|
106
|
-
# payload, so the queue can show "flagged by <adapter> under
|
|
107
|
-
# <Class>#<field>" and an auditor can inspect the untouched response.
|
|
108
|
-
context: {
|
|
109
|
-
raw: result.raw,
|
|
110
|
-
policy: { class_name: policy.class_name, field: policy.field, mode: policy.mode.to_s }
|
|
111
|
-
}
|
|
112
|
-
)
|
|
113
|
-
ensure
|
|
114
|
-
moderation_field_committed(field)
|
|
115
|
-
end
|
|
163
|
+
moderate_flag_field(field, policy)
|
|
116
164
|
end
|
|
117
165
|
end
|
|
118
166
|
|
|
167
|
+
# Just before Active Storage uploads `field`'s files: enqueue the job that
|
|
168
|
+
# classifies them even if this upload fails (see the module comment).
|
|
169
|
+
def moderate_upload_starting(field)
|
|
170
|
+
policy = Moderate.filter_policy_for(self, field)
|
|
171
|
+
return unless policy.flag? && moderation_field_changed_for_commit?(field)
|
|
172
|
+
|
|
173
|
+
(@moderate_guarded_uploads ||= Set.new) << field
|
|
174
|
+
Moderate::ClassifyJob.set(wait: Moderate::ClassifyJob::UPLOAD_WAIT)
|
|
175
|
+
.perform_later(self, field, blob_ids: Moderate::Attachments.blob_ids(moderation_field_value(field)))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The upload returned: classify now if the files are there (the guard job takes
|
|
179
|
+
# them otherwise).
|
|
180
|
+
def moderate_upload_finished(field)
|
|
181
|
+
@moderate_watched_uploads&.delete(field)
|
|
182
|
+
return moderation_field_committed(field) unless @moderate_guarded_uploads&.delete?(field)
|
|
183
|
+
|
|
184
|
+
moderate_flag_field(field, Moderate.filter_policy_for(self, field), guarded: true)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Classify one changed field and file its Flag. Wrapped in `ensure` so the
|
|
188
|
+
# clean-up hook (`moderation_field_committed`) always runs even if
|
|
189
|
+
# classification raises — that hook is what clears the one-shot attachment
|
|
190
|
+
# snapshot.
|
|
191
|
+
#
|
|
192
|
+
# An attachment whose files aren't all on the service yet is never classified
|
|
193
|
+
# here: the after-upload guard job takes it (`guarded:`), or a ClassifyJob that
|
|
194
|
+
# waits for it is enqueued now.
|
|
195
|
+
def moderate_flag_field(field, policy, guarded: false)
|
|
196
|
+
value = moderation_field_value(field)
|
|
197
|
+
attachment = Moderate::Attachments.attachment?(value)
|
|
198
|
+
return if attachment && moderation_value_blank?(value)
|
|
199
|
+
|
|
200
|
+
if attachment && !Moderate::Attachments.uploaded?(value)
|
|
201
|
+
Moderate::ClassifyJob.perform_later(self, field, blob_ids: Moderate::Attachments.blob_ids(value)) unless guarded
|
|
202
|
+
return
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# ASYNC adapters (remote moderation APIs) never classify inline — a network
|
|
206
|
+
# call in the request's after_commit would stall the response for as long
|
|
207
|
+
# as the provider takes. ClassifyJob re-reads the value at run time (so it
|
|
208
|
+
# always classifies what's actually persisted) and files the Flag through the
|
|
209
|
+
# same Flag.flag! builder.
|
|
210
|
+
if Moderate.config.adapter_async?(policy.adapter)
|
|
211
|
+
blob_ids = (Moderate::Attachments.blob_ids(value) if attachment)
|
|
212
|
+
Moderate::ClassifyJob.perform_later(self, field, blob_ids: blob_ids)
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
return if moderation_value_blank?(value)
|
|
217
|
+
return moderate_classify_attachment(field, policy) if attachment
|
|
218
|
+
|
|
219
|
+
result = Moderate.classify(value, policy: policy)
|
|
220
|
+
return unless result.flagged?
|
|
221
|
+
|
|
222
|
+
moderate_file_flag(field, policy, value, result)
|
|
223
|
+
ensure
|
|
224
|
+
moderation_field_committed(field)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Claim, classify outside any lock, complete (Moderate::Attachments): the guard
|
|
228
|
+
# job and this path never both classify the same files, and a slow adapter never
|
|
229
|
+
# holds this record's row.
|
|
230
|
+
def moderate_classify_attachment(field, policy)
|
|
231
|
+
Moderate::Attachments.classify_once(
|
|
232
|
+
self, field,
|
|
233
|
+
owner: SecureRandom.uuid,
|
|
234
|
+
read: -> { self.class.unscoped.find_by(id: id)&.send(:moderation_field_value, field) },
|
|
235
|
+
classify: ->(value) { Moderate.classify(value, policy: policy) },
|
|
236
|
+
file: ->(value, result) { moderate_file_flag(field, policy, value, result) }
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def moderate_file_flag(field, policy, value, result)
|
|
241
|
+
Moderate::Flag.flag!(
|
|
242
|
+
flaggable: self,
|
|
243
|
+
field: field,
|
|
244
|
+
# `reported_owner` comes from Moderate::Reportable. A filterable model is
|
|
245
|
+
# usually also reportable; if it isn't — or its owner isn't a person
|
|
246
|
+
# (Moderate.responsible_user) — the owner is simply nil (the flag still
|
|
247
|
+
# lands in the queue, just unattributed).
|
|
248
|
+
owner: Moderate.responsible_user(self),
|
|
249
|
+
source: result.source,
|
|
250
|
+
mode: policy.mode,
|
|
251
|
+
# Cap the stored excerpt so we never balloon a row with a huge body; 500
|
|
252
|
+
# chars is plenty of context for a reviewer.
|
|
253
|
+
excerpt: value.to_s.truncate(500),
|
|
254
|
+
categories: result.categories,
|
|
255
|
+
scores: result.scores,
|
|
256
|
+
# Keep the policy that produced the flag alongside the adapter's raw
|
|
257
|
+
# payload, so the queue can show "flagged by <adapter> under
|
|
258
|
+
# <Class>#<field>" and an auditor can inspect the untouched response.
|
|
259
|
+
context: {
|
|
260
|
+
raw: result.raw,
|
|
261
|
+
policy: { class_name: policy.class_name, field: policy.field, mode: policy.mode.to_s }
|
|
262
|
+
}
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
119
266
|
# --- Overridable field seam -----------------------------------------------
|
|
120
267
|
#
|
|
121
268
|
# These three methods are the seam that lets one concern filter BOTH plain text
|
|
122
|
-
# columns AND non-column content
|
|
123
|
-
#
|
|
124
|
-
#
|
|
269
|
+
# columns AND non-column content, without hosts having to re-plumb the common
|
|
270
|
+
# cases. Defaults handle text attributes AND Active Storage attachments; a
|
|
271
|
+
# host overrides them for anything richer (derived values, external blobs).
|
|
125
272
|
|
|
126
|
-
# The value to classify for `field`. Default: the attribute reader
|
|
127
|
-
#
|
|
128
|
-
#
|
|
273
|
+
# The value to classify for `field`. Default: the attribute reader — which for
|
|
274
|
+
# a `has_one_attached` field returns the `ActiveStorage::Attached` proxy, the
|
|
275
|
+
# natural input for an image adapter (it can read `.record`, `.blob`,
|
|
276
|
+
# `.variant(...)`, or download bytes as it sees fit).
|
|
129
277
|
def moderation_field_value(field)
|
|
130
278
|
public_send(field)
|
|
131
279
|
end
|
|
132
280
|
|
|
133
|
-
# Did `field` change on the just-committed save?
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
#
|
|
281
|
+
# Did `field` change on the just-committed save? Defaults, in order:
|
|
282
|
+
# 1. the attachment snapshot taken in before_save (AR dirty tracking can't
|
|
283
|
+
# see attachment writes, and Active Storage drops `attachment_changes`
|
|
284
|
+
# in its own after_commit — hence the one-shot snapshot),
|
|
285
|
+
# 2. ActiveRecord's own dirty tracking (`saved_change_to_attribute?`),
|
|
286
|
+
# 3. `true` for a PORO/ActiveModel object with no dirty tracking at all —
|
|
287
|
+
# the conservative assumption.
|
|
288
|
+
# Override for non-attribute content the defaults can't see.
|
|
138
289
|
def moderation_field_changed_for_commit?(field)
|
|
290
|
+
return true if @moderate_changed_attachment_fields&.include?(field.to_s)
|
|
291
|
+
|
|
139
292
|
if respond_to?(:saved_change_to_attribute?)
|
|
140
293
|
saved_change_to_attribute?(field)
|
|
141
294
|
elsif respond_to?(:"saved_change_to_#{field}?")
|
|
@@ -146,10 +299,42 @@ module Moderate
|
|
|
146
299
|
end
|
|
147
300
|
|
|
148
301
|
# Per-field clean-up after the commit-time flag attempt (success OR failure).
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
def moderation_field_committed(
|
|
302
|
+
# Consumes the one-shot attachment snapshot; override-and-super if you track
|
|
303
|
+
# extra per-field state of your own.
|
|
304
|
+
def moderation_field_committed(field)
|
|
305
|
+
@moderate_changed_attachment_fields&.delete(field.to_s)
|
|
152
306
|
nil
|
|
153
307
|
end
|
|
308
|
+
|
|
309
|
+
# before_save: record which FILTERED fields have a pending attachment write on
|
|
310
|
+
# this save, and watch the upload of each (AfterUpload). `attachment_changes`
|
|
311
|
+
# only exists on Active Storage models — plain models skip straight through. A
|
|
312
|
+
# removal (`avatar = nil`) is a change with nothing to upload, so nothing to watch.
|
|
313
|
+
def moderate_snapshot_attachment_changes
|
|
314
|
+
return unless respond_to?(:attachment_changes)
|
|
315
|
+
|
|
316
|
+
@moderate_watched_uploads = Set.new
|
|
317
|
+
moderation_filtered_fields.each do |field|
|
|
318
|
+
change = attachment_changes[field]
|
|
319
|
+
next if change.nil?
|
|
320
|
+
|
|
321
|
+
(@moderate_changed_attachment_fields ||= Set.new) << field
|
|
322
|
+
next unless change.respond_to?(:upload)
|
|
323
|
+
|
|
324
|
+
change.extend(AfterUpload) unless change.is_a?(AfterUpload)
|
|
325
|
+
change.moderate_watch(self, field)
|
|
326
|
+
@moderate_watched_uploads << field
|
|
327
|
+
end
|
|
328
|
+
nil # a callback's return value never halts the save (only `throw :abort` does)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Blank check that can see through an `ActiveStorage::Attached` proxy — an
|
|
332
|
+
# attachment with nothing attached must read as "nothing to classify"
|
|
333
|
+
# (Object#blank? can't tell: the proxy is truthy and has no #empty?).
|
|
334
|
+
def moderation_value_blank?(value)
|
|
335
|
+
return !value.attached? if value.respond_to?(:attached?)
|
|
336
|
+
|
|
337
|
+
value.blank?
|
|
338
|
+
end
|
|
154
339
|
end
|
|
155
340
|
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# Notices that must not reach anyone before the change they announce is durable.
|
|
5
|
+
#
|
|
6
|
+
# A service changes a record inside a transaction, then queues what to send with
|
|
7
|
+
# `record.after_commit_notice { … }`. The TRANSACTION owns the notice, exactly as it
|
|
8
|
+
# owns a record's after_commit callbacks: the notice is wrapped in an Envelope and
|
|
9
|
+
# enrolled in the current transaction (`connection.add_transaction_record`, the same
|
|
10
|
+
# call Active Record makes for a saved record, on every supported Rails), and
|
|
11
|
+
# the transaction finalizes it — sends it on commit, drops it on rollback or when an
|
|
12
|
+
# exception ends the transaction. Nothing lives on a Ruby instance or on the thread,
|
|
13
|
+
# so it doesn't matter which instance of a row was saved, whether the row was saved
|
|
14
|
+
# at all, or which thread runs next.
|
|
15
|
+
#
|
|
16
|
+
# Notices follow Active Record's after_commit rules, exactly:
|
|
17
|
+
# - with no transaction open, the notice is sent at once;
|
|
18
|
+
# - the notice joins the transaction of the connection it is queued ON. Queue it
|
|
19
|
+
# inside the transaction that saved the record, on that transaction's connection:
|
|
20
|
+
# `after_commit_notice(on: connection)`, where `on:` defaults to the record
|
|
21
|
+
# class's connection at call time. The gem's services capture their model's
|
|
22
|
+
# connection at the top of the transaction, before any save, and pass it to every
|
|
23
|
+
# notice they queue, so a host callback that switches databases or shards
|
|
24
|
+
# mid-save can't move them. A host that calls after_commit_notice itself passes
|
|
25
|
+
# `on:` when it uses `connected_to`;
|
|
26
|
+
# - in nested transactions that join their parent, and in savepoints
|
|
27
|
+
# (`requires_new: true`) under a joinable parent, it moves up and is sent at the
|
|
28
|
+
# OUTERMOST commit; a rolled-back savepoint drops only its own notices;
|
|
29
|
+
# - under a parent that is not joinable (`transaction(joinable: false)`, and the
|
|
30
|
+
# wrapper Rails' transactional tests run each test in), it is sent when the
|
|
31
|
+
# savepoint is released — when after_commit runs too.
|
|
32
|
+
module DeferredNotices
|
|
33
|
+
# A queued notice, in the shape a transaction finalizes: Active Record calls
|
|
34
|
+
# `before_committed!`, then `committed!` or `rolledback!` on every record enrolled
|
|
35
|
+
# in it (and reads `trigger_transactional_callbacks?`, `destroyed?`,
|
|
36
|
+
# `_new_record_before_last_commit` when choosing which instance runs callbacks).
|
|
37
|
+
#
|
|
38
|
+
# @api private
|
|
39
|
+
class Envelope
|
|
40
|
+
attr_accessor :_new_record_before_last_commit
|
|
41
|
+
|
|
42
|
+
def initialize(notice)
|
|
43
|
+
@notice = notice
|
|
44
|
+
@finalized = false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def trigger_transactional_callbacks? = true
|
|
48
|
+
def has_transactional_callbacks? = true # rubocop:disable Naming/PredicatePrefix -- Active Record's name
|
|
49
|
+
def destroyed? = false
|
|
50
|
+
def before_committed!; end
|
|
51
|
+
|
|
52
|
+
# Sent once, when the transaction that owns the envelope runs commit callbacks.
|
|
53
|
+
# (Active Record also calls this with should_run_callbacks: false when it is only
|
|
54
|
+
# cleaning up after an exception: nothing is sent then.)
|
|
55
|
+
def committed!(should_run_callbacks: true)
|
|
56
|
+
return if @finalized
|
|
57
|
+
|
|
58
|
+
@finalized = true
|
|
59
|
+
@notice.call if should_run_callbacks
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def rolledback!(force_restore_state: false, should_run_callbacks: true) # rubocop:disable Lint/UnusedMethodArgument
|
|
63
|
+
@finalized = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Enroll `notice` in `connection`'s current transaction, or send it now when there
|
|
68
|
+
# is no connection or it has no transaction open.
|
|
69
|
+
#
|
|
70
|
+
# @api private: call `record.after_commit_notice { … }`.
|
|
71
|
+
def self.defer(connection, ¬ice)
|
|
72
|
+
return notice.call unless connection&.current_transaction&.open?
|
|
73
|
+
|
|
74
|
+
connection.add_transaction_record(Envelope.new(notice))
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The connection a service captures at the top of its transaction, before any save.
|
|
79
|
+
#
|
|
80
|
+
# @api private
|
|
81
|
+
def self.connection_for(model)
|
|
82
|
+
model.lease_connection
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def after_commit_notice(on: DeferredNotices.connection_for(self.class), ¬ice)
|
|
86
|
+
DeferredNotices.defer(on, ¬ice)
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -9,7 +9,8 @@ module Moderate
|
|
|
9
9
|
# `Moderate::Report#reportable` association. This concern answers the
|
|
10
10
|
# Trust & Safety questions that polymorphism *can't*:
|
|
11
11
|
#
|
|
12
|
-
# - which fields of the record may be reported (`reportable_fields`)
|
|
12
|
+
# - which fields of the record may be reported (`reportable_fields`), and
|
|
13
|
+
# what humans should call each one (`labels:` / `reportable_field_label`)
|
|
13
14
|
# - who is *responsible* for the content (`reported_owner`) — the person a
|
|
14
15
|
# decision's statement of reasons must reach (DSA Art. 17) and who a ban
|
|
15
16
|
# would apply to
|
|
@@ -56,6 +57,15 @@ module Moderate
|
|
|
56
57
|
# Empty default = "the whole record is reportable, no specific field."
|
|
57
58
|
class_attribute :moderation_reportable_fields, instance_writer: false, default: [].freeze
|
|
58
59
|
|
|
60
|
+
# The human labels declared next to the fields (`labels: { body: "Chat
|
|
61
|
+
# message" }`), keyed by field name String. Values are Strings or callables
|
|
62
|
+
# (evaluated at read time, so a lambda can call I18n under the CURRENT
|
|
63
|
+
# locale). Same `class_attribute` reasoning as the field list above: it
|
|
64
|
+
# inherits down an STI tree and a subclass redeclaration doesn't mutate the
|
|
65
|
+
# parent. Read through `Moderate.reportable_field_label`, never directly —
|
|
66
|
+
# that's where the i18n and humanize fallbacks live.
|
|
67
|
+
class_attribute :moderation_reportable_field_labels, instance_writer: false, default: {}.freeze
|
|
68
|
+
|
|
59
69
|
# Self-register in the gem's reportable registry the moment the concern is
|
|
60
70
|
# included, so `Moderate.reportable_classes` is auto-discovered with NO
|
|
61
71
|
# manual list to maintain (README: "Reportable classes are auto-discovered
|
|
@@ -73,10 +83,54 @@ module Moderate
|
|
|
73
83
|
#
|
|
74
84
|
# reportable_fields :title, :description # writer
|
|
75
85
|
# reportable_fields # => ["title", "description"]
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
#
|
|
87
|
+
# `labels:` names each field for humans — what the moderation queue, the
|
|
88
|
+
# statement of reasons, and the appeal page print instead of the raw column
|
|
89
|
+
# name (a moderator reading «Body» on a chat-message report has to guess
|
|
90
|
+
# what was reported). Optional and per field; anything unlabeled falls back
|
|
91
|
+
# to i18n, then to `field.humanize` (see `Moderate.reportable_field_label`).
|
|
92
|
+
#
|
|
93
|
+
# reportable_fields :body, :files, labels: { body: "Chat message", files: "Chat photo" }
|
|
94
|
+
#
|
|
95
|
+
# Labels follow the same "last declaration wins" rule as the fields, and a
|
|
96
|
+
# label for a field you didn't declare raises — a typo'd key would otherwise
|
|
97
|
+
# silently never show up.
|
|
98
|
+
def reportable_fields(*fields, labels: nil)
|
|
99
|
+
if labels && fields.empty?
|
|
100
|
+
raise ArgumentError, "#{name}: `labels:` needs the fields it labels — " \
|
|
101
|
+
"declare them together, e.g. `reportable_fields :body, labels: { body: \"Message\" }`"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if fields.any?
|
|
105
|
+
field_names = fields.map(&:to_s).freeze
|
|
106
|
+
self.moderation_reportable_fields = field_names
|
|
107
|
+
self.moderation_reportable_field_labels = normalize_reportable_field_labels(labels, field_names)
|
|
108
|
+
end
|
|
109
|
+
|
|
78
110
|
moderation_reportable_fields
|
|
79
111
|
end
|
|
112
|
+
|
|
113
|
+
# The human label for one of this class's reported fields. Class-level sugar
|
|
114
|
+
# for `Moderate.reportable_field_label(self, field)`.
|
|
115
|
+
def reportable_field_label(field)
|
|
116
|
+
Moderate.reportable_field_label(self, field)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def normalize_reportable_field_labels(labels, field_names)
|
|
122
|
+
return {}.freeze if labels.blank?
|
|
123
|
+
raise ArgumentError, "#{name}: `labels:` must be a Hash of field => label" unless labels.respond_to?(:to_h)
|
|
124
|
+
|
|
125
|
+
normalized = labels.to_h.transform_keys(&:to_s)
|
|
126
|
+
unknown = normalized.keys - field_names
|
|
127
|
+
if unknown.any?
|
|
128
|
+
raise ArgumentError, "#{name}: `labels:` names #{unknown.map(&:inspect).join(", ")}, " \
|
|
129
|
+
"not among the reportable fields #{field_names.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
normalized.freeze
|
|
133
|
+
end
|
|
80
134
|
end
|
|
81
135
|
|
|
82
136
|
# Is `field` one a reporter is allowed to name? Two cases:
|
|
@@ -100,6 +154,13 @@ module Moderate
|
|
|
100
154
|
self.class.reportable_fields.include?(field_s)
|
|
101
155
|
end
|
|
102
156
|
|
|
157
|
+
# The human label for `field` on this record ("Chat message", not "Body"), or
|
|
158
|
+
# nil for a blank field (a whole-record report has no field to name). Sugar
|
|
159
|
+
# for `Moderate.reportable_field_label(self, field)`.
|
|
160
|
+
def reportable_field_label(field)
|
|
161
|
+
Moderate.reportable_field_label(self, field)
|
|
162
|
+
end
|
|
163
|
+
|
|
103
164
|
# WHO is responsible for this content — the account a decision's statement of
|
|
104
165
|
# reasons reaches (DSA Art. 17) and the user a ban would apply to.
|
|
105
166
|
#
|
|
@@ -179,6 +240,25 @@ module Moderate
|
|
|
179
240
|
true
|
|
180
241
|
end
|
|
181
242
|
|
|
243
|
+
# The generic content bucket this record is (Report::CONTENT_TYPES: "message",
|
|
244
|
+
# "listing", "user_profile", …), stored on reports filed against it so the queue
|
|
245
|
+
# and the transparency counts can group by kind. nil (the default) leaves the
|
|
246
|
+
# report's content_type empty; a value outside CONTENT_TYPES is stored as "other".
|
|
247
|
+
def moderation_content_type(_field = nil)
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# The signed, purpose-scoped token that names this record in a report link
|
|
252
|
+
# (`/reports/new?target=…`), resolved back by Report.locate_signed_reportable.
|
|
253
|
+
# NON-expiring on purpose: it identifies which record to report and grants no
|
|
254
|
+
# capability, and an expiring token changes on every render, which defeats the
|
|
255
|
+
# fragment caching of anything that shows a report link (a chat bubble rendered
|
|
256
|
+
# once per broadcast, for every viewer). Prefer the view helper
|
|
257
|
+
# `moderate_report_target_path(record, field:)` to building URLs by hand.
|
|
258
|
+
def to_moderation_sgid
|
|
259
|
+
to_sgid_param(for: Moderate::Report::SIGNED_GLOBAL_ID_PURPOSE, expires_in: nil)
|
|
260
|
+
end
|
|
261
|
+
|
|
182
262
|
# Open reports filed against this record, optionally narrowed to one field.
|
|
183
263
|
# Hosts can use this when they need the actual relation (queue previews,
|
|
184
264
|
# counters, "already reported" affordances) instead of just a boolean.
|
|
@@ -264,7 +344,7 @@ module Moderate
|
|
|
264
344
|
return false if owner.nil?
|
|
265
345
|
|
|
266
346
|
if owner.respond_to?(:id) && viewer.respond_to?(:id)
|
|
267
|
-
owner.id == viewer.id && owner.
|
|
347
|
+
owner.id == viewer.id && owner.instance_of?(viewer.class)
|
|
268
348
|
else
|
|
269
349
|
owner == viewer
|
|
270
350
|
end
|