moderate 0.1.0 → 1.0.0.beta1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -0
- data/.simplecov +62 -0
- data/AGENTS.md +7 -0
- data/Appraisals +16 -0
- data/CHANGELOG.md +71 -1
- data/CLAUDE.md +7 -0
- data/README.md +376 -29
- data/Rakefile +28 -2
- data/app/controllers/concerns/moderate/moderation.rb +161 -0
- data/app/controllers/moderate/appeals_controller.rb +190 -0
- data/app/controllers/moderate/application_controller.rb +45 -0
- data/app/controllers/moderate/notices_controller.rb +382 -0
- data/app/controllers/moderate/transparency_reports_controller.rb +30 -0
- data/app/helpers/moderate/engine_helper.rb +151 -0
- data/app/views/moderate/appeals/new.html.erb +78 -0
- data/app/views/moderate/notices/new.html.erb +255 -0
- data/app/views/moderate/transparency_reports/_summary_card.html.erb +20 -0
- data/app/views/moderate/transparency_reports/show.html.erb +52 -0
- data/config/moderate/blocklists/en.yml +81 -0
- data/config/moderate/blocklists/es.yml +40 -0
- data/config/routes.rb +36 -0
- data/docs/compliance.md +178 -0
- data/docs/configuration.md +326 -0
- data/docs/dsa-notice-form.md +371 -0
- data/docs/madmin.md +490 -0
- data/docs/notifications.md +363 -0
- data/examples/aws_rekognition_adapter.rb +140 -0
- data/examples/openai_moderation_adapter.rb +111 -0
- data/gemfiles/rails_7.1.gemfile +36 -0
- data/gemfiles/rails_7.2.gemfile +36 -0
- data/gemfiles/rails_8.1.gemfile +36 -0
- data/lib/generators/moderate/install_generator.rb +56 -0
- data/lib/generators/moderate/templates/create_moderate_tables.rb.erb +237 -0
- data/lib/generators/moderate/templates/initializer.rb +198 -0
- data/lib/generators/moderate/views_generator.rb +63 -0
- data/lib/moderate/configuration.rb +341 -0
- data/lib/moderate/engine.rb +138 -0
- data/lib/moderate/errors.rb +26 -0
- data/lib/moderate/event.rb +75 -0
- data/lib/moderate/filters/base.rb +126 -0
- data/lib/moderate/filters/wordlist.rb +255 -0
- data/lib/moderate/jobs/classify_job.rb +158 -0
- data/lib/moderate/label.rb +111 -0
- data/lib/moderate/macros.rb +90 -0
- data/lib/moderate/models/appeal.rb +154 -0
- data/lib/moderate/models/application_record.rb +31 -0
- data/lib/moderate/models/block.rb +203 -0
- data/lib/moderate/models/concerns/actor.rb +174 -0
- data/lib/moderate/models/concerns/content_filterable.rb +155 -0
- data/lib/moderate/models/concerns/reportable.rb +282 -0
- data/lib/moderate/models/flag.rb +136 -0
- data/lib/moderate/models/report.rb +620 -0
- data/lib/moderate/result.rb +176 -0
- data/lib/moderate/services/intake_appeal.rb +89 -0
- data/lib/moderate/services/intake_notice.rb +132 -0
- data/lib/moderate/services/intake_report.rb +132 -0
- data/lib/moderate/services/resolve_appeal.rb +134 -0
- data/lib/moderate/services/resolve_flag.rb +101 -0
- data/lib/moderate/services/resolve_report.rb +291 -0
- data/lib/moderate/version.rb +1 -1
- data/lib/moderate.rb +365 -18
- data/log/development.log +0 -0
- data/log/test.log +0 -0
- metadata +154 -15
data/Rakefile
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
|
-
|
|
1
|
+
begin
|
|
2
|
+
require "bundler/setup"
|
|
3
|
+
rescue LoadError
|
|
4
|
+
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
|
|
5
|
+
end
|
|
2
6
|
|
|
3
7
|
require "bundler/gem_tasks"
|
|
4
|
-
|
|
8
|
+
|
|
9
|
+
require "rdoc/task"
|
|
10
|
+
|
|
11
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
|
12
|
+
rdoc.rdoc_dir = "rdoc"
|
|
13
|
+
rdoc.title = "Moderate"
|
|
14
|
+
rdoc.options << "--line-numbers"
|
|
15
|
+
rdoc.rdoc_files.include("README.md")
|
|
16
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
20
|
+
load "rails/tasks/engine.rake"
|
|
21
|
+
|
|
22
|
+
require "rake/testtask"
|
|
23
|
+
|
|
24
|
+
Rake::TestTask.new(:test) do |t|
|
|
25
|
+
t.libs << "test"
|
|
26
|
+
t.pattern = "test/**/*_test.rb"
|
|
27
|
+
t.verbose = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
task default: :test
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# Drop-in admin moderation actions for a host's admin controller (BYOUI).
|
|
5
|
+
#
|
|
6
|
+
# class Admin::ReportsController < ApplicationController
|
|
7
|
+
# include Moderate::Moderation # resolve/dismiss (+ uphold/reject) actions
|
|
8
|
+
# before_action :require_admin # you bring auth
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# `moderate` deliberately ships NO admin UI — Trust & Safety chrome (branding,
|
|
12
|
+
# auth, layout) is the part every app wants to own. What it DOES own is the
|
|
13
|
+
# decision *logic*: every status change must go through the model's atomic
|
|
14
|
+
# decision method (`resolve!`/`dismiss!`/`uphold!`/`reject!`) so content removal,
|
|
15
|
+
# bans, the notify/audit hooks, the DSA Art. 17 statement-of-reasons, and the
|
|
16
|
+
# Art. 20 appeal window all happen together-or-not-at-all. This concern is the
|
|
17
|
+
# thin HTTP glue that calls those methods, so a host gets the standard wiring for
|
|
18
|
+
# free and never hand-rolls a raw `status = "..."` update (which would skip every
|
|
19
|
+
# one of those guarantees). See docs/madmin.md.
|
|
20
|
+
#
|
|
21
|
+
# The actions assume `@record` is already loaded (madmin's ResourceController and
|
|
22
|
+
# most admin frameworks set it from the member route). If yours doesn't, override
|
|
23
|
+
# `moderation_record` below.
|
|
24
|
+
module Moderation
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
# --- Report / Flag decisions ---------------------------------------------
|
|
28
|
+
# Both `Moderate::Report` and `Moderate::Flag` expose `resolve!`/`dismiss!` with
|
|
29
|
+
# the same keyword contract, so the SAME two actions drive either resource —
|
|
30
|
+
# the host just includes this concern in whichever controller.
|
|
31
|
+
|
|
32
|
+
# Resolve (action) a report/flag: optionally remove the offending content and/or
|
|
33
|
+
# ban the responsible user, always with a moderator + a note.
|
|
34
|
+
#
|
|
35
|
+
# `remove_content`/`ban_user` come from the form as checkboxes ("1"/"0"); the
|
|
36
|
+
# model casts them, but we pass them through untouched so the model stays the one
|
|
37
|
+
# place that interprets them. `note` is required by the model (it feeds the
|
|
38
|
+
# statement of reasons) — we let the model raise and turn that into a flash.
|
|
39
|
+
def resolve
|
|
40
|
+
record = moderation_record
|
|
41
|
+
record.resolve!(**moderation_decision_params)
|
|
42
|
+
redirect_after_moderation(record, notice: moderation_t(:resolved))
|
|
43
|
+
rescue => error
|
|
44
|
+
redirect_after_moderation(record, alert: moderation_error(:resolve, error))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Dismiss (action) a report/flag: no violation found. Note still required.
|
|
48
|
+
def dismiss
|
|
49
|
+
record = moderation_record
|
|
50
|
+
record.dismiss!(by: moderation_actor, note: moderation_note)
|
|
51
|
+
redirect_after_moderation(record, notice: moderation_t(:dismissed))
|
|
52
|
+
rescue => error
|
|
53
|
+
redirect_after_moderation(record, alert: moderation_error(:dismiss, error))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# --- Appeal decisions (DSA Art. 20) --------------------------------------
|
|
57
|
+
# An appeal is a free, electronic, human-decided internal complaint against a
|
|
58
|
+
# decision. `uphold!` OVERTURNS the original decision; `reject!` CONFIRMS it.
|
|
59
|
+
# https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Article 20)
|
|
60
|
+
|
|
61
|
+
def uphold
|
|
62
|
+
record = moderation_record
|
|
63
|
+
record.uphold!(by: moderation_actor, note: moderation_note)
|
|
64
|
+
redirect_after_moderation(record, notice: moderation_t(:upheld))
|
|
65
|
+
rescue => error
|
|
66
|
+
redirect_after_moderation(record, alert: moderation_error(:uphold, error))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reject
|
|
70
|
+
record = moderation_record
|
|
71
|
+
record.reject!(by: moderation_actor, note: moderation_note)
|
|
72
|
+
redirect_after_moderation(record, notice: moderation_t(:rejected))
|
|
73
|
+
rescue => error
|
|
74
|
+
redirect_after_moderation(record, alert: moderation_error(:reject, error))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# The record being decided on. Override if your admin framework names it
|
|
80
|
+
# differently — madmin uses `@record`, many hand-rolled admins do too.
|
|
81
|
+
def moderation_record
|
|
82
|
+
@record
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The moderator making the call. `current_user` is the near-universal accessor;
|
|
86
|
+
# a host whose admin uses a different actor (e.g. `current_admin`) overrides
|
|
87
|
+
# this single method. Required by every decision method (the decision must be
|
|
88
|
+
# attributable to a human — a DSA Art. 17/20 requirement).
|
|
89
|
+
def moderation_actor
|
|
90
|
+
current_user
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The mandatory decision rationale. Required by the model too (belt and
|
|
94
|
+
# suspenders) — it's what populates the statement of reasons sent to the parties.
|
|
95
|
+
def moderation_note
|
|
96
|
+
params[:note]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Strong params for the richest decision (`resolve` on a report). We don't use
|
|
100
|
+
# `params.require(:report)` because the decision form is a flat panel of
|
|
101
|
+
# checkboxes + a note, not a nested model form — so we read top-level params and
|
|
102
|
+
# hand the model exactly the keyword args it documents. `ban_user`/`remove_content`
|
|
103
|
+
# default to off so a missing checkbox never accidentally bans someone.
|
|
104
|
+
def moderation_decision_params
|
|
105
|
+
{
|
|
106
|
+
by: moderation_actor,
|
|
107
|
+
note: moderation_note,
|
|
108
|
+
remove_content: params[:remove_content],
|
|
109
|
+
ban_user: params[:ban_user]
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Redirect back to the record after a decision.
|
|
114
|
+
#
|
|
115
|
+
# `status: :see_other` (303) is REQUIRED, not stylistic: the decision actions are
|
|
116
|
+
# POSTs, and Turbo Drive only follows a redirect after a non-GET when the status
|
|
117
|
+
# is 303 — without it the redirect is swallowed and the page appears to hang.
|
|
118
|
+
# This is the same convention Rails' own scaffold create/update use.
|
|
119
|
+
# https://turbo.hotwired.dev/handbook/drive#redirecting-after-a-form-submission
|
|
120
|
+
#
|
|
121
|
+
# We fall back to `:back` when we can't build a path for the record, so the
|
|
122
|
+
# concern works regardless of the host's route names (BYOUI — we don't know
|
|
123
|
+
# them). The host can override `moderation_redirect_path` for an exact target.
|
|
124
|
+
def redirect_after_moderation(record, **flash)
|
|
125
|
+
path = moderation_redirect_path(record)
|
|
126
|
+
if path
|
|
127
|
+
redirect_to(path, status: :see_other, **flash)
|
|
128
|
+
else
|
|
129
|
+
redirect_back(fallback_location: "/", status: :see_other, **flash)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Where to go after a decision. Returns nil so `redirect_after_moderation` falls
|
|
134
|
+
# back to `redirect_back` — the safe default for an admin we know nothing about.
|
|
135
|
+
# Override in the host controller to land on the record's show page, e.g.
|
|
136
|
+
# def moderation_redirect_path(record) = main_app.madmin_report_path(record)
|
|
137
|
+
def moderation_redirect_path(_record)
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A flash message for the failure case. We surface the model's own error message
|
|
142
|
+
# (e.g. "report already closed", "note required") so the moderator sees WHY the
|
|
143
|
+
# decision didn't apply, not a generic "something went wrong".
|
|
144
|
+
def moderation_error(action, error)
|
|
145
|
+
message = error.respond_to?(:message) ? error.message : error.to_s
|
|
146
|
+
moderation_t(:"#{action}_failed", default: "Could not #{action}: #{message}", error: message)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Translated flash copy, with a plain-English default so the concern works even
|
|
150
|
+
# before the host loads the gem's locale file.
|
|
151
|
+
def moderation_t(key, **options)
|
|
152
|
+
defaults = {
|
|
153
|
+
resolved: "Report resolved.",
|
|
154
|
+
dismissed: "Report dismissed.",
|
|
155
|
+
upheld: "Appeal upheld.",
|
|
156
|
+
rejected: "Appeal rejected."
|
|
157
|
+
}
|
|
158
|
+
I18n.t("moderate.moderation.#{key}", default: options.delete(:default) || defaults[key] || key.to_s, **options)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -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
|