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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -0
  3. data/.simplecov +62 -0
  4. data/AGENTS.md +7 -0
  5. data/Appraisals +16 -0
  6. data/CHANGELOG.md +71 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +376 -29
  9. data/Rakefile +28 -2
  10. data/app/controllers/concerns/moderate/moderation.rb +161 -0
  11. data/app/controllers/moderate/appeals_controller.rb +190 -0
  12. data/app/controllers/moderate/application_controller.rb +45 -0
  13. data/app/controllers/moderate/notices_controller.rb +382 -0
  14. data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
  15. data/app/helpers/moderate/engine_helper.rb +151 -0
  16. data/app/views/moderate/appeals/new.html.erb +78 -0
  17. data/app/views/moderate/notices/new.html.erb +255 -0
  18. data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
  19. data/app/views/moderate/transparency_reports/show.html.erb +52 -0
  20. data/config/moderate/blocklists/en.yml +81 -0
  21. data/config/moderate/blocklists/es.yml +40 -0
  22. data/config/routes.rb +36 -0
  23. data/docs/compliance.md +178 -0
  24. data/docs/configuration.md +326 -0
  25. data/docs/dsa-notice-form.md +371 -0
  26. data/docs/madmin.md +490 -0
  27. data/docs/notifications.md +363 -0
  28. data/examples/aws_rekognition_adapter.rb +140 -0
  29. data/examples/openai_moderation_adapter.rb +111 -0
  30. data/gemfiles/rails_7.1.gemfile +36 -0
  31. data/gemfiles/rails_7.2.gemfile +36 -0
  32. data/gemfiles/rails_8.1.gemfile +36 -0
  33. data/lib/generators/moderate/install_generator.rb +56 -0
  34. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  35. data/lib/generators/moderate/templates/initializer.rb +198 -0
  36. data/lib/generators/moderate/views_generator.rb +63 -0
  37. data/lib/moderate/configuration.rb +341 -0
  38. data/lib/moderate/engine.rb +138 -0
  39. data/lib/moderate/errors.rb +26 -0
  40. data/lib/moderate/event.rb +75 -0
  41. data/lib/moderate/filters/base.rb +126 -0
  42. data/lib/moderate/filters/wordlist.rb +255 -0
  43. data/lib/moderate/jobs/classify_job.rb +158 -0
  44. data/lib/moderate/label.rb +111 -0
  45. data/lib/moderate/macros.rb +90 -0
  46. data/lib/moderate/models/appeal.rb +154 -0
  47. data/lib/moderate/models/application_record.rb +31 -0
  48. data/lib/moderate/models/block.rb +203 -0
  49. data/lib/moderate/models/concerns/actor.rb +174 -0
  50. data/lib/moderate/models/concerns/content_filterable.rb +155 -0
  51. data/lib/moderate/models/concerns/reportable.rb +282 -0
  52. data/lib/moderate/models/flag.rb +136 -0
  53. data/lib/moderate/models/report.rb +620 -0
  54. data/lib/moderate/result.rb +176 -0
  55. data/lib/moderate/services/intake_appeal.rb +89 -0
  56. data/lib/moderate/services/intake_notice.rb +132 -0
  57. data/lib/moderate/services/intake_report.rb +132 -0
  58. data/lib/moderate/services/resolve_appeal.rb +134 -0
  59. data/lib/moderate/services/resolve_flag.rb +101 -0
  60. data/lib/moderate/services/resolve_report.rb +291 -0
  61. data/lib/moderate/version.rb +1 -1
  62. data/lib/moderate.rb +365 -18
  63. data/log/development.log +0 -0
  64. data/log/test.log +0 -0
  65. 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>