moderate 0.1.0 → 1.0.0.beta1
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 +8 -0
- data/.simplecov +62 -0
- data/AGENTS.md +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +71 -1
- data/CLAUDE.md +7 -0
- data/README.md +376 -29
- data/Rakefile +28 -2
- data/app/controllers/concerns/moderate/moderation.rb +161 -0
- data/app/controllers/moderate/appeals_controller.rb +190 -0
- data/app/controllers/moderate/application_controller.rb +45 -0
- data/app/controllers/moderate/notices_controller.rb +382 -0
- data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
- data/app/helpers/moderate/engine_helper.rb +151 -0
- data/app/views/moderate/appeals/new.html.erb +78 -0
- data/app/views/moderate/notices/new.html.erb +255 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
- data/app/views/moderate/transparency_reports/show.html.erb +52 -0
- data/config/moderate/blocklists/en.yml +81 -0
- data/config/moderate/blocklists/es.yml +40 -0
- data/config/routes.rb +36 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/madmin.md +490 -0
- data/docs/notifications.md +363 -0
- data/examples/aws_rekognition_adapter.rb +140 -0
- data/examples/openai_moderation_adapter.rb +111 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/generators/moderate/install_generator.rb +56 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
- data/lib/generators/moderate/templates/initializer.rb +198 -0
- data/lib/generators/moderate/views_generator.rb +63 -0
- data/lib/moderate/configuration.rb +341 -0
- data/lib/moderate/engine.rb +138 -0
- data/lib/moderate/errors.rb +26 -0
- data/lib/moderate/event.rb +75 -0
- data/lib/moderate/filters/base.rb +126 -0
- data/lib/moderate/filters/wordlist.rb +255 -0
- data/lib/moderate/jobs/classify_job.rb +158 -0
- data/lib/moderate/label.rb +111 -0
- data/lib/moderate/macros.rb +90 -0
- data/lib/moderate/models/appeal.rb +154 -0
- data/lib/moderate/models/application_record.rb +31 -0
- data/lib/moderate/models/block.rb +203 -0
- data/lib/moderate/models/concerns/actor.rb +174 -0
- data/lib/moderate/models/concerns/content_filterable.rb +155 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +136 -0
- data/lib/moderate/models/report.rb +620 -0
- data/lib/moderate/result.rb +176 -0
- data/lib/moderate/services/intake_appeal.rb +89 -0
- data/lib/moderate/services/intake_notice.rb +132 -0
- data/lib/moderate/services/intake_report.rb +132 -0
- data/lib/moderate/services/resolve_appeal.rb +134 -0
- data/lib/moderate/services/resolve_flag.rb +101 -0
- data/lib/moderate/services/resolve_report.rb +291 -0
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +365 -18
- data/log/development.log +0 -0
- data/log/test.log +0 -0
- metadata +154 -15
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# Pre-publication content filtering for a model's fields. Backs the `moderates`
|
|
5
|
+
# macro (and its documented equivalent, `include Moderate::ContentFilterable` +
|
|
6
|
+
# `moderates_fields :body`).
|
|
7
|
+
#
|
|
8
|
+
# For each declared field the concern resolves the field's FilterPolicy (via
|
|
9
|
+
# `Moderate.filter_policy_for`, which walks the ancestor chain so an STI parent's
|
|
10
|
+
# policy covers its children) and enforces it in one of two ways, by mode:
|
|
11
|
+
#
|
|
12
|
+
# :off — nothing.
|
|
13
|
+
# :block — a VALIDATION. If the classifier flags the value, the save is
|
|
14
|
+
# rejected with `errors.add(field, :objectionable_content)`. Runs
|
|
15
|
+
# synchronously, so it only works with a synchronous adapter — the
|
|
16
|
+
# Configuration validates that invariant (README: ":block requires a
|
|
17
|
+
# synchronous adapter").
|
|
18
|
+
# :flag — an AFTER_COMMIT side effect. The save SUCCEEDS, then (only if the
|
|
19
|
+
# field actually changed and the value trips the filter) a
|
|
20
|
+
# `Moderate::Flag` is filed for review.
|
|
21
|
+
#
|
|
22
|
+
# WHY :flag lives in after_commit and not in a validator (this is the whole
|
|
23
|
+
# reason `:flag` is a `moderates` mode you can't hand-roll with `validates`):
|
|
24
|
+
# validators must be side-effect-free, and a Flag created inside a transaction
|
|
25
|
+
# that later rolls back would silently vanish — you'd think you flagged something
|
|
26
|
+
# you didn't. `after_commit` guarantees the surrounding transaction committed
|
|
27
|
+
# before we write the Flag. See docs/configuration.md ("`:flag` never lives in a
|
|
28
|
+
# validator").
|
|
29
|
+
module ContentFilterable
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
included do
|
|
33
|
+
# The set of filtered field names (Strings), inherited via class_attribute so
|
|
34
|
+
# STI subclasses keep their parent's filtered fields. Accumulates across
|
|
35
|
+
# multiple `moderates`/`moderates_fields` declarations on the same class.
|
|
36
|
+
class_attribute :moderation_filtered_fields, instance_writer: false, default: [].freeze
|
|
37
|
+
|
|
38
|
+
validate :moderate_blocked_fields_must_be_allowed
|
|
39
|
+
after_commit :moderate_flag_filtered_fields
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class_methods do
|
|
43
|
+
# Register one or more fields for filtering. Additive and de-duplicated, so
|
|
44
|
+
# `moderates :a; moderates :b` and `moderates_fields :a, :b` are equivalent.
|
|
45
|
+
# The PER-FIELD adapter/mode (the `with:`/`mode:` of the `moderates` macro)
|
|
46
|
+
# is recorded separately as a Configuration FilterPolicy by the macro; this
|
|
47
|
+
# method only tracks WHICH fields to run on commit/validate.
|
|
48
|
+
def moderates_fields(*fields)
|
|
49
|
+
self.moderation_filtered_fields =
|
|
50
|
+
(moderation_filtered_fields + fields.map(&:to_s)).uniq.freeze
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# :block enforcement — a validation. We deliberately skip :flag fields here
|
|
57
|
+
# (their work happens after_commit) and blank values (nothing to classify).
|
|
58
|
+
def moderate_blocked_fields_must_be_allowed
|
|
59
|
+
moderation_filtered_fields.each do |field|
|
|
60
|
+
policy = Moderate.filter_policy_for(self, field)
|
|
61
|
+
next unless policy.block?
|
|
62
|
+
|
|
63
|
+
value = moderation_field_value(field)
|
|
64
|
+
next if value.blank?
|
|
65
|
+
|
|
66
|
+
result = Moderate.classify(value, policy: policy)
|
|
67
|
+
errors.add(field, :objectionable_content) if result.flagged?
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# :flag enforcement — an after_commit side effect that files a Moderate::Flag.
|
|
72
|
+
#
|
|
73
|
+
# 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), and we wrap each
|
|
75
|
+
# field in `begin/ensure` so a clean-up hook (`moderation_field_committed`)
|
|
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.
|
|
78
|
+
def moderate_flag_filtered_fields
|
|
79
|
+
moderation_filtered_fields.each do |field|
|
|
80
|
+
policy = Moderate.filter_policy_for(self, field)
|
|
81
|
+
next unless policy.flag?
|
|
82
|
+
next unless moderation_field_changed_for_commit?(field)
|
|
83
|
+
|
|
84
|
+
begin
|
|
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
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# --- Overridable field seam -----------------------------------------------
|
|
120
|
+
#
|
|
121
|
+
# These three methods are the seam that lets one concern filter BOTH plain text
|
|
122
|
+
# columns AND non-column content (e.g. an Active Storage attachment), without
|
|
123
|
+
# the concern knowing anything about attachments. Defaults handle the common
|
|
124
|
+
# "it's a text attribute" case; a host overrides them for richer content.
|
|
125
|
+
|
|
126
|
+
# The value to classify for `field`. Default: the attribute reader. Override to
|
|
127
|
+
# return, say, an attachment's blob/URL for an image adapter. The classifier
|
|
128
|
+
# (text or image) is whatever the field's policy adapter is.
|
|
129
|
+
def moderation_field_value(field)
|
|
130
|
+
public_send(field)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Did `field` change on the just-committed save? Default: ask ActiveRecord's
|
|
134
|
+
# dirty tracking (`saved_change_to_attribute?`). We guard with `respond_to?`
|
|
135
|
+
# so the concern also works on a PORO/ActiveModel object that lacks AR dirty
|
|
136
|
+
# tracking (in which case we conservatively assume it changed). Override for
|
|
137
|
+
# non-attribute content (e.g. track an attachment's "was replaced" flag).
|
|
138
|
+
def moderation_field_changed_for_commit?(field)
|
|
139
|
+
if respond_to?(:saved_change_to_attribute?)
|
|
140
|
+
saved_change_to_attribute?(field)
|
|
141
|
+
elsif respond_to?(:"saved_change_to_#{field}?")
|
|
142
|
+
public_send(:"saved_change_to_#{field}?")
|
|
143
|
+
else
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Per-field clean-up after the commit-time flag attempt (success OR failure).
|
|
149
|
+
# No-op by default; the attachment seam overrides it to reset a one-shot
|
|
150
|
+
# "changed" flag it set during the save.
|
|
151
|
+
def moderation_field_committed(_field)
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# The contract a model opts into to become *reportable* — i.e. a piece of
|
|
5
|
+
# user-generated content (a comment, a listing, a profile) that another user (or
|
|
6
|
+
# a public DSA notice) can file a report against.
|
|
7
|
+
#
|
|
8
|
+
# Plain Rails polymorphism answers "what row was reported?" via the
|
|
9
|
+
# `Moderate::Report#reportable` association. This concern answers the
|
|
10
|
+
# Trust & Safety questions that polymorphism *can't*:
|
|
11
|
+
#
|
|
12
|
+
# - which fields of the record may be reported (`reportable_fields`)
|
|
13
|
+
# - who is *responsible* for the content (`reported_owner`) — the person a
|
|
14
|
+
# decision's statement of reasons must reach (DSA Art. 17) and who a ban
|
|
15
|
+
# would apply to
|
|
16
|
+
# - what the moderation queue should *call* this item (`moderation_label`)
|
|
17
|
+
# - what immutable evidence to snapshot at report time so it survives an edit
|
|
18
|
+
# or delete (`moderation_snapshot`)
|
|
19
|
+
# - what a moderator may *remove* without nuking the whole record
|
|
20
|
+
# (`remove_reported_field!`)
|
|
21
|
+
# - whether a given viewer is even allowed to report it (`report_visible_to?`)
|
|
22
|
+
#
|
|
23
|
+
# WHY a concern and not just config: these answers are intrinsic to each model
|
|
24
|
+
# (only the Listing knows its title is reportable but its internal SKU isn't),
|
|
25
|
+
# so they belong ON the model. Hosts override the handful of methods that need
|
|
26
|
+
# domain knowledge; everything else has a safe default.
|
|
27
|
+
#
|
|
28
|
+
# Regulatory grounding for why a reportable contract has to exist at all:
|
|
29
|
+
# - Apple App Store Review Guideline 1.2 requires a way to report UGC and a
|
|
30
|
+
# mechanism to remove offending content:
|
|
31
|
+
# https://developer.apple.com/app-store/review/guidelines/#user-generated-content
|
|
32
|
+
# - Google Play UGC policy requires in-app reporting and ongoing moderation:
|
|
33
|
+
# https://support.google.com/googleplay/android-developer/answer/9876937
|
|
34
|
+
# - EU DSA Art. 16 (notice & action) presupposes that reported items can be
|
|
35
|
+
# identified, snapshotted, and acted on:
|
|
36
|
+
# https://eur-lex.europa.eu/eli/reg/2022/2065/oj
|
|
37
|
+
#
|
|
38
|
+
# The documented include form is `include Moderate::Reportable` +
|
|
39
|
+
# `reportable_fields :a, :b`; the `has_reportable_content :a, :b` macro is exact sugar.
|
|
40
|
+
module Reportable
|
|
41
|
+
extend ActiveSupport::Concern
|
|
42
|
+
|
|
43
|
+
included do
|
|
44
|
+
# Reports filed against THIS record. Kept on the public `reports` reader
|
|
45
|
+
# because the README promises `listing.reports`, and because a reportable
|
|
46
|
+
# model should read naturally in host code. Reports are legal/evidentiary,
|
|
47
|
+
# so hard-deleting the content detaches rather than destroys them.
|
|
48
|
+
has_many :reports,
|
|
49
|
+
as: :reportable,
|
|
50
|
+
class_name: "Moderate::Report",
|
|
51
|
+
dependent: :nullify
|
|
52
|
+
|
|
53
|
+
# The whitelist of reportable field names, stored as frozen Strings. A
|
|
54
|
+
# `class_attribute` (not a plain constant) so it inherits down an STI tree
|
|
55
|
+
# AND can be overridden per subclass without mutating the parent's list.
|
|
56
|
+
# Empty default = "the whole record is reportable, no specific field."
|
|
57
|
+
class_attribute :moderation_reportable_fields, instance_writer: false, default: [].freeze
|
|
58
|
+
|
|
59
|
+
# Self-register in the gem's reportable registry the moment the concern is
|
|
60
|
+
# included, so `Moderate.reportable_classes` is auto-discovered with NO
|
|
61
|
+
# manual list to maintain (README: "Reportable classes are auto-discovered
|
|
62
|
+
# from the `has_reportable_content` macro — no manual registry."). We register the class
|
|
63
|
+
# NAME (the registry stores strings and constantizes lazily) so we never pin
|
|
64
|
+
# the class across a Zeitwerk reload in development.
|
|
65
|
+
Moderate.register_reportable(self)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
class_methods do
|
|
69
|
+
# Declare (or read) the reportable fields. Idempotent and additive-free:
|
|
70
|
+
# the last declaration wins for THIS class (it doesn't merge with the
|
|
71
|
+
# inherited value), matching how a host expects `reportable_fields :title`
|
|
72
|
+
# to mean exactly `["title"]`. Called with no args, it's a reader.
|
|
73
|
+
#
|
|
74
|
+
# reportable_fields :title, :description # writer
|
|
75
|
+
# reportable_fields # => ["title", "description"]
|
|
76
|
+
def reportable_fields(*fields)
|
|
77
|
+
self.moderation_reportable_fields = fields.map(&:to_s).freeze if fields.any?
|
|
78
|
+
moderation_reportable_fields
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Is `field` one a reporter is allowed to name? Two cases:
|
|
83
|
+
#
|
|
84
|
+
# - A BLANK field is ALWAYS allowed — it means "report the whole record," which
|
|
85
|
+
# is valid whether or not the model declared specific reportable fields. (A
|
|
86
|
+
# user tapping "Report this comment" doesn't name a field; only the public DSA
|
|
87
|
+
# notice / a field-targeted in-app flow does.) So a Comment that declares
|
|
88
|
+
# `has_reportable_content :body` can still be reported as a whole with a nil field.
|
|
89
|
+
#
|
|
90
|
+
# - A NAMED field must be in the whitelist. With no fields declared, the
|
|
91
|
+
# whitelist is empty, so any named field is rejected (there's nothing to
|
|
92
|
+
# target field-by-field on a bare-`has_reportable_content` record).
|
|
93
|
+
#
|
|
94
|
+
# This is the authorization gate the Report model and the report controller both
|
|
95
|
+
# consult before accepting a `reported_field`.
|
|
96
|
+
def reportable_field_allowed?(field)
|
|
97
|
+
field_s = field.to_s
|
|
98
|
+
return true if field_s.empty?
|
|
99
|
+
|
|
100
|
+
self.class.reportable_fields.include?(field_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# WHO is responsible for this content — the account a decision's statement of
|
|
104
|
+
# reasons reaches (DSA Art. 17) and the user a ban would apply to.
|
|
105
|
+
#
|
|
106
|
+
# NO default: a model that can be reported MUST tell the gem who's behind it,
|
|
107
|
+
# because guessing wrong here means notifying or banning the wrong person.
|
|
108
|
+
# We raise a NotImplementedError naming the class so the omission is loud at
|
|
109
|
+
# the first report, not silent. (A `User` model with `has_reporting_and_blocking` is itself
|
|
110
|
+
# reportable and returns `self` — see Moderate::Actor.)
|
|
111
|
+
def reported_owner
|
|
112
|
+
raise NotImplementedError,
|
|
113
|
+
"#{self.class.name} is reportable but doesn't define #reported_owner. " \
|
|
114
|
+
"Return the user responsible for this content (the one a decision notifies " \
|
|
115
|
+
"and a ban applies to), e.g. `def reported_owner = user`."
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The human-readable name the moderation queue shows for this item. Defaults
|
|
119
|
+
# to Rails' own `to_s` (usually "#<Comment id: 42>"); override for something an
|
|
120
|
+
# admin can act on at a glance, e.g. `def moderation_label = "Comment #{id}"`.
|
|
121
|
+
def moderation_label
|
|
122
|
+
to_s
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The immutable evidence text to capture for `field` at report time, so the
|
|
126
|
+
# report survives the content being edited or deleted. The Report model calls
|
|
127
|
+
# this in its `before_validation :capture_snapshot` (DSA-grade evidence
|
|
128
|
+
# preservation). Defaults to nil; override to return the actual field text
|
|
129
|
+
# (e.g. `public_send(field)`) or a description of a non-text attachment.
|
|
130
|
+
#
|
|
131
|
+
# NAMED `moderation_snapshot` per the gem's public reportable contract; takes
|
|
132
|
+
# the field so a multi-field record can snapshot the specific thing reported.
|
|
133
|
+
def moderation_snapshot(_field)
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Remove the reported `field` as an enforcement action — WITHOUT destroying the
|
|
138
|
+
# whole record (a moderator removing one objectionable photo shouldn't delete
|
|
139
|
+
# the whole listing). Returns truthy if something was actually removed (so the
|
|
140
|
+
# decision service knows whether to fire the `content_removed` event).
|
|
141
|
+
#
|
|
142
|
+
# Defaults to a no-op returning false: a model that hasn't opted into
|
|
143
|
+
# field-level removal simply reports "nothing removed," and the moderator falls
|
|
144
|
+
# back to other actions. Override to purge an attachment, blank a column, etc.
|
|
145
|
+
def remove_reported_field!(_field)
|
|
146
|
+
false
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Companion query to `remove_reported_field!`: CAN this specific `field` be
|
|
150
|
+
# removed on this record? An admin UI uses it to decide whether to OFFER a
|
|
151
|
+
# "remove content" action at all. Without it, a host that only removes SOME
|
|
152
|
+
# fields (e.g. an avatar but not a display name) would render a remove button
|
|
153
|
+
# that always fails when the moderator clicks it on a non-removable field.
|
|
154
|
+
#
|
|
155
|
+
# Defaults to false (mirrors the no-op `remove_reported_field!`). Override it
|
|
156
|
+
# alongside `remove_reported_field!` and have the latter reuse it, so the
|
|
157
|
+
# "can I?" answer and the "do it" action never drift apart.
|
|
158
|
+
def removable_reported_field?(_field)
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Visibility/authorization gate for the report affordance: should `viewer` be
|
|
163
|
+
# offered a "report this" control for `field`? The default enforces two rules:
|
|
164
|
+
#
|
|
165
|
+
# 1. the field must be reportable (`reportable_field_allowed?`), and
|
|
166
|
+
# 2. you can't report YOUR OWN content — the affordance is hidden from the
|
|
167
|
+
# content's owner, so an author never sees "Report" on their own post. We
|
|
168
|
+
# compare the viewer to this content's `reported_owner` (the Reportable
|
|
169
|
+
# contract's "who is responsible" answer).
|
|
170
|
+
#
|
|
171
|
+
# The `moderate_report_link` helper renders nothing when this is false, and the
|
|
172
|
+
# report controller redirects. Hosts can override for richer rules. (A User with
|
|
173
|
+
# `has_reporting_and_blocking` overrides this in Moderate::Actor to compare ids directly,
|
|
174
|
+
# since a user IS its own owner.)
|
|
175
|
+
def report_visible_to?(viewer, field:)
|
|
176
|
+
return false unless reportable_field_allowed?(field)
|
|
177
|
+
return false if viewer.present? && moderation_owner_is?(viewer)
|
|
178
|
+
|
|
179
|
+
true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Open reports filed against this record, optionally narrowed to one field.
|
|
183
|
+
# Hosts can use this when they need the actual relation (queue previews,
|
|
184
|
+
# counters, "already reported" affordances) instead of just a boolean.
|
|
185
|
+
def open_reports(field = nil)
|
|
186
|
+
moderation_scope_by_field(reports.open, :reported_field, field)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Has this record received any open reports? This is the public predicate
|
|
190
|
+
# documented beside `reports` in the README.
|
|
191
|
+
def reported?(field = nil)
|
|
192
|
+
open_reports(field).exists?
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# All auto-filter/manual flags against this record, optionally narrowed to a
|
|
196
|
+
# field. We keep this as a method instead of a `has_many :flags` association:
|
|
197
|
+
# `flags` is a common host-model word, while `flagged?` is the public DX.
|
|
198
|
+
def moderation_flags(field = nil)
|
|
199
|
+
moderation_scope_by_field(
|
|
200
|
+
Moderate::Flag.where(flaggable: self),
|
|
201
|
+
:field,
|
|
202
|
+
field
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Pending flags are the "allowed through, awaiting review" state a host may
|
|
207
|
+
# want to surface near user-generated content.
|
|
208
|
+
def pending_moderation_flags(field = nil)
|
|
209
|
+
moderation_flags(field).pending
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Has this record been flagged and not yet resolved/dismissed? Optionally
|
|
213
|
+
# pass a field (`listing.flagged?(:description)`) for field-level UI.
|
|
214
|
+
def flagged?(field = nil)
|
|
215
|
+
pending_moderation_flags(field).exists?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# --- Route descriptor hooks -----------------------------------------------
|
|
219
|
+
#
|
|
220
|
+
# The gem is UI-agnostic and doesn't know the host's routes, so a reportable
|
|
221
|
+
# tells the gem how to build the few URLs/paths that decision notices and the
|
|
222
|
+
# public DSA notice form need. Each receives the host's route proxy (whatever
|
|
223
|
+
# responds to the app's `*_url`/`*_path` helpers — typically the controller or
|
|
224
|
+
# `Rails.application.routes.url_helpers`) and returns a string or nil.
|
|
225
|
+
#
|
|
226
|
+
# All default to nil — the gem treats a missing route as "no link available"
|
|
227
|
+
# and degrades gracefully (e.g. the statement of reasons just omits the link,
|
|
228
|
+
# the post-report redirect falls back to the app root). Hosts override only the
|
|
229
|
+
# ones their flows actually surface.
|
|
230
|
+
|
|
231
|
+
# The public, canonical URL of this content — what a DSA Art. 16 notice records
|
|
232
|
+
# as the precise location of the allegedly illegal content, and what a
|
|
233
|
+
# statement of reasons points the affected user to.
|
|
234
|
+
def moderation_subject_url(_routes)
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Where to send a user *back to* after they file a report on this content
|
|
239
|
+
# (e.g. the content's own page). Falls back to the app root when nil.
|
|
240
|
+
def moderation_return_path(_routes)
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# The admin/back-office path for this content, so the moderation queue can deep
|
|
245
|
+
# link a reviewer straight to the item under review.
|
|
246
|
+
def moderation_admin_path(_routes)
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
# Is `viewer` the user responsible for this content? Used by the default
|
|
253
|
+
# `report_visible_to?` to hide the report affordance from the content's own owner.
|
|
254
|
+
#
|
|
255
|
+
# We resolve ownership via the Reportable contract's `reported_owner`, but guard
|
|
256
|
+
# it carefully: `reported_owner` deliberately RAISES NotImplementedError on a
|
|
257
|
+
# reportable that hasn't defined it (so the omission is loud at report time). A
|
|
258
|
+
# visibility check, by contrast, must never blow up a view — so we rescue that
|
|
259
|
+
# case and treat "unknown owner" as "not the viewer" (show the link; the write
|
|
260
|
+
# path will still surface the missing-owner error if they actually report). We
|
|
261
|
+
# compare by primary key when both sides are AR records, else by object equality.
|
|
262
|
+
def moderation_owner_is?(viewer)
|
|
263
|
+
owner = reported_owner
|
|
264
|
+
return false if owner.nil?
|
|
265
|
+
|
|
266
|
+
if owner.respond_to?(:id) && viewer.respond_to?(:id)
|
|
267
|
+
owner.id == viewer.id && owner.class == viewer.class
|
|
268
|
+
else
|
|
269
|
+
owner == viewer
|
|
270
|
+
end
|
|
271
|
+
rescue NotImplementedError
|
|
272
|
+
false
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def moderation_scope_by_field(scope, column, field)
|
|
276
|
+
field_s = field.to_s.squish
|
|
277
|
+
return scope if field_s.blank?
|
|
278
|
+
|
|
279
|
+
scope.where(column => field_s)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# A system/auto-filter flag: the record an adapter (or a manual action) leaves
|
|
5
|
+
# behind when content is flagged for review rather than rejected outright.
|
|
6
|
+
#
|
|
7
|
+
# Flags are what `:flag`-mode filtering produces AFTER COMMIT — the write
|
|
8
|
+
# succeeds, then a Flag lands in the queue. This is intentional and load-bearing:
|
|
9
|
+
# a flag must NEVER be created inside a validator/transaction, because a validator
|
|
10
|
+
# is supposed to be side-effect-free and a Flag written inside a transaction that
|
|
11
|
+
# later rolls back would silently vanish (you'd "moderate" content that was never
|
|
12
|
+
# saved). The Filterable concern enforces the after_commit timing; this model is
|
|
13
|
+
# just the record + the queue + the "content flagged" notification.
|
|
14
|
+
#
|
|
15
|
+
# The same `pending` scope serves BOTH a human admin queue and an automated
|
|
16
|
+
# consumer (e.g. a job that auto-actions high-confidence flags), so the gem
|
|
17
|
+
# doesn't presume a human is the only reviewer.
|
|
18
|
+
class Flag < ApplicationRecord
|
|
19
|
+
self.table_name = "moderate_flags"
|
|
20
|
+
|
|
21
|
+
STATUSES = %w[pending actioned dismissed].freeze
|
|
22
|
+
|
|
23
|
+
# Built-in/generic source names. Host-registered adapter names are also valid
|
|
24
|
+
# sources (see `.sources` below) because `Moderate.classify` stamps the adapter
|
|
25
|
+
# name onto the Result when the adapter does not set one explicitly. This is why
|
|
26
|
+
# a host can register `:openai` or `:image` and see that exact name in the queue.
|
|
27
|
+
SOURCES = %w[text_filter image_filter external_classifier manual].freeze
|
|
28
|
+
|
|
29
|
+
# What the flag WOULD do. `:flag` allowed the write and queued it; `:block`
|
|
30
|
+
# rejected the write (a block-mode trip can also be recorded as a flag for the
|
|
31
|
+
# audit trail). Validated by the model (inclusion), not a DB constraint.
|
|
32
|
+
MODES = %w[flag block].freeze
|
|
33
|
+
|
|
34
|
+
# The flagged content is polymorphic — any `Moderate::Reportable`. `owner` is the
|
|
35
|
+
# responsible user (inferred from the flaggable's `reported_owner`), kept here so
|
|
36
|
+
# the queue can group flags by user without re-resolving ownership; optional
|
|
37
|
+
# because not all content has a single owning account. `reviewed_by` is the
|
|
38
|
+
# moderator who closed it. All user associations resolve to the host's configured
|
|
39
|
+
# user class, read lazily from config as a String.
|
|
40
|
+
belongs_to :flaggable, polymorphic: true
|
|
41
|
+
belongs_to :owner, class_name: Moderate.config.user_class, optional: true
|
|
42
|
+
belongs_to :reviewed_by, class_name: Moderate.config.user_class, optional: true
|
|
43
|
+
|
|
44
|
+
# Default the JSON columns to their empty shape before save: `categories` is a
|
|
45
|
+
# list ([]), `scores`/`context` are hashes ({}). The migration makes all three
|
|
46
|
+
# NOT NULL, but MySQL 8+ forbids a JSON DEFAULT, so a Flag built directly (rather
|
|
47
|
+
# than via `flag!`, which already coerces them) could write NULL and trip a
|
|
48
|
+
# NotNullViolation on MySQL. (SQLite/PostgreSQL get the defaults from the
|
|
49
|
+
# migration; coalescing is harmless there.)
|
|
50
|
+
before_save :default_json_columns
|
|
51
|
+
|
|
52
|
+
# Announce a new flag through the host's notify hook so admins get pinged (e.g.
|
|
53
|
+
# a Telegram alert) the moment content is flagged. after_create_COMMIT, not
|
|
54
|
+
# after_create: the flag must be durably saved before we tell anyone about it,
|
|
55
|
+
# and the notify must not be able to roll the flag back.
|
|
56
|
+
after_create_commit :notify_content_flagged
|
|
57
|
+
|
|
58
|
+
scope :pending, -> { where(status: "pending") }
|
|
59
|
+
scope :actioned, -> { where(status: "actioned") }
|
|
60
|
+
scope :dismissed, -> { where(status: "dismissed") }
|
|
61
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
62
|
+
|
|
63
|
+
validates :field, presence: true
|
|
64
|
+
validates :status, inclusion: { in: STATUSES }
|
|
65
|
+
validates :source, inclusion: { in: ->(_flag) { sources } }, on: :create
|
|
66
|
+
validates :mode, inclusion: { in: MODES }
|
|
67
|
+
validates :resolution_note, presence: true, if: :closed?
|
|
68
|
+
|
|
69
|
+
# The single entry point the Filterable concern uses to file a flag. Centralizes
|
|
70
|
+
# coercion (categories → Array, scores/context → Hash) so callers can hand us
|
|
71
|
+
# whatever shape a Moderate::Result exposed without each one re-normalizing.
|
|
72
|
+
# `context` is freeform JSON for audit (which policy fired, the raw classifier
|
|
73
|
+
# payload, etc.) — never relied on by the gem's own logic.
|
|
74
|
+
def self.flag!(flaggable:, field:, owner:, source:, mode:, excerpt:, categories:, scores:, context:)
|
|
75
|
+
create!(
|
|
76
|
+
flaggable: flaggable,
|
|
77
|
+
field: field,
|
|
78
|
+
owner: owner,
|
|
79
|
+
source: source,
|
|
80
|
+
mode: mode,
|
|
81
|
+
excerpt: excerpt,
|
|
82
|
+
categories: Array(categories),
|
|
83
|
+
scores: scores.to_h,
|
|
84
|
+
context: context.to_h
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def self.sources
|
|
89
|
+
(SOURCES + Moderate.config.adapters.keys.map(&:to_s)).uniq
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pending?
|
|
93
|
+
status == "pending"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def closed?
|
|
97
|
+
status.in?(%w[actioned dismissed])
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A label for the flagged thing in the queue. Asks the flaggable for its own
|
|
101
|
+
# `moderation_label` (Moderate::Reportable interface); falls back to a generic
|
|
102
|
+
# "Type id" string for content that doesn't implement it.
|
|
103
|
+
def flaggable_label
|
|
104
|
+
return flaggable.moderation_label if flaggable.respond_to?(:moderation_label)
|
|
105
|
+
|
|
106
|
+
"#{flaggable_type} #{flaggable_id}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# See the before_save comment: keep the NOT-NULL JSON columns non-null on MySQL.
|
|
112
|
+
def default_json_columns
|
|
113
|
+
self.categories ||= []
|
|
114
|
+
self.scores ||= {}
|
|
115
|
+
self.context ||= {}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def notify_content_flagged
|
|
119
|
+
# `content_flagged` has NO user recipient by design — it's an admin/system
|
|
120
|
+
# signal, not a message to the content owner — so the Event's recipients stay
|
|
121
|
+
# empty and the host's notify hook routes it to admin channels only.
|
|
122
|
+
Moderate.notify(
|
|
123
|
+
:content_flagged,
|
|
124
|
+
subject: self,
|
|
125
|
+
payload: {
|
|
126
|
+
flaggable_type: flaggable_type,
|
|
127
|
+
flaggable_id: flaggable_id,
|
|
128
|
+
field: field,
|
|
129
|
+
source: source,
|
|
130
|
+
categories: Array(categories),
|
|
131
|
+
summary: "content flagged (#{source}) on #{flaggable_label}##{field}"
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|