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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "label"
|
|
4
|
+
|
|
5
|
+
module Moderate
|
|
6
|
+
# The single return type of every filter adapter: `adapter.classify(value) → Moderate::Result`.
|
|
7
|
+
#
|
|
8
|
+
# This is the gem's content-filtering value object — immutable, frozen at
|
|
9
|
+
# construction. It answers the two questions the rest of the gem asks ("is this
|
|
10
|
+
# allowed?" and "if not, why?") and carries the per-label detail for the
|
|
11
|
+
# moderation queue, the DSA statement of reasons, and the transparency counters.
|
|
12
|
+
#
|
|
13
|
+
# The public surface follows the README's "Content filtering" section verbatim:
|
|
14
|
+
# result.allowed? # => false
|
|
15
|
+
# result.flagged? # => true (the inverse — convenience for the validator)
|
|
16
|
+
# result.categories # => [:hate, :"hate/threatening"] (canonical slugs)
|
|
17
|
+
# result.scores # => { "hate" => 0.97, "hate/threatening" => 0.81 }
|
|
18
|
+
# result.labels # => [#<Moderate::Label ...>, ...]
|
|
19
|
+
# result.source # => "wordlist" / "openai" / your adapter name
|
|
20
|
+
# result.raw # => the untouched provider response (for debugging/audit)
|
|
21
|
+
#
|
|
22
|
+
# Built on `Data.define` (Ruby 3.2+) for a frozen value object. We expose a
|
|
23
|
+
# keyword `.new` whose contract is forgiving: an adapter can hand us either a
|
|
24
|
+
# rich `labels:` array OR the flatter `categories:`/`scores:` shape (the simpler
|
|
25
|
+
# shape a deterministic adapter naturally returns), and we reconcile both into a
|
|
26
|
+
# coherent Result.
|
|
27
|
+
Result = Data.define(:allowed, :labels, :source, :raw) do
|
|
28
|
+
# @param allowed [Boolean, nil] explicit allow/deny. If nil, we infer it
|
|
29
|
+
# from whether any label is flagged (no labels ⇒ allowed).
|
|
30
|
+
# @param labels [Array<Moderate::Label, Hash>] rich per-label verdicts.
|
|
31
|
+
# Hashes are coerced to `Moderate::Label`. Optional.
|
|
32
|
+
# @param categories [Array<String, Symbol>] flat canonical slugs, the simpler
|
|
33
|
+
# shape adapters may return instead of `labels:`. Each becomes a Label
|
|
34
|
+
# (parsing "hate/threatening" → category :hate, subcategory :threatening).
|
|
35
|
+
# @param scores [Hash] slug => 0..1 score, merged onto the labels built
|
|
36
|
+
# from `categories:` (and onto label slugs generally).
|
|
37
|
+
# @param source [String, Symbol] the adapter name that produced this — the
|
|
38
|
+
# value recorded as `Moderate::Flag#source` so the queue shows which backend
|
|
39
|
+
# flagged each item. Defaults to "unknown".
|
|
40
|
+
# @param raw [Object] the untouched provider payload, kept for audit
|
|
41
|
+
# and debugging. Never relied on by the gem's own logic.
|
|
42
|
+
def initialize(allowed: nil, labels: nil, categories: nil, scores: nil, source: nil, raw: nil)
|
|
43
|
+
scores_hash = normalize_scores(scores)
|
|
44
|
+
built_labels = build_labels(labels, categories, scores_hash)
|
|
45
|
+
|
|
46
|
+
# Infer `allowed` when the adapter didn't say: any flagged label ⇒ denied.
|
|
47
|
+
# This lets a deterministic adapter return just `categories: [...]` and have
|
|
48
|
+
# the verdict fall out correctly, without having to compute `allowed` itself.
|
|
49
|
+
resolved_allowed =
|
|
50
|
+
if allowed.nil?
|
|
51
|
+
built_labels.none?(&:flagged)
|
|
52
|
+
else
|
|
53
|
+
allowed ? true : false
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
super(
|
|
57
|
+
allowed: resolved_allowed,
|
|
58
|
+
labels: built_labels.freeze,
|
|
59
|
+
source: (source || "unknown").to_s,
|
|
60
|
+
raw: raw
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Convenience builder for the most common deterministic case: nothing matched.
|
|
65
|
+
def self.allowed(source: nil, raw: nil)
|
|
66
|
+
new(allowed: true, labels: [], source: source, raw: raw)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def allowed? = allowed
|
|
70
|
+
|
|
71
|
+
# The inverse of `allowed?`. The validator and the `moderates` concern read
|
|
72
|
+
# this; it's spelled out (rather than `!allowed`) because "flagged?" is the
|
|
73
|
+
# word everyone reaches for.
|
|
74
|
+
def flagged? = !allowed
|
|
75
|
+
|
|
76
|
+
# Canonical category slugs as symbols, e.g. [:hate, :"hate/threatening"].
|
|
77
|
+
# Only flagged labels count — an adapter may return a full score map including
|
|
78
|
+
# non-tripping categories, and those shouldn't show up as "the categories this
|
|
79
|
+
# tripped". De-duplicated, order-preserving.
|
|
80
|
+
def categories
|
|
81
|
+
flagged_labels.map { |label| label.slug.to_sym }.uniq
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# slug => score, e.g. { "hate" => 0.97, "hate/threatening" => 0.81 }. String
|
|
85
|
+
# keys to match OpenAI's wire format and what we persist on the Flag. Skips
|
|
86
|
+
# labels with a nil score (a deterministic adapter may not provide one).
|
|
87
|
+
def scores
|
|
88
|
+
flagged_labels.each_with_object({}) do |label, acc|
|
|
89
|
+
acc[label.slug] = label.score unless label.score.nil?
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def flagged_labels
|
|
96
|
+
labels.select(&:flagged)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_scores(scores)
|
|
100
|
+
return {} if scores.nil?
|
|
101
|
+
|
|
102
|
+
# Accept symbol- or string-keyed maps; normalize keys to the slug string so
|
|
103
|
+
# we can look up by a Label's slug regardless of how the adapter keyed them.
|
|
104
|
+
scores.each_with_object({}) do |(key, value), acc|
|
|
105
|
+
acc[key.to_s] = value&.to_f
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Reconcile the two accepted shapes (`labels:` and `categories:`+`scores:`)
|
|
110
|
+
# into one frozen array of `Moderate::Label`.
|
|
111
|
+
def build_labels(labels, categories, scores_hash)
|
|
112
|
+
out = []
|
|
113
|
+
|
|
114
|
+
Array(labels).each do |label|
|
|
115
|
+
out << coerce_label(label, scores_hash)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Array(categories).each do |category|
|
|
119
|
+
out << label_from_slug(category.to_s, scores_hash)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
out
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def coerce_label(label, scores_hash)
|
|
126
|
+
return apply_score(label, scores_hash) if label.is_a?(Moderate::Label)
|
|
127
|
+
|
|
128
|
+
# Allow a plain Hash (e.g. from a deserialized adapter response).
|
|
129
|
+
label_from_hash(label.to_h, scores_hash)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Backfill a Label's score from the scores map when the Label itself carries
|
|
133
|
+
# none — so an adapter can pass `labels:` for structure and `scores:` for
|
|
134
|
+
# confidence as two parallel inputs.
|
|
135
|
+
def apply_score(label, scores_hash)
|
|
136
|
+
return label unless label.score.nil?
|
|
137
|
+
|
|
138
|
+
score = scores_hash[label.slug]
|
|
139
|
+
return label if score.nil?
|
|
140
|
+
|
|
141
|
+
Moderate::Label.new(
|
|
142
|
+
category: label.category, subcategory: label.subcategory,
|
|
143
|
+
score: score, flagged: label.flagged, input: label.input
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def label_from_hash(hash, scores_hash)
|
|
148
|
+
hash = hash.transform_keys { |k| k.to_s }
|
|
149
|
+
category = hash["category"]
|
|
150
|
+
subcategory = hash["subcategory"]
|
|
151
|
+
slug = subcategory ? "#{category}/#{subcategory}" : category.to_s
|
|
152
|
+
|
|
153
|
+
Moderate::Label.new(
|
|
154
|
+
category: category,
|
|
155
|
+
subcategory: subcategory,
|
|
156
|
+
score: hash.fetch("score", scores_hash[slug]),
|
|
157
|
+
flagged: hash.fetch("flagged", true),
|
|
158
|
+
input: hash.fetch("input", :unknown)
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Parse a canonical slug ("hate/threatening" or "hate") into a Label, pulling
|
|
163
|
+
# its score from the scores map if present.
|
|
164
|
+
def label_from_slug(slug, scores_hash)
|
|
165
|
+
category, subcategory = slug.split("/", 2)
|
|
166
|
+
|
|
167
|
+
Moderate::Label.new(
|
|
168
|
+
category: category,
|
|
169
|
+
subcategory: subcategory,
|
|
170
|
+
score: scores_hash[slug],
|
|
171
|
+
flagged: true,
|
|
172
|
+
input: :unknown
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
module Services
|
|
5
|
+
# IntakeAppeal — persistence path for a DSA Article 20 internal complaint
|
|
6
|
+
# ("appeal") against a moderation decision.
|
|
7
|
+
#
|
|
8
|
+
# Art. 20 requires an internal complaint-handling system that is FREE, by
|
|
9
|
+
# ELECTRONIC means, open for AT LEAST SIX MONTHS after the decision, and decided
|
|
10
|
+
# by a human (not solely automated). This service owns only the *intake* half:
|
|
11
|
+
# validating + persisting the complaint and emitting the `appeal_received`
|
|
12
|
+
# receipt. The human decision lives in ResolveAppeal.
|
|
13
|
+
# See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 20).
|
|
14
|
+
#
|
|
15
|
+
# The model enforces the legal preconditions (the report must be closed and its
|
|
16
|
+
# appeal window still open) as validations, so a save that violates them returns
|
|
17
|
+
# false with errors — this service doesn't re-implement that, it just runs the
|
|
18
|
+
# surrounding side effects on success.
|
|
19
|
+
#
|
|
20
|
+
# HOST-AGNOSTIC: the appellant may be a User (a logged-in affected party) OR an
|
|
21
|
+
# anonymous notifier (name + email) appealing a public-notice decision. We never
|
|
22
|
+
# assume a User.
|
|
23
|
+
class IntakeAppeal
|
|
24
|
+
# @param appeal [Moderate::Appeal] an unsaved Appeal the caller has populated
|
|
25
|
+
# (reason, source, appellant contact). The caller owns strong-params; we own
|
|
26
|
+
# save + side effects.
|
|
27
|
+
# @param report [Moderate::Report] the decision being appealed.
|
|
28
|
+
# @param appellant [user_class, nil] the complainant, when they're a User.
|
|
29
|
+
def initialize(appeal:, report:, appellant: nil)
|
|
30
|
+
@appeal = appeal
|
|
31
|
+
@appeal.assign_attributes(report: report, appellant: appellant)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
attr_reader :appeal
|
|
35
|
+
|
|
36
|
+
def save
|
|
37
|
+
return false unless appeal.save
|
|
38
|
+
|
|
39
|
+
deliver_receipt
|
|
40
|
+
audit_intake
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# The appellant's receipt ("we got your appeal; a person will review it").
|
|
47
|
+
# Recipient is the User when present, else the lightweight notifier struct,
|
|
48
|
+
# so the host's notify hook addresses both the same way (`recipient.email`).
|
|
49
|
+
def deliver_receipt
|
|
50
|
+
Moderate.notify(
|
|
51
|
+
:appeal_received,
|
|
52
|
+
subject: appeal,
|
|
53
|
+
actor: appeal.appellant,
|
|
54
|
+
recipients: [appellant_recipient].compact,
|
|
55
|
+
payload: {
|
|
56
|
+
report_id: appeal.report_id,
|
|
57
|
+
source: appeal.source,
|
|
58
|
+
summary: "New appeal on Report ##{appeal.report_id}"
|
|
59
|
+
}
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def audit_intake
|
|
64
|
+
Moderate.audit(
|
|
65
|
+
:appeal_received,
|
|
66
|
+
subject: appeal,
|
|
67
|
+
actor: appeal.appellant,
|
|
68
|
+
payload: {
|
|
69
|
+
appeal_id: appeal.id,
|
|
70
|
+
report_id: appeal.report_id,
|
|
71
|
+
source: appeal.source,
|
|
72
|
+
summary: "Appeal ##{appeal.id} filed on Report ##{appeal.report_id}"
|
|
73
|
+
}.compact
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Prefer the User; fall back to a non-User recipient carrying just the email/
|
|
78
|
+
# name an anonymous appellant supplied. Reuses the same lightweight-recipient
|
|
79
|
+
# contract as IntakeNotice (responds to email/name only) so host mailers don't
|
|
80
|
+
# special-case appeals.
|
|
81
|
+
def appellant_recipient
|
|
82
|
+
return appeal.appellant if appeal.appellant
|
|
83
|
+
return nil if appeal.appellant_email.blank?
|
|
84
|
+
|
|
85
|
+
IntakeNotice::NotifierRecipient.new(appeal.appellant_email, appeal.appellant_name)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
module Services
|
|
5
|
+
# IntakeNotice — persistence path for a *public* DSA Article 16 notice.
|
|
6
|
+
#
|
|
7
|
+
# This is the regulator-facing intake: the "Report illegal content (EU)" form
|
|
8
|
+
# you see at the bottom of X / YouTube / Reddit, open to ANYONE (not just
|
|
9
|
+
# logged-in users), by electronic means, with a confirmation of receipt. It is
|
|
10
|
+
# legally distinct from the in-app "Report" button:
|
|
11
|
+
# - it carries a `legal_reason` from the DSA statement-of-reasons taxonomy
|
|
12
|
+
# (the law's vocabulary) rather than a community `category` (your rules);
|
|
13
|
+
# - the notifier may be anonymous (a name+email, not a User);
|
|
14
|
+
# - it requires the exact electronic location (URL) of the content — Art.16(2)(b);
|
|
15
|
+
# - it requires a good-faith attestation — Art.16(2)(d).
|
|
16
|
+
# See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 16).
|
|
17
|
+
#
|
|
18
|
+
# A notice is NOT a fourth table: it's a `Moderate::Report` with
|
|
19
|
+
# `intake_kind: "dsa"`, so it shares the same queue, evidence snapshot, appeal
|
|
20
|
+
# window, statement-of-reasons path, and Art. 24 transparency counters as an
|
|
21
|
+
# in-app report. One queue, one decision workflow, two front doors. This service
|
|
22
|
+
# builds that notice-kind Report and hands it to IntakeReport for the shared
|
|
23
|
+
# persistence + side effects, then emits the notice-specific `notice_received`
|
|
24
|
+
# event whose delivery boolean gates the Art. 16(4) "confirmation of receipt".
|
|
25
|
+
#
|
|
26
|
+
# HOST-AGNOSTIC: no concrete content type is named. The URL is resolved to a
|
|
27
|
+
# reportable record (if the host can) by the Report model's snapshot logic; this
|
|
28
|
+
# service only owns intake orchestration and the legal-email gating.
|
|
29
|
+
class IntakeNotice
|
|
30
|
+
# @param attributes [Hash] the public-form attributes, already strong-param'd
|
|
31
|
+
# by the controller: notifier_name, notifier_email, good_faith_confirmed,
|
|
32
|
+
# legal_reason, legal_country_code, subject_url(s), content_type, message
|
|
33
|
+
# (the substantiated explanation), anonymous, reported_account_identifier.
|
|
34
|
+
# @param reporter [user_class, nil] the submitter IF they happened to be
|
|
35
|
+
# logged in (the form is public, so usually nil).
|
|
36
|
+
def initialize(attributes:, reporter: nil)
|
|
37
|
+
# Force the DSA shape regardless of what the form posted: a public notice is
|
|
38
|
+
# always intake_kind "dsa". We default the community `category` to a neutral
|
|
39
|
+
# "illegal_content" because the column is NOT NULL and a notice's real
|
|
40
|
+
# taxonomy lives in `legal_reason` — the category is just the bucket that
|
|
41
|
+
# keeps a notice in the same queue as community reports.
|
|
42
|
+
@report = Moderate::Report.new(attributes)
|
|
43
|
+
@report.assign_attributes(
|
|
44
|
+
intake_kind: "dsa",
|
|
45
|
+
category: @report.category.presence || "illegal_content"
|
|
46
|
+
)
|
|
47
|
+
@report.skip_received_notice = true
|
|
48
|
+
@reporter = reporter
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :report
|
|
52
|
+
|
|
53
|
+
def save
|
|
54
|
+
# Reuse the shared intake (save + acknowledge! + audit). We pass the report
|
|
55
|
+
# straight through — it already carries the notice attributes and the forced
|
|
56
|
+
# DSA shape. The reporter is the actor when present (a logged-in submitter);
|
|
57
|
+
# for a truly anonymous notice the actor is nil, which the event envelope
|
|
58
|
+
# handles (system/no-actor events are normal).
|
|
59
|
+
intake = IntakeReport.new(
|
|
60
|
+
report: report,
|
|
61
|
+
reporter: reporter,
|
|
62
|
+
reportable: report.reportable,
|
|
63
|
+
reported_field: report.reported_field,
|
|
64
|
+
actor: reporter
|
|
65
|
+
)
|
|
66
|
+
return false unless intake.save
|
|
67
|
+
|
|
68
|
+
deliver_confirmation_of_receipt
|
|
69
|
+
true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
attr_reader :reporter
|
|
75
|
+
|
|
76
|
+
# DSA Art. 16(4): "the provider shall, without undue delay, send to that
|
|
77
|
+
# individual or entity a confirmation of receipt of the notice."
|
|
78
|
+
#
|
|
79
|
+
# We send it to the NOTIFIER (who is often not a User — an anonymous person
|
|
80
|
+
# with just an email), distinct from IntakeReport's in-app receipt. The notify
|
|
81
|
+
# hook returns a "delivered" boolean: when it's truthy we stamp
|
|
82
|
+
# `decision_notified_at`... no — we stamp nothing here, because the durable
|
|
83
|
+
# legal proof of *receipt* is `acknowledged_at` (already set by IntakeReport).
|
|
84
|
+
# The boolean instead lets the controller fall back to an on-screen receipt
|
|
85
|
+
# (the form shows a reference number) when the host hasn't wired a mailer —
|
|
86
|
+
# that's the whole reason `Moderate.notify` returns delivered/undelivered
|
|
87
|
+
# rather than nothing. See docs/notifications.md and docs/dsa-notice-form.md.
|
|
88
|
+
#
|
|
89
|
+
# The recipient is a lightweight notifier struct (responds to email/name) when
|
|
90
|
+
# the submitter isn't a User, so the host's `notify` hook can email it the same
|
|
91
|
+
# way it emails a real user — the recipes guard with `respond_to?(:email)`.
|
|
92
|
+
def deliver_confirmation_of_receipt
|
|
93
|
+
return false if report.notifier_email.blank?
|
|
94
|
+
|
|
95
|
+
Moderate.notify(
|
|
96
|
+
:notice_received,
|
|
97
|
+
subject: report,
|
|
98
|
+
actor: reporter,
|
|
99
|
+
recipients: [notifier_recipient],
|
|
100
|
+
payload: {
|
|
101
|
+
legal_reason: report.legal_reason,
|
|
102
|
+
legal_country_code: report.legal_country_code,
|
|
103
|
+
subject_url: report.subject_url,
|
|
104
|
+
# Redaction-safe admin one-liner. We deliberately keep host content out
|
|
105
|
+
# of it — just the legal ground and an opaque pointer to the record.
|
|
106
|
+
summary: "New DSA notice (#{report.legal_reason || 'illegal_content'}) — Report ##{report.id}"
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# A non-User recipient the host's mailer can address. We prefer the real
|
|
112
|
+
# reporter (a User) when the submitter was logged in; otherwise we build a
|
|
113
|
+
# minimal value object that quacks like a recipient (responds to `email`,
|
|
114
|
+
# `name`) — documented in docs/notifications.md as the "lightweight recipient"
|
|
115
|
+
# for anonymous DSA notifiers.
|
|
116
|
+
def notifier_recipient
|
|
117
|
+
return report.reporter if report.reporter
|
|
118
|
+
|
|
119
|
+
NotifierRecipient.new(report.notifier_email, report.notifier_name)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The anonymous-notifier recipient. Intentionally tiny and NOT a User: the
|
|
123
|
+
# host's notify hook reaches `recipient.email` / `recipient.name` and nothing
|
|
124
|
+
# else. Frozen value object.
|
|
125
|
+
NotifierRecipient = Data.define(:email, :name) do
|
|
126
|
+
# Mirror the User-ish reader some host mailers reach for, so a notifier and a
|
|
127
|
+
# User are interchangeable at the `recipient.email` call site.
|
|
128
|
+
def display_name = name.to_s.empty? ? email : name
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
module Services
|
|
5
|
+
# IntakeReport — the single persistence path for an *in-app* community report
|
|
6
|
+
# (a logged-in actor tapping "Report" on a piece of content or another user).
|
|
7
|
+
#
|
|
8
|
+
# WHY a service object and not just `Report.create`:
|
|
9
|
+
# - Intake is more than a save. After the row commits we must (a) acknowledge
|
|
10
|
+
# receipt, (b) emit the `report_received` event (the reporter's receipt +
|
|
11
|
+
# the admin ping), and (c) write the immutable audit record. Keeping all of
|
|
12
|
+
# that in one object means the model stays a plain validating record and the
|
|
13
|
+
# side effects live in exactly one place — easy to test, impossible to
|
|
14
|
+
# accidentally skip from a second call site.
|
|
15
|
+
# - The same shape is reused by the public DSA notice path (see IntakeNotice),
|
|
16
|
+
# which delegates here once it has assembled a notice-kind Report. One intake,
|
|
17
|
+
# two front doors.
|
|
18
|
+
#
|
|
19
|
+
# This object is HOST-AGNOSTIC: it never references a concrete content type. The
|
|
20
|
+
# `has_reportable_content` is any `Moderate::Reportable` record (polymorphic), the actor is
|
|
21
|
+
# whatever `Moderate.user_class` resolves to, and notification/audit go through
|
|
22
|
+
# the configured hooks — never a hard-wired mailer.
|
|
23
|
+
class IntakeReport
|
|
24
|
+
# @param report [Moderate::Report] an unsaved Report the caller has already
|
|
25
|
+
# populated (category, message, etc.). Letting the caller build the record
|
|
26
|
+
# keeps this service free of the (host-specific) strong-params shape — the
|
|
27
|
+
# controller/macro decides which attributes are permitted; we own the save +
|
|
28
|
+
# side effects.
|
|
29
|
+
# @param reporter [user_class, nil] who filed it. nil for an anonymous public
|
|
30
|
+
# notice (the DSA path), present for an in-app report.
|
|
31
|
+
# @param reportable [Moderate::Reportable, nil] the reported content/record.
|
|
32
|
+
# @param reported_field [String, Symbol, nil] which field was reported.
|
|
33
|
+
# @param actor [user_class, nil] who triggered the intake for the audit/event
|
|
34
|
+
# envelope (defaults to the reporter — they're the same person for an in-app
|
|
35
|
+
# report; a public notice may have no actor).
|
|
36
|
+
def initialize(report:, reporter: nil, reportable: nil, reported_field: nil, actor: :reporter)
|
|
37
|
+
@report = report
|
|
38
|
+
@report.assign_attributes(
|
|
39
|
+
reporter: reporter,
|
|
40
|
+
reportable: reportable,
|
|
41
|
+
reported_field: reported_field&.to_s
|
|
42
|
+
)
|
|
43
|
+
# ":reporter" sentinel means "use the reporter as the actor" — the common
|
|
44
|
+
# case — while still letting a caller pass `actor: nil` explicitly.
|
|
45
|
+
@actor = actor == :reporter ? reporter : actor
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :report
|
|
49
|
+
|
|
50
|
+
# Persist + run side effects. Returns true on success, false if validation
|
|
51
|
+
# failed (the report carries its errors, Rails-conventionally) so a controller
|
|
52
|
+
# can `if intake.save ... else render :new` exactly like a bare model save.
|
|
53
|
+
def save
|
|
54
|
+
return false unless report.save
|
|
55
|
+
|
|
56
|
+
# DSA Art. 16(4): the provider must confirm receipt "without undue delay."
|
|
57
|
+
# `acknowledge!` stamps `acknowledged_at` — the durable, on-record proof of
|
|
58
|
+
# receipt — BEFORE we attempt the (best-effort, possibly-undelivered) email,
|
|
59
|
+
# so the legal obligation is met by the database fact, not by a mailer that
|
|
60
|
+
# might not be wired. See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj
|
|
61
|
+
report.acknowledge!
|
|
62
|
+
|
|
63
|
+
deliver_receipt
|
|
64
|
+
audit_intake
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
attr_reader :actor
|
|
71
|
+
|
|
72
|
+
# The reporter's receipt. We only attempt it when there's somewhere to send it
|
|
73
|
+
# (a notifier_email) — an in-app reporter without a contact email simply gets
|
|
74
|
+
# the in-app acknowledgement instead. `Moderate.notify` returns a "delivered"
|
|
75
|
+
# boolean; we don't gate anything on it here (the durable receipt is
|
|
76
|
+
# `acknowledged_at`, set above), but the recipient list is still resolved so
|
|
77
|
+
# the host's single notify hook can email AND ping admins from one event.
|
|
78
|
+
def deliver_receipt
|
|
79
|
+
return if report.skip_received_notice
|
|
80
|
+
return if recipient_email.blank?
|
|
81
|
+
|
|
82
|
+
Moderate.notify(
|
|
83
|
+
:report_received,
|
|
84
|
+
subject: report,
|
|
85
|
+
actor: actor,
|
|
86
|
+
recipients: [report.reporter].compact,
|
|
87
|
+
payload: {
|
|
88
|
+
category: report.category,
|
|
89
|
+
intake_kind: report.intake_kind,
|
|
90
|
+
# `:summary` is the contract every event carries — a redaction-safe,
|
|
91
|
+
# ready-to-send one-liner for the admin Telegram ping (docs/notifications.md).
|
|
92
|
+
summary: "New #{report.category} report on #{reportable_label}"
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Append-only audit of the intake itself (separate from the notify hook, which
|
|
98
|
+
# is for humans). `Moderate.audit` swallows its own errors, so a broken audit
|
|
99
|
+
# sink can never roll back an accepted report.
|
|
100
|
+
def audit_intake
|
|
101
|
+
Moderate.audit(
|
|
102
|
+
:report_received,
|
|
103
|
+
subject: report,
|
|
104
|
+
actor: actor,
|
|
105
|
+
payload: {
|
|
106
|
+
report_id: report.id,
|
|
107
|
+
reportable_type: report.reportable_type,
|
|
108
|
+
reportable_id: report.reportable_id,
|
|
109
|
+
reported_user_id: report.reported_user_id,
|
|
110
|
+
category: report.category,
|
|
111
|
+
intake_kind: report.intake_kind,
|
|
112
|
+
summary: "Report ##{report.id} filed (#{report.category})"
|
|
113
|
+
}.compact
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def recipient_email
|
|
118
|
+
report.notifier_email
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The reportable's own human label, or a neutral fallback — NEVER a host-
|
|
122
|
+
# specific string. The Reportable concern supplies `moderation_label`.
|
|
123
|
+
def reportable_label
|
|
124
|
+
if report.reportable.respond_to?(:moderation_label)
|
|
125
|
+
report.reportable.moderation_label
|
|
126
|
+
else
|
|
127
|
+
[report.reportable_type, report.reportable_id].compact.join(" #")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
module Services
|
|
5
|
+
# ResolveAppeal — the human decision on a DSA Article 20 appeal, behind
|
|
6
|
+
# `appeal.uphold!` / `appeal.reject!`.
|
|
7
|
+
#
|
|
8
|
+
# Art. 20 demands appeals be decided "in a timely, non-discriminatory, diligent
|
|
9
|
+
# and non-arbitrary manner" and crucially "NOT SOLELY ON THE BASIS OF AUTOMATED
|
|
10
|
+
# MEANS." That last clause is why there is no auto-decide path here at all:
|
|
11
|
+
# `uphold!`/`reject!` REQUIRE a `by:` moderator and a `note:` — a human and a
|
|
12
|
+
# reason — and the model column for the moderator (`resolved_by`) makes the human
|
|
13
|
+
# decider part of the permanent record.
|
|
14
|
+
# See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 20).
|
|
15
|
+
#
|
|
16
|
+
# Same atomic discipline as ResolveReport: the transition runs under a row lock,
|
|
17
|
+
# re-checks `open?` inside the lock (so two moderators can't both decide the same
|
|
18
|
+
# appeal), and the notification happens after the lock releases.
|
|
19
|
+
#
|
|
20
|
+
# NOTE on enforcement: upholding an appeal means the ORIGINAL decision was wrong
|
|
21
|
+
# and should be reversed (Art. 20 outcomes must be acted on). Reversal is
|
|
22
|
+
# host-specific — re-publishing content, lifting a ban — and the gem can't know
|
|
23
|
+
# how to undo an arbitrary host action. So this service records the upheld
|
|
24
|
+
# outcome and emits `appeal_decision`; the host wires the actual reversal off
|
|
25
|
+
# that event (or off its `audit` hook). We document this rather than pretend a
|
|
26
|
+
# generic "un-remove" exists.
|
|
27
|
+
class ResolveAppeal
|
|
28
|
+
def initialize(appeal, by:)
|
|
29
|
+
@appeal = appeal
|
|
30
|
+
@moderator = by
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def uphold!(note:)
|
|
34
|
+
transition!("upheld", note)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reject!(note:)
|
|
38
|
+
transition!("rejected", note)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :appeal, :moderator
|
|
44
|
+
|
|
45
|
+
def transition!(status, note)
|
|
46
|
+
note = require_note!(note)
|
|
47
|
+
|
|
48
|
+
appeal.with_lock do
|
|
49
|
+
appeal.reload
|
|
50
|
+
|
|
51
|
+
# A decided appeal is immutable — re-check inside the lock so a concurrent
|
|
52
|
+
# second decision bails cleanly instead of overwriting the first.
|
|
53
|
+
unless appeal.open?
|
|
54
|
+
appeal.errors.add(:base, already_closed_message)
|
|
55
|
+
raise ActiveRecord::RecordInvalid, appeal
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
appeal.update!(
|
|
59
|
+
status: status,
|
|
60
|
+
resolution_note: note,
|
|
61
|
+
resolved_by: moderator,
|
|
62
|
+
resolved_at: Time.now
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
audit_decision(status, note)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
deliver_decision_notice(status)
|
|
69
|
+
appeal
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Inform the complainant of the appeal outcome and remaining redress (out-of-
|
|
73
|
+
# court dispute settlement / judicial — copy is the host's, it names the
|
|
74
|
+
# jurisdiction). We stamp `decision_notified_at` only when the hook reported a
|
|
75
|
+
# delivery, so the timestamp reflects an actual communication.
|
|
76
|
+
def deliver_decision_notice(status)
|
|
77
|
+
delivered = Moderate.notify(
|
|
78
|
+
:appeal_decision,
|
|
79
|
+
subject: appeal,
|
|
80
|
+
actor: moderator,
|
|
81
|
+
recipients: [appellant_recipient].compact,
|
|
82
|
+
payload: {
|
|
83
|
+
appeal_id: appeal.id,
|
|
84
|
+
report_id: appeal.report_id,
|
|
85
|
+
status: status,
|
|
86
|
+
reason: appeal.resolution_note,
|
|
87
|
+
summary: "Decision on appeal for Report ##{appeal.report_id}: #{status}"
|
|
88
|
+
}.compact
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
appeal.update_column(:decision_notified_at, Time.now) if delivered
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def audit_decision(status, note)
|
|
95
|
+
Moderate.audit(
|
|
96
|
+
:appeal_decision,
|
|
97
|
+
subject: appeal,
|
|
98
|
+
actor: moderator,
|
|
99
|
+
payload: {
|
|
100
|
+
appeal_id: appeal.id,
|
|
101
|
+
report_id: appeal.report_id,
|
|
102
|
+
status: status,
|
|
103
|
+
note: note,
|
|
104
|
+
summary: "Appeal ##{appeal.id} #{status} by moderator"
|
|
105
|
+
}.compact
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The complainant: a User when present, else the lightweight notifier struct.
|
|
110
|
+
def appellant_recipient
|
|
111
|
+
return appeal.appellant if appeal.appellant
|
|
112
|
+
return nil if appeal.appellant_email.blank?
|
|
113
|
+
|
|
114
|
+
IntakeNotice::NotifierRecipient.new(appeal.appellant_email, appeal.appellant_name)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def require_note!(note)
|
|
118
|
+
normalized = note.to_s.strip
|
|
119
|
+
return normalized unless normalized.empty?
|
|
120
|
+
|
|
121
|
+
appeal.errors.add(:resolution_note, :blank)
|
|
122
|
+
raise ActiveRecord::RecordInvalid, appeal
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def already_closed_message
|
|
126
|
+
if defined?(I18n)
|
|
127
|
+
I18n.t("moderate.errors.appeal_already_closed", default: "This appeal has already been decided.")
|
|
128
|
+
else
|
|
129
|
+
"This appeal has already been decided."
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|