moderate 0.1.0 → 1.0.0.beta2

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 (67) 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 +109 -1
  7. data/CLAUDE.md +7 -0
  8. data/README.md +378 -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/context7.json +4 -0
  24. data/docs/compliance.md +178 -0
  25. data/docs/configuration.md +326 -0
  26. data/docs/dsa-notice-form.md +371 -0
  27. data/docs/images/moderate-user-report-block-actions.webp +0 -0
  28. data/docs/madmin.md +490 -0
  29. data/docs/notifications.md +363 -0
  30. data/examples/aws_rekognition_adapter.rb +140 -0
  31. data/examples/openai_moderation_adapter.rb +111 -0
  32. data/gemfiles/rails_7.1.gemfile +36 -0
  33. data/gemfiles/rails_7.2.gemfile +36 -0
  34. data/gemfiles/rails_8.1.gemfile +36 -0
  35. data/lib/generators/moderate/install_generator.rb +56 -0
  36. data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
  37. data/lib/generators/moderate/templates/initializer.rb +198 -0
  38. data/lib/generators/moderate/views_generator.rb +63 -0
  39. data/lib/moderate/configuration.rb +355 -0
  40. data/lib/moderate/engine.rb +138 -0
  41. data/lib/moderate/errors.rb +26 -0
  42. data/lib/moderate/event.rb +75 -0
  43. data/lib/moderate/filters/base.rb +126 -0
  44. data/lib/moderate/filters/wordlist.rb +255 -0
  45. data/lib/moderate/jobs/classify_job.rb +162 -0
  46. data/lib/moderate/label.rb +111 -0
  47. data/lib/moderate/macros.rb +90 -0
  48. data/lib/moderate/models/appeal.rb +154 -0
  49. data/lib/moderate/models/application_record.rb +31 -0
  50. data/lib/moderate/models/block.rb +203 -0
  51. data/lib/moderate/models/concerns/actor.rb +174 -0
  52. data/lib/moderate/models/concerns/content_filterable.rb +214 -0
  53. data/lib/moderate/models/concerns/reportable.rb +282 -0
  54. data/lib/moderate/models/flag.rb +169 -0
  55. data/lib/moderate/models/report.rb +620 -0
  56. data/lib/moderate/result.rb +176 -0
  57. data/lib/moderate/services/intake_appeal.rb +89 -0
  58. data/lib/moderate/services/intake_notice.rb +132 -0
  59. data/lib/moderate/services/intake_report.rb +132 -0
  60. data/lib/moderate/services/resolve_appeal.rb +134 -0
  61. data/lib/moderate/services/resolve_flag.rb +101 -0
  62. data/lib/moderate/services/resolve_report.rb +291 -0
  63. data/lib/moderate/version.rb +1 -1
  64. data/lib/moderate.rb +365 -18
  65. data/log/development.log +0 -0
  66. data/log/test.log +0 -0
  67. metadata +156 -15
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # Public DSA Art. 20 internal complaint form for moderation decisions.
5
+ class AppealsController < Moderate::ApplicationController
6
+ helper_method :turnstile_widget_required?
7
+
8
+ before_action :enforce_appeal_enabled!
9
+ before_action :throttle_appeals!, only: :create
10
+ before_action :verify_human!, only: :create
11
+
12
+ def new
13
+ @report = Moderate::Report.locate_signed_appeal_report(params[:token])
14
+ return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
15
+
16
+ @appeal = Moderate::Appeal.new(
17
+ report: @report,
18
+ appellant: current_appellant,
19
+ appellant_name: current_appellant_name,
20
+ appellant_email: current_appellant_email,
21
+ source: appeal_source_for(@report, params[:source])
22
+ )
23
+ end
24
+
25
+ def create
26
+ @report = Moderate::Report.locate_signed_appeal_report(params[:token])
27
+ return redirect_to appeal_return_path, alert: t("moderate.appeals.not_found", default: "We couldn't find that moderation decision.") if @report.blank?
28
+
29
+ attributes = appeal_params
30
+ attributes[:source] = appeal_source_for(@report, attributes[:source])
31
+
32
+ intake = Moderate::Services::IntakeAppeal.new(
33
+ appeal: Moderate::Appeal.new(attributes),
34
+ report: @report,
35
+ appellant: current_appellant
36
+ )
37
+ @appeal = intake.appeal
38
+
39
+ if intake.save
40
+ redirect_to appeal_return_path,
41
+ notice: t("moderate.appeals.received", default: "Appeal received. A human reviewer will assess the decision."),
42
+ status: :see_other
43
+ else
44
+ render :new, status: :unprocessable_entity
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def appeal_params
51
+ params.require(:appeal).permit(:appellant_name, :appellant_email, :source, :reason)
52
+ end
53
+
54
+ def appeal_source_for(report, requested_source)
55
+ return "affected_user" if current_appellant.present? && current_appellant.id == report.reported_user_id
56
+
57
+ source = requested_source.to_s.squish
58
+ return "notifier" if source == "affected_user" && report.reported_user_id.blank?
59
+
60
+ Moderate::Appeal::SOURCES.include?(source) ? source : "notifier"
61
+ end
62
+
63
+ def current_appellant
64
+ return @current_appellant if defined?(@current_appellant)
65
+
66
+ @current_appellant =
67
+ if respond_to?(:current_user, true)
68
+ current_user
69
+ end
70
+ rescue StandardError
71
+ @current_appellant = nil
72
+ end
73
+
74
+ def current_appellant_name
75
+ current_appellant&.try(:display_name) || current_appellant&.try(:name)
76
+ end
77
+
78
+ def current_appellant_email
79
+ current_appellant&.try(:email)
80
+ end
81
+
82
+ def appeal_return_path
83
+ path = Moderate.config.appeal_return_path
84
+ path = path.call(self) if path.respond_to?(:call)
85
+ path.presence || "/"
86
+ end
87
+
88
+ def enforce_appeal_enabled!
89
+ return if Moderate.config.appeal_form_enabled
90
+
91
+ raise ActionController::RoutingError, "Moderate appeal form is disabled (config.appeal_form_enabled = false)"
92
+ end
93
+
94
+ def throttle_appeals!
95
+ limit = Moderate.config.appeal_rate_limit
96
+ return if limit == false || limit.nil?
97
+
98
+ max = limit.fetch(:max, 10)
99
+ within = limit.fetch(:within, 60).to_i
100
+
101
+ key = "moderate:appeal_rate:#{request.remote_ip}"
102
+ count = rate_limit_increment(key, expires_in: within)
103
+
104
+ render_rate_limited if count > max
105
+ end
106
+
107
+ def rate_limit_increment(key, expires_in:)
108
+ store = Rails.cache
109
+ return 0 unless store
110
+
111
+ current = store.read(key).to_i
112
+ store.write(key, current + 1, expires_in: expires_in) if current.zero?
113
+ store.increment(key) || (current + 1)
114
+ rescue StandardError
115
+ 0
116
+ end
117
+
118
+ def render_rate_limited
119
+ flash.now[:alert] = t("moderate.appeals.rate_limited", default: "Too many appeals from this address. Please try again later.")
120
+ rebuild_appeal_from_params
121
+ render :new, status: :too_many_requests
122
+ end
123
+
124
+ def verify_human!
125
+ return if human_verification_skipped?
126
+
127
+ if turnstile_available?
128
+ verify_turnstile!
129
+ else
130
+ run_appeal_guard!
131
+ end
132
+ end
133
+
134
+ def turnstile_widget_required?
135
+ turnstile_available? && !human_verification_skipped?
136
+ end
137
+
138
+ def human_verification_skipped?
139
+ predicate = Moderate.config.appeal_human_verification_skip_if
140
+ return false unless predicate.respond_to?(:call)
141
+
142
+ predicate.call(self) ? true : false
143
+ rescue StandardError
144
+ false
145
+ end
146
+
147
+ def turnstile_available?
148
+ defined?(::RailsCloudflareTurnstile) && respond_to?(:validate_cloudflare_turnstile, true)
149
+ end
150
+
151
+ def verify_turnstile!
152
+ validate_cloudflare_turnstile
153
+ rescue ::RailsCloudflareTurnstile::Forbidden
154
+ render_captcha_failed
155
+ end
156
+
157
+ def run_appeal_guard!
158
+ guard = Moderate.config.appeal_guard
159
+ return unless guard.respond_to?(:call)
160
+ return if safe_call_guard(guard)
161
+
162
+ render_captcha_failed
163
+ end
164
+
165
+ def safe_call_guard(guard)
166
+ guard.call(self) ? true : false
167
+ rescue StandardError
168
+ false
169
+ end
170
+
171
+ def render_captcha_failed
172
+ flash.now[:alert] = t("moderate.appeals.captcha_failed", default: "We couldn't verify you're human. Please try the check again.")
173
+ rebuild_appeal_from_params
174
+ render :new, status: :unprocessable_entity
175
+ end
176
+
177
+ def rebuild_appeal_from_params
178
+ @report ||= Moderate::Report.locate_signed_appeal_report(params[:token])
179
+ @appeal = Moderate::Appeal.new(appeal_params_safe)
180
+ @appeal.report = @report if @report
181
+ @appeal.source = appeal_source_for(@report, @appeal.source) if @report
182
+ end
183
+
184
+ def appeal_params_safe
185
+ appeal_params.to_h
186
+ rescue ActionController::ParameterMissing
187
+ {}
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Moderate
4
+ # Base controller for the engine's OWN controllers (today: the public DSA notice
5
+ # form). It is NOT used by the host's in-app report/admin controllers — those are
6
+ # BYOUI and inherit from the host's own ApplicationController.
7
+ #
8
+ # The parent class is INDIRECTED through `config.parent_controller`
9
+ # (default `"::ActionController::Base"`), exactly the `config.parent_controller`
10
+ # trick Devise and `api_keys` use. Why indirect instead of just inheriting from
11
+ # `ActionController::Base`?
12
+ # - On an API-only app there is no `ActionController::Base` view stack by
13
+ # default; defaulting to it (and pulling in the view modules below) keeps the
14
+ # HTML notice form working even there.
15
+ # - A host that wants the public forms to sit inside its own site chrome points
16
+ # `config.parent_controller` at its own base controller and inherits
17
+ # its layout, locale-setting, current_user, etc. — without us hard-coding any
18
+ # of that.
19
+ #
20
+ # We resolve the parent at class-definition time via `Class.new(...)` + a
21
+ # `const_set`-free `superclass` trick: Ruby can't change a class's superclass
22
+ # after definition, so we constantize the configured name HERE, on first load of
23
+ # this file. The configured value is a STRING constantized lazily, consistent with
24
+ # the rest of the gem's "store class names as strings" rule.
25
+ parent = begin
26
+ Moderate.config.parent_controller.to_s.constantize
27
+ rescue NameError
28
+ # Defensive fallback: if the configured parent isn't loadable (typo, or an
29
+ # API-only app without ActionController::Base required yet), fall back to the
30
+ # stock base so the engine still boots. ActionController::Base is part of Rails.
31
+ require "action_controller"
32
+ ::ActionController::Base
33
+ end
34
+
35
+ class ApplicationController < parent
36
+ # CSRF protection — but only when the parent actually supports it. On
37
+ # `ActionController::API` (or a host base that doesn't include the module),
38
+ # `protect_from_forgery` isn't defined, so we guard the call. The public notice
39
+ # form is a state-changing POST, so we want forgery protection whenever it's
40
+ # available. `with: :exception` is the modern Rails default.
41
+ if respond_to?(:protect_from_forgery)
42
+ protect_from_forgery with: :exception
43
+ end
44
+ end
45
+ end
@@ -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