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
data/Rakefile CHANGED
@@ -1,4 +1,30 @@
1
- # frozen_string_literal: true
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
- task default: %i[]
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