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