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,382 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# The PUBLIC, regulator-facing DSA "notice and action" intake form.
|
|
5
|
+
#
|
|
6
|
+
# The EU Digital Services Act, Article 16, requires every hosting service serving
|
|
7
|
+
# EU users to offer a PUBLIC, ELECTRONIC mechanism for ANYONE (not just logged-in
|
|
8
|
+
# users) to flag illegal content, and to ACKNOWLEDGE receipt of that notice. This
|
|
9
|
+
# controller is that mechanism — the "Report illegal content (EU)" form you see at
|
|
10
|
+
# the bottom of X / YouTube / Reddit. It is separate from the in-app "Report"
|
|
11
|
+
# button (that's the host's BYOUI report controller) and from the admin queue.
|
|
12
|
+
# See: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 16)
|
|
13
|
+
#
|
|
14
|
+
# The controller is intentionally boring: it does HTTP only. All the Trust &
|
|
15
|
+
# Safety work — the Art. 16 field validations, the immutable evidence snapshot,
|
|
16
|
+
# the durable acknowledgement (Art. 16(4)), dropping the row into
|
|
17
|
+
# `Moderate::Report.pending`, and firing the `notice_received` confirmation-of-
|
|
18
|
+
# receipt event — lives in the model/service (`Moderate::Services::IntakeNotice`
|
|
19
|
+
# building a `Moderate::Report` with `intake_kind: "dsa"`), never here.
|
|
20
|
+
#
|
|
21
|
+
# A notice is NOT a fourth table: it is a `Moderate::Report` with
|
|
22
|
+
# `intake_kind: "dsa"`, sharing the same queue, snapshot, appeal window, and Art.
|
|
23
|
+
# 24 transparency counters as an in-app report. One queue, two front doors.
|
|
24
|
+
class NoticesController < Moderate::ApplicationController
|
|
25
|
+
helper_method :turnstile_widget_required?
|
|
26
|
+
|
|
27
|
+
# Hard kill-switch: if a host sets `config.notice_form_enabled = false`, the
|
|
28
|
+
# whole engine surface 404s. Lets an app mount the engine but disable the form
|
|
29
|
+
# (e.g. it doesn't serve EU users) without un-mounting routes.
|
|
30
|
+
before_action :enforce_notice_enabled!
|
|
31
|
+
|
|
32
|
+
# Anti-abuse, defense-in-depth, applied only to the state-changing POST. Both
|
|
33
|
+
# gates degrade to "off" gracefully so the form is never a support burden in
|
|
34
|
+
# environments that don't need them (dev/test, or apps that gate at the edge).
|
|
35
|
+
before_action :throttle_notices!, only: :create
|
|
36
|
+
|
|
37
|
+
# The bot gate, auto-wired. A single before_action that decides AT REQUEST TIME
|
|
38
|
+
# which check to run, so the wiring auto-adapts to whether the host has the
|
|
39
|
+
# `rails_cloudflare_turnstile` gem — no hard dependency, no class-reload to flip
|
|
40
|
+
# the behavior. See #verify_human! near the bottom of this file for the branch.
|
|
41
|
+
before_action :verify_human!, only: :create
|
|
42
|
+
|
|
43
|
+
# GET /notices/new — the form, prefilled (and partially locked) from the request.
|
|
44
|
+
#
|
|
45
|
+
# X-style deep link: a host can link to this form from a piece of content with
|
|
46
|
+
# the reported-content details already in the query string (content_url,
|
|
47
|
+
# content_type, content_author, content_id — see #prefill_attributes), so the
|
|
48
|
+
# notifier doesn't have to copy-paste the URL of what they're flagging. We ALSO
|
|
49
|
+
# prefill the notifier's identity from Devise `current_user` when someone happens
|
|
50
|
+
# to be logged in (the form is still public/anonymous-friendly). The view locks
|
|
51
|
+
# the auto-prefilled IDENTITY fields so they can't be tampered with, while the
|
|
52
|
+
# reported-content fields stay fully editable. DSA Art. 16(2)(b)/(c).
|
|
53
|
+
def new
|
|
54
|
+
@report = Moderate::Report.new(prefill_attributes)
|
|
55
|
+
@identity_locked = identity_locked?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /notices — validate + persist as a DSA-kind Report, fire the confirmation
|
|
59
|
+
# of receipt, and show the submitter a success page. On failure we re-render the
|
|
60
|
+
# form with the model's validation errors and a 422 (standard Rails; lets Turbo
|
|
61
|
+
# replace the form in place).
|
|
62
|
+
def create
|
|
63
|
+
@intake = Moderate::Services::IntakeNotice.new(
|
|
64
|
+
attributes: notice_params,
|
|
65
|
+
reporter: current_notifier
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if @intake.save
|
|
69
|
+
redirect_to(
|
|
70
|
+
new_notice_path,
|
|
71
|
+
notice: t("moderate.notices.received", default: "Notice received. We have logged your report and will review it."),
|
|
72
|
+
status: :see_other
|
|
73
|
+
)
|
|
74
|
+
else
|
|
75
|
+
@report = @intake.report
|
|
76
|
+
@identity_locked = identity_locked?
|
|
77
|
+
render :new, status: :unprocessable_entity
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# --- Prefill (Art. 16(2)(b)/(c)) ------------------------------------------
|
|
84
|
+
|
|
85
|
+
# The attributes used to PREFILL the blank form. Two sources, deliberately kept
|
|
86
|
+
# apart so the view can lock one and leave the other editable:
|
|
87
|
+
# * REPORTED-CONTENT fields, from the query string (an X-style deep link). These
|
|
88
|
+
# stay EDITABLE — the notifier is allowed to correct the URL or pick a
|
|
89
|
+
# different content type.
|
|
90
|
+
# * IDENTITY fields, from Devise `current_user`. These are LOCKED by the view
|
|
91
|
+
# (readonly/disabled) so a logged-in notifier can't spoof someone else's
|
|
92
|
+
# name/email onto a legal notice.
|
|
93
|
+
def prefill_attributes
|
|
94
|
+
content_prefill.merge(identity_prefill)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Reported-content prefill from the query string. The param NAMES are the gem's
|
|
98
|
+
# documented contract (docs/dsa-notice-form.md), chosen to read naturally in a
|
|
99
|
+
# link and to map cleanly onto the Report's Art. 16 columns:
|
|
100
|
+
# content_url → subject_url ("the exact electronic location", Art. 16(2)(b))
|
|
101
|
+
# content_type → content_type (constrained to Report::CONTENT_TYPES; we only
|
|
102
|
+
# prefill a value the model will accept, so a
|
|
103
|
+
# junk query param can't pre-poison the select)
|
|
104
|
+
# content_author → reported_account_identifier (a host-side handle/username, free text)
|
|
105
|
+
# content_id → reported_account_identifier fallback if no author was given
|
|
106
|
+
# All are OPTIONAL: a bare /notices/new with no query string renders a blank form.
|
|
107
|
+
def content_prefill
|
|
108
|
+
type = params[:content_type].to_s
|
|
109
|
+
{
|
|
110
|
+
subject_url: params[:content_url].presence,
|
|
111
|
+
# Only echo a content_type the model's inclusion validation would accept, so
|
|
112
|
+
# a crafted ?content_type=<script> can never reach the page as a selected value.
|
|
113
|
+
content_type: (type.presence if Moderate::Report::CONTENT_TYPES.include?(type)),
|
|
114
|
+
reported_account_identifier: params[:content_author].presence || params[:content_id].presence
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Identity prefill from the signed-in user, when one exists. We detect Devise (or
|
|
119
|
+
# any auth that exposes `current_user`) WITHOUT a hard dependency: the engine's
|
|
120
|
+
# base controller may or may not define `current_user` depending on the host's
|
|
121
|
+
# `parent_controller`. `respond_to?` keeps the public/anonymous form
|
|
122
|
+
# working when nobody is logged in (the overwhelmingly common case for Art. 16).
|
|
123
|
+
# We read name/email via `try` so the host's user class only needs whichever it
|
|
124
|
+
# actually has. These keys are what the view LOCKS.
|
|
125
|
+
def identity_prefill
|
|
126
|
+
user = current_notifier
|
|
127
|
+
return {} if user.nil?
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
notifier_name: user.try(:display_name) || user.try(:name),
|
|
131
|
+
notifier_email: user.try(:email)
|
|
132
|
+
}.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Whether the IDENTITY fields are locked (readonly) on the form. They are locked
|
|
136
|
+
# exactly when there's a signed-in user whose identity we prefilled — so an
|
|
137
|
+
# anonymous notice keeps name/email editable, and a logged-in notice locks them
|
|
138
|
+
# against tampering. Passed to the view as `@identity_locked` so the view never
|
|
139
|
+
# has to reach for `current_user` itself (it isn't exposed as a view helper here).
|
|
140
|
+
def identity_locked?
|
|
141
|
+
user = current_notifier
|
|
142
|
+
return false if user.nil?
|
|
143
|
+
|
|
144
|
+
(user.try(:email) || user.try(:display_name) || user.try(:name)).present?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The logged-in submitter, if any — read defensively. The form is PUBLIC: most
|
|
148
|
+
# notices come from anonymous notifiers, so `current_notifier` is usually nil and
|
|
149
|
+
# the notice is a name+email-only record (not tied to a User). We only call
|
|
150
|
+
# `current_user` when the parent controller actually defines it (Devise-style),
|
|
151
|
+
# so the engine never hard-depends on an auth gem. `defined?` guards the symbol
|
|
152
|
+
# itself for parents that expose it as a helper_method but not a public method.
|
|
153
|
+
def current_notifier
|
|
154
|
+
return @current_notifier if defined?(@current_notifier)
|
|
155
|
+
|
|
156
|
+
@current_notifier =
|
|
157
|
+
if respond_to?(:current_user, true)
|
|
158
|
+
current_user
|
|
159
|
+
end
|
|
160
|
+
rescue StandardError
|
|
161
|
+
# A host `current_user` that raises (e.g. a not-yet-migrated session) must not
|
|
162
|
+
# break the public notice form — fall back to "anonymous".
|
|
163
|
+
@current_notifier = nil
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- Strong params --------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# Strong params — the Art. 16 field set mapped onto the Report columns. These are
|
|
169
|
+
# the fields the regulation dictates (legal ground, exact URL, substantiated
|
|
170
|
+
# explanation, notifier identity, member state, good-faith attestation, plus the
|
|
171
|
+
# content-type bucket the snapshot needs); there's no product decision to make,
|
|
172
|
+
# so the permit list is fixed.
|
|
173
|
+
#
|
|
174
|
+
# SECURITY NOTE — the LOCKED identity fields. The view renders the prefilled
|
|
175
|
+
# `notifier_name`/`notifier_email` as readonly/disabled for a logged-in notifier;
|
|
176
|
+
# a *disabled* field is NOT submitted by the browser, so on create we re-derive
|
|
177
|
+
# identity from `current_user` and OVERWRITE whatever the params carried. That way
|
|
178
|
+
# a tampered request that re-enables the field and posts a forged name can't land
|
|
179
|
+
# a spoofed identity on a legal notice. For an anonymous notifier (no
|
|
180
|
+
# current_user) the posted name/email are used as-is — they're the only identity
|
|
181
|
+
# there is.
|
|
182
|
+
def notice_params
|
|
183
|
+
permitted = params.require(:notice).permit(
|
|
184
|
+
:legal_reason, # DSA statement-of-reasons taxonomy (Art. 17 ground)
|
|
185
|
+
:legal_country_code, # ISO-3166 EU/EEA selector — jurisdiction/routing
|
|
186
|
+
:content_type, # host-agnostic CONTENT_TYPES bucket for the snapshot
|
|
187
|
+
:subject_url, # "the exact electronic location" — Art. 16(2)(b)
|
|
188
|
+
:subject_urls, # newline-separated exact locations; normalized by Report
|
|
189
|
+
:message, # "sufficiently substantiated explanation" — Art. 16(2)(a)
|
|
190
|
+
:reported_account_identifier, # optional host-side handle the notice is about
|
|
191
|
+
:notifier_name, # Art. 16(2)(c)
|
|
192
|
+
:notifier_email, # Art. 16(2)(c) — where the confirmation + decision go
|
|
193
|
+
:good_faith_confirmed, # Art. 16(2)(d) attestation — must be checked
|
|
194
|
+
:anonymous # the narrow Art. 16(2)(c) minors carve-out
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
enforce_locked_identity(permitted)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Re-assert the locked identity from the signed-in user, ignoring whatever the
|
|
201
|
+
# client posted for name/email. No-op for anonymous notifiers. See the security
|
|
202
|
+
# note on #notice_params.
|
|
203
|
+
def enforce_locked_identity(permitted)
|
|
204
|
+
user = current_notifier
|
|
205
|
+
return permitted if user.nil?
|
|
206
|
+
|
|
207
|
+
name = user.try(:display_name) || user.try(:name)
|
|
208
|
+
email = user.try(:email)
|
|
209
|
+
permitted[:notifier_name] = name if name.present?
|
|
210
|
+
permitted[:notifier_email] = email if email.present?
|
|
211
|
+
permitted
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# 404 the form when the host has disabled it.
|
|
215
|
+
def enforce_notice_enabled!
|
|
216
|
+
return if Moderate.config.notice_form_enabled
|
|
217
|
+
|
|
218
|
+
raise ActionController::RoutingError, "Moderate notice form is disabled (config.notice_form_enabled = false)"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# --- Rate limit (per-IP throttle) ----------------------------------------
|
|
222
|
+
|
|
223
|
+
# A public, unauthenticated POST is a spam/abuse magnet, so we throttle per IP.
|
|
224
|
+
#
|
|
225
|
+
# Rails 7.2 shipped a first-class controller `rate_limit` macro; on 7.1 it
|
|
226
|
+
# doesn't exist, so we fall back to a tiny `Rails.cache`-backed counter. We
|
|
227
|
+
# implement the throttle as a single `before_action` (rather than the class-level
|
|
228
|
+
# `rate_limit` macro) precisely so we can honor the host's RUNTIME
|
|
229
|
+
# `config.notice_rate_limit` (the macro is evaluated at class load, before the
|
|
230
|
+
# host's initializer has necessarily run).
|
|
231
|
+
# https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html
|
|
232
|
+
def throttle_notices!
|
|
233
|
+
limit = Moderate.config.notice_rate_limit
|
|
234
|
+
return if limit == false || limit.nil? # explicitly disabled
|
|
235
|
+
|
|
236
|
+
max = limit.fetch(:max, 5)
|
|
237
|
+
within = limit.fetch(:within, 3600).to_i # seconds; the config stores a raw Integer
|
|
238
|
+
|
|
239
|
+
key = "moderate:notice_rate:#{request.remote_ip}"
|
|
240
|
+
count = rate_limit_increment(key, expires_in: within)
|
|
241
|
+
|
|
242
|
+
render_rate_limited if count > max
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Increment a per-IP counter in the cache, setting the TTL on first write so the
|
|
246
|
+
# window slides correctly. We guard against a missing cache store (NullStore in a
|
|
247
|
+
# bare test env) by treating "can't count" as "not limited" — the form must never
|
|
248
|
+
# break just because rate-limiting can't run.
|
|
249
|
+
def rate_limit_increment(key, expires_in:)
|
|
250
|
+
store = Rails.cache
|
|
251
|
+
return 0 unless store
|
|
252
|
+
|
|
253
|
+
current = store.read(key).to_i
|
|
254
|
+
store.write(key, current + 1, expires_in: expires_in) if current.zero?
|
|
255
|
+
store.increment(key) || (current + 1)
|
|
256
|
+
rescue StandardError
|
|
257
|
+
0
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_rate_limited
|
|
261
|
+
flash.now[:alert] = t(
|
|
262
|
+
"moderate.notices.rate_limited",
|
|
263
|
+
default: "Too many notices from this address. Please try again later."
|
|
264
|
+
)
|
|
265
|
+
@report ||= Moderate::Report.new(prefill_attributes)
|
|
266
|
+
@identity_locked = identity_locked?
|
|
267
|
+
render :new, status: :too_many_requests
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# --- Bot gate (auto-integrates rails_cloudflare_turnstile when present) -----
|
|
271
|
+
|
|
272
|
+
# The single, request-time bot gate. Layered so a host gets the right behavior
|
|
273
|
+
# with zero wiring:
|
|
274
|
+
#
|
|
275
|
+
# 1. If the `rails_cloudflare_turnstile` gem is installed, run ITS server-side
|
|
276
|
+
# check — the gem auto-includes RailsCloudflareTurnstile::ControllerHelpers
|
|
277
|
+
# into every controller (via its railtie's on_load(:action_controller)
|
|
278
|
+
# hook), so `validate_cloudflare_turnstile` is an instance method here. That
|
|
279
|
+
# method is exactly what the gem's README tells a host to put in a
|
|
280
|
+
# before_action; we call it automatically so the host wires NOTHING beyond
|
|
281
|
+
# installing the gem + its keys. A failed challenge raises
|
|
282
|
+
# RailsCloudflareTurnstile::Forbidden, which we catch and turn into a
|
|
283
|
+
# friendly 422 (the submitter retries the check) rather than a raw error.
|
|
284
|
+
# https://github.com/instrumentl/rails-cloudflare-turnstile (README)
|
|
285
|
+
#
|
|
286
|
+
# 2. Otherwise, fall back to the host-configurable `config.notice_guard` proc
|
|
287
|
+
# (no-op by default, so the form just works in dev/test and for apps that
|
|
288
|
+
# gate at the edge). The proc receives THIS controller and returns a boolean
|
|
289
|
+
# (truthy ⇒ allowed). A host on hCaptcha / reCAPTCHA / their own check wires
|
|
290
|
+
# it; a host on Cloudflare Turnstile installs the gem and gets path (1) free.
|
|
291
|
+
#
|
|
292
|
+
# Detection is via `defined?`/`respond_to?` with NO hard dependency in the
|
|
293
|
+
# gemspec, decided at REQUEST time so the wiring auto-adapts to the bundle.
|
|
294
|
+
def verify_human!
|
|
295
|
+
return if human_verification_skipped?
|
|
296
|
+
|
|
297
|
+
if turnstile_available?
|
|
298
|
+
verify_turnstile!
|
|
299
|
+
else
|
|
300
|
+
run_notice_guard!
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def turnstile_widget_required?
|
|
305
|
+
turnstile_available? && !human_verification_skipped?
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def human_verification_skipped?
|
|
309
|
+
predicate = Moderate.config.notice_human_verification_skip_if
|
|
310
|
+
return false unless predicate.respond_to?(:call)
|
|
311
|
+
|
|
312
|
+
predicate.call(self) ? true : false
|
|
313
|
+
rescue StandardError
|
|
314
|
+
false
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# True when the gem is loaded AND its controller helper is mixed in here, so a
|
|
318
|
+
# half-loaded/renamed gem can never put us on a path whose method doesn't exist.
|
|
319
|
+
def turnstile_available?
|
|
320
|
+
defined?(::RailsCloudflareTurnstile) && respond_to?(:validate_cloudflare_turnstile, true)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Run the gem's verifier, translating its `Forbidden` into our friendly 422. We
|
|
324
|
+
# reference the exception class by `defined?`-guarded constant so this file never
|
|
325
|
+
# hard-names a constant that may not exist (the gem is optional).
|
|
326
|
+
def verify_turnstile!
|
|
327
|
+
validate_cloudflare_turnstile
|
|
328
|
+
rescue ::RailsCloudflareTurnstile::Forbidden
|
|
329
|
+
render_captcha_failed
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The gem-absent fallback gate (see #verify_human! point 2). We read the guard
|
|
333
|
+
# via `respond_to?` so the form still works on a Configuration that predates the
|
|
334
|
+
# `notice_guard` accessor (treated as "no guard" ⇒ the form just works).
|
|
335
|
+
def run_notice_guard!
|
|
336
|
+
return unless Moderate.config.respond_to?(:notice_guard)
|
|
337
|
+
|
|
338
|
+
guard = Moderate.config.notice_guard
|
|
339
|
+
return unless guard.respond_to?(:call)
|
|
340
|
+
return if truthy?(safe_call_guard(guard))
|
|
341
|
+
|
|
342
|
+
render_captcha_failed
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# Never let a flaky/broken guard raise into the request — a bot service must not
|
|
346
|
+
# 500 a legal notice form. An exception is treated as "failed closed" (we can't
|
|
347
|
+
# prove the human), which re-renders the form so the submitter can retry.
|
|
348
|
+
def safe_call_guard(guard)
|
|
349
|
+
guard.call(self)
|
|
350
|
+
rescue StandardError
|
|
351
|
+
false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Shared failure renderer for BOTH gate paths (a failed Turnstile challenge and a
|
|
355
|
+
# falsy/raising guard). Re-renders `new` with a friendly 422 so the submitter can
|
|
356
|
+
# try the check again. We rebuild a prefilled (and re-locked) report so the form
|
|
357
|
+
# comes back populated rather than blank.
|
|
358
|
+
def render_captcha_failed
|
|
359
|
+
flash.now[:alert] = t(
|
|
360
|
+
"moderate.notices.captcha_failed",
|
|
361
|
+
default: "We couldn't verify you're human. Please try the check again."
|
|
362
|
+
)
|
|
363
|
+
@report ||= Moderate::Report.new(prefill_attributes.merge(notice_params_safe))
|
|
364
|
+
@identity_locked = identity_locked?
|
|
365
|
+
render :new, status: :unprocessable_entity
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Like `notice_params` but as a plain SYMBOL-keyed Hash and tolerant of a missing
|
|
369
|
+
# `notice` key, so re-rendering after a failed gate (where params may be partial)
|
|
370
|
+
# never raises — and so it merges cleanly over the symbol-keyed `prefill_attributes`
|
|
371
|
+
# (a string-keyed Parameters would create duplicate logical keys and not override).
|
|
372
|
+
def notice_params_safe
|
|
373
|
+
notice_params.to_h.symbolize_keys
|
|
374
|
+
rescue ActionController::ParameterMissing
|
|
375
|
+
{}
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def truthy?(value)
|
|
379
|
+
value ? true : false
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# Public aggregate transparency report for moderation intake, decisions, appeals,
|
|
5
|
+
# and automated flags.
|
|
6
|
+
class TransparencyReportsController < Moderate::ApplicationController
|
|
7
|
+
before_action :ensure_transparency_report_enabled!
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
@period_start = 1.year.ago.beginning_of_day
|
|
11
|
+
@period_end = Time.current
|
|
12
|
+
# The aggregation is a public facade method so a host that keeps this page off
|
|
13
|
+
# (it's opt-in) can still call `Moderate.transparency(from:, to:)` to publish
|
|
14
|
+
# its own report. The view renders the same hash either way.
|
|
15
|
+
@summary = Moderate.transparency(from: @period_start, to: @period_end)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
# Hard kill-switch: the public transparency report is OFF unless the host opts
|
|
21
|
+
# in with `config.transparency_report_enabled = true`. Raising RoutingError makes
|
|
22
|
+
# the mounted route behave as if it doesn't exist (a 404), same pattern as the
|
|
23
|
+
# notice/appeal form kill-switches.
|
|
24
|
+
def ensure_transparency_report_enabled!
|
|
25
|
+
return if Moderate.config.transparency_report_enabled
|
|
26
|
+
|
|
27
|
+
raise ActionController::RoutingError, "Moderate transparency report is disabled (config.transparency_report_enabled = false)"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# View helpers exposed to BOTH the engine's own views and the HOST app's views.
|
|
5
|
+
#
|
|
6
|
+
# The headline helper is `moderate_report_link` (and its terser alias
|
|
7
|
+
# `report_link`): a one-liner you drop next to any reportable piece of content to
|
|
8
|
+
# render an in-app "Report" affordance — the exact thing Apple Guideline 1.2 and
|
|
9
|
+
# Google Play's UGC policy require every social/UGC app to surface next to
|
|
10
|
+
# user-generated content.
|
|
11
|
+
# - https://developer.apple.com/app-store/review/guidelines/#user-generated-content
|
|
12
|
+
# - https://support.google.com/googleplay/android-developer/answer/9876937
|
|
13
|
+
#
|
|
14
|
+
# GOTCHA — isolated engine + host views: `Moderate::Engine` is an *isolated*
|
|
15
|
+
# engine (`isolate_namespace Moderate`). Rails does NOT auto-include an isolated
|
|
16
|
+
# engine's helpers into the host application's views — that's the whole point of
|
|
17
|
+
# isolation. But `moderate_report_link` is meant to be called from the host's own
|
|
18
|
+
# templates (e.g. `<%= moderate_report_link(@comment, field: :body) %>`), so we
|
|
19
|
+
# explicitly mix this module into ActionView for the host at load time via the
|
|
20
|
+
# `ActiveSupport.on_load(:action_view)` hook at the bottom of this file. That is
|
|
21
|
+
# the same trick Devise uses to expose its url helpers app-wide.
|
|
22
|
+
module EngineHelper
|
|
23
|
+
# Render an in-app report affordance for `record`'s `field`, or NOTHING when the
|
|
24
|
+
# current viewer isn't allowed to report it.
|
|
25
|
+
#
|
|
26
|
+
# Renders nothing (returns nil) — deliberately, and in three cases:
|
|
27
|
+
# 1. there is no signed-in viewer (anonymous users use the public DSA notice
|
|
28
|
+
# form instead; this is the *in-app* button), or
|
|
29
|
+
# 2. the record doesn't know how to be reported (no `report_visible_to?`), or
|
|
30
|
+
# 3. the record says this viewer may not report this field
|
|
31
|
+
# (`record.report_visible_to?(viewer, field:)` is false — e.g. you can't
|
|
32
|
+
# report your own content, or content you can't even see).
|
|
33
|
+
#
|
|
34
|
+
# This "render nothing unless permitted" contract is what lets a host sprinkle
|
|
35
|
+
# the helper liberally across a template without guarding each call site — the
|
|
36
|
+
# helper is the guard. It mirrors the reference app's `report_link`.
|
|
37
|
+
#
|
|
38
|
+
# @param record [Object] any model that `include`s Moderate::Reportable
|
|
39
|
+
# @param field [Symbol, String, nil] which field is being reported (nil =>
|
|
40
|
+
# the whole record). Passed straight through to the visibility check and the
|
|
41
|
+
# intake form so the moderator sees exactly which field was flagged.
|
|
42
|
+
# @param label [String, nil] the visible link text. Defaults to a translated
|
|
43
|
+
# "Report" string so the host gets i18n for free.
|
|
44
|
+
# @param html_options [Hash] extra HTML attributes merged onto the <a> (class,
|
|
45
|
+
# data-*, aria-*, …) so the host can style it to taste without a wrapper.
|
|
46
|
+
# @return [ActiveSupport::SafeBuffer, nil]
|
|
47
|
+
def moderate_report_link(record, field: nil, label: nil, **html_options)
|
|
48
|
+
viewer = moderate_current_viewer
|
|
49
|
+
return if viewer.nil?
|
|
50
|
+
|
|
51
|
+
# The record gates its own reportability. We `respond_to?`-guard so a host can
|
|
52
|
+
# pass any object without the helper exploding — a non-reportable object simply
|
|
53
|
+
# renders nothing, same as "not permitted".
|
|
54
|
+
return unless record.respond_to?(:report_visible_to?)
|
|
55
|
+
return unless record.report_visible_to?(viewer, field: field)
|
|
56
|
+
|
|
57
|
+
label ||= moderate_report_default_label
|
|
58
|
+
link_to(label, moderate_report_path_for(record, field: field), **html_options)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Terse alias. The README/docs use `moderate_report_link` in host views (to
|
|
62
|
+
# avoid clashing with a host's own `report_link`), but `report_link` reads
|
|
63
|
+
# cleanest inside the engine's own templates. Same method, two names.
|
|
64
|
+
def report_link(record, field: nil, label: nil, **html_options)
|
|
65
|
+
moderate_report_link(record, field: field, label: label, **html_options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# The path to the in-app intake form for this record/field.
|
|
71
|
+
#
|
|
72
|
+
# The target is passed as a SIGNED Global ID, never a raw `type`+`id`. This is
|
|
73
|
+
# the load-bearing security decision of the report flow: a raw polymorphic
|
|
74
|
+
# `reportable_type`/`reportable_id` pair in a URL is attacker-controlled — anyone
|
|
75
|
+
# could file a report against an arbitrary record (or probe which ids exist).
|
|
76
|
+
# A signed GID is tamper-proof and scoped to a single purpose, so the intake
|
|
77
|
+
# controller can `locate_signed_reportable` it back to the exact record the host
|
|
78
|
+
# chose to expose here, and nothing else.
|
|
79
|
+
# See: https://api.rubyonrails.org/classes/GlobalID/Identification.html#method-i-to_sgid
|
|
80
|
+
#
|
|
81
|
+
# NOTE: the in-app report controller/route is the host's (BYOUI) — `moderate`
|
|
82
|
+
# ships the primitives, not the in-app report UI. So we build the path from the
|
|
83
|
+
# host's named route (`new_report_path`/`new_moderate_report_path`) when present
|
|
84
|
+
# and fall back to a conventional `/reports/new?target=…` otherwise, rather than
|
|
85
|
+
# hard-coding the engine's routes (which only cover the *public* DSA form).
|
|
86
|
+
def moderate_report_path_for(record, field:)
|
|
87
|
+
target = moderate_signed_target(record)
|
|
88
|
+
query = { target: target, field: field.presence }.compact
|
|
89
|
+
|
|
90
|
+
if respond_to?(:new_report_path)
|
|
91
|
+
new_report_path(query)
|
|
92
|
+
elsif respond_to?(:new_moderate_report_path)
|
|
93
|
+
new_moderate_report_path(query)
|
|
94
|
+
else
|
|
95
|
+
# Last-resort conventional path so the helper is never a hard dependency on
|
|
96
|
+
# a particular route name. The host wires the actual route (BYOUI).
|
|
97
|
+
"/reports/new?#{query.to_query}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The record as a signed, purpose-scoped GID parameter. We delegate to the
|
|
102
|
+
# Reportable concern's own signer when available (so the purpose/expiry stay in
|
|
103
|
+
# ONE place — the model), and fall back to `to_sgid_param` with the canonical
|
|
104
|
+
# purpose otherwise.
|
|
105
|
+
def moderate_signed_target(record)
|
|
106
|
+
return record.to_moderation_sgid if record.respond_to?(:to_moderation_sgid)
|
|
107
|
+
|
|
108
|
+
# GlobalID's signed param, scoped to a stable purpose string so a token minted
|
|
109
|
+
# for reporting can't be replayed against an unrelated signed-GID feature.
|
|
110
|
+
#
|
|
111
|
+
# `expires_in: nil` mints a NON-EXPIRING token on purpose. By default
|
|
112
|
+
# `to_sgid_param` bakes in an `exp` timestamp (SignedGlobalID.expires_in, ~1
|
|
113
|
+
# month), which makes the token — and therefore the rendered link's URL —
|
|
114
|
+
# CHANGE on every render. That breaks HTTP/fragment caching of pages that show
|
|
115
|
+
# the report link, and makes the link non-deterministic. The token is already
|
|
116
|
+
# purpose-scoped ("moderate_report") and the locator restricts resolution to the
|
|
117
|
+
# allow-listed reportable classes, so a stable, non-expiring identifier is the
|
|
118
|
+
# right trade-off here: it identifies WHICH record to report, it doesn't grant
|
|
119
|
+
# any capability that needs to time out.
|
|
120
|
+
record.to_sgid_param(for: "moderate_report", expires_in: nil)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Who is viewing — resolved without coupling `moderate` to any auth gem. We try
|
|
124
|
+
# `current_user` (Devise/most apps) first; a host with a different actor accessor
|
|
125
|
+
# can override `moderate_current_viewer` in their own helper. nil => anonymous,
|
|
126
|
+
# which correctly hides the in-app button.
|
|
127
|
+
def moderate_current_viewer
|
|
128
|
+
return current_user if respond_to?(:current_user)
|
|
129
|
+
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Default link text, pulled through I18n so it localizes with the host's locale
|
|
134
|
+
# and can be overridden without touching call sites. Falls back to plain English
|
|
135
|
+
# if the host hasn't loaded the gem's locale file.
|
|
136
|
+
def moderate_report_default_label
|
|
137
|
+
if defined?(I18n)
|
|
138
|
+
I18n.t("moderate.report_link.label", default: "Report")
|
|
139
|
+
else
|
|
140
|
+
"Report"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Expose the helper to the HOST app's views (see the "isolated engine" gotcha in
|
|
147
|
+
# the module doc above). `on_load(:action_view)` defers until ActionView is loaded,
|
|
148
|
+
# so we never force it at boot and we play nice with the host's load order.
|
|
149
|
+
ActiveSupport.on_load(:action_view) do
|
|
150
|
+
include Moderate::EngineHelper
|
|
151
|
+
end if defined?(ActiveSupport)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.moderate-page { color: var(--moderate-text, #111827); font-family: var(--moderate-font-family, system-ui, sans-serif); }
|
|
3
|
+
.moderate-hero { background: var(--moderate-accent, #facc15); padding: 3rem 1.25rem; }
|
|
4
|
+
.moderate-container { max-width: 42rem; margin: 0 auto; }
|
|
5
|
+
.moderate-eyebrow { margin: 0; font-size: .75rem; font-weight: 800; text-transform: uppercase; letter-spacing: .12em; color: var(--moderate-muted-text, #374151); }
|
|
6
|
+
.moderate-title { margin: .5rem 0 0; font-size: clamp(2rem, 6vw, 2.5rem); line-height: 1.1; font-weight: 900; }
|
|
7
|
+
.moderate-main { padding: 2.5rem 1.25rem; }
|
|
8
|
+
.moderate-panel { margin-bottom: 1.5rem; border: 1px solid rgba(17, 24, 39, .08); border-radius: .5rem; background: var(--moderate-panel, #f9fafb); padding: 1rem; color: var(--moderate-muted-text, #374151); }
|
|
9
|
+
.moderate-form { display: grid; gap: 1.25rem; }
|
|
10
|
+
.moderate-grid { display: grid; gap: 1rem; }
|
|
11
|
+
@media (min-width: 640px) { .moderate-grid { grid-template-columns: 1fr 1fr; } }
|
|
12
|
+
.moderate-label { display: block; font-size: .875rem; font-weight: 700; }
|
|
13
|
+
.moderate-input { box-sizing: border-box; margin-top: .5rem; width: 100%; border: 1px solid rgba(17, 24, 39, .14); border-radius: .5rem; background: white; padding: .75rem 1rem; font: inherit; }
|
|
14
|
+
.moderate-error { border: 1px solid #fecaca; border-radius: .5rem; background: #fef2f2; padding: .75rem 1rem; color: #991b1b; font-size: .875rem; font-weight: 700; }
|
|
15
|
+
.moderate-button { width: 100%; border: 0; border-radius: 999px; background: var(--moderate-button, #111827); color: white; padding: .85rem 1rem; font: inherit; font-weight: 900; cursor: pointer; }
|
|
16
|
+
.moderate-turnstile { display: flex; justify-content: center; }
|
|
17
|
+
</style>
|
|
18
|
+
|
|
19
|
+
<div class="moderate-page">
|
|
20
|
+
<section class="moderate-hero">
|
|
21
|
+
<div class="moderate-container">
|
|
22
|
+
<p class="moderate-eyebrow"><%= t("moderate.appeals.eyebrow", default: "Moderation") %></p>
|
|
23
|
+
<h1 class="moderate-title"><%= t("moderate.appeals.title", default: "Appeal a decision") %></h1>
|
|
24
|
+
</div>
|
|
25
|
+
</section>
|
|
26
|
+
|
|
27
|
+
<main class="moderate-main">
|
|
28
|
+
<div class="moderate-container">
|
|
29
|
+
<div class="moderate-panel">
|
|
30
|
+
<p>
|
|
31
|
+
<strong><%= t("moderate.appeals.decision", default: "Decision") %></strong>
|
|
32
|
+
<span><%= @report.id %></span>
|
|
33
|
+
</p>
|
|
34
|
+
<% if @report.appeal_deadline_at.present? %>
|
|
35
|
+
<p><%= t("moderate.appeals.deadline", default: "You can request a free internal review until %{deadline}. A person will review it.", deadline: l(@report.appeal_deadline_at, format: :long)) %></p>
|
|
36
|
+
<% end %>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<%= form_with model: @appeal, url: appeals_path(token: params[:token]), scope: :appeal, class: "moderate-form" do |form| %>
|
|
40
|
+
<%= form.hidden_field :source, value: @appeal.source || "notifier" %>
|
|
41
|
+
|
|
42
|
+
<div class="moderate-grid">
|
|
43
|
+
<div>
|
|
44
|
+
<%= form.label :appellant_name, t("moderate.appeals.fields.name", default: "Name"), class: "moderate-label" %>
|
|
45
|
+
<%= form.text_field :appellant_name, class: "moderate-input" %>
|
|
46
|
+
</div>
|
|
47
|
+
<div>
|
|
48
|
+
<%= form.label :appellant_email, t("moderate.appeals.fields.email", default: "Email"), class: "moderate-label" %>
|
|
49
|
+
<%= form.email_field :appellant_email, required: true, class: "moderate-input" %>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div>
|
|
54
|
+
<%= form.label :reason, t("moderate.appeals.fields.reason", default: "Reason for appeal"), class: "moderate-label" %>
|
|
55
|
+
<%= form.text_area :reason,
|
|
56
|
+
rows: 8,
|
|
57
|
+
maxlength: Moderate::Report::MESSAGE_MAX_LENGTH,
|
|
58
|
+
required: true,
|
|
59
|
+
class: "moderate-input",
|
|
60
|
+
placeholder: t("moderate.appeals.placeholders.reason", default: "Explain why you believe the decision should be reviewed and include any useful context.") %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<% if turnstile_widget_required? && respond_to?(:cloudflare_turnstile) %>
|
|
64
|
+
<div class="moderate-turnstile">
|
|
65
|
+
<%= cloudflare_turnstile_script_tag if respond_to?(:cloudflare_turnstile_script_tag) %>
|
|
66
|
+
<%= cloudflare_turnstile %>
|
|
67
|
+
</div>
|
|
68
|
+
<% end %>
|
|
69
|
+
|
|
70
|
+
<% if @appeal.errors.any? %>
|
|
71
|
+
<div class="moderate-error"><%= @appeal.errors.full_messages.to_sentence %></div>
|
|
72
|
+
<% end %>
|
|
73
|
+
|
|
74
|
+
<%= form.submit t("moderate.appeals.submit", default: "Submit appeal"), class: "moderate-button" %>
|
|
75
|
+
<% end %>
|
|
76
|
+
</div>
|
|
77
|
+
</main>
|
|
78
|
+
</div>
|