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.
- 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 +109 -1
- data/CLAUDE.md +7 -0
- data/README.md +378 -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/context7.json +4 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/images/moderate-user-report-block-actions.webp +0 -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 +355 -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 +162 -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 +214 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +169 -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 +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
|