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
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Moderate
|
|
6
|
+
# The single configuration object the host populates in
|
|
7
|
+
# `config/initializers/moderate.rb` via `Moderate.configure do |config| ... end`.
|
|
8
|
+
#
|
|
9
|
+
# Design rules, straight from docs/configuration.md:
|
|
10
|
+
# - Config is read AT THE POINT OF USE, not frozen at boot. Class names are
|
|
11
|
+
# stored as strings and constantized lazily, so the initializer works no
|
|
12
|
+
# matter when the app loads (the User model may not exist yet at boot).
|
|
13
|
+
# - Validating setters normalize their input (`to_s.strip.downcase.to_sym`) so
|
|
14
|
+
# "Block", :block and " block " all mean the same thing, and raise a
|
|
15
|
+
# plain-English ArgumentError on a bad value — failing fast at the assignment
|
|
16
|
+
# line. `validate!` runs once more at the end of `configure` for cross-field
|
|
17
|
+
# checks (e.g. a :block filter pointed at an async adapter).
|
|
18
|
+
# - Every hook (`audit`/`notify`/`on_block`/`ban_handler`) defaults to a no-op,
|
|
19
|
+
# so the gem works untouched and a host wires hooks only as needed.
|
|
20
|
+
#
|
|
21
|
+
# This mirrors the validating-setter convention across the ecosystem
|
|
22
|
+
# (usage_credits' `default_currency=`, wallets' `default_asset=`).
|
|
23
|
+
class Configuration
|
|
24
|
+
# The three filter modes a `moderates :field` / `config.filter` can use.
|
|
25
|
+
# :off — no check
|
|
26
|
+
# :block — reject the save with a validation error if the filter trips
|
|
27
|
+
# :flag — allow the save, create a Moderate::Flag after commit for review
|
|
28
|
+
# Order matters for the error message ("must be one of: off, block, flag").
|
|
29
|
+
FILTER_MODES = %i[off block flag].freeze
|
|
30
|
+
|
|
31
|
+
# A per-(class, field) filter policy. `class_name` is stored as a STRING and
|
|
32
|
+
# constantized lazily by the consumer, same as `user_class`, so declaring a
|
|
33
|
+
# filter for a model that isn't loaded yet is fine. `adapter` is the adapter
|
|
34
|
+
# NAME (a symbol) resolved against the adapters registry at classify time —
|
|
35
|
+
# never the adapter object itself, so swapping a backend is a one-line change.
|
|
36
|
+
FilterPolicy = Data.define(:class_name, :field, :adapter, :mode) do
|
|
37
|
+
def off? = mode == :off
|
|
38
|
+
def block? = mode == :block
|
|
39
|
+
def flag? = mode == :flag
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# --- Identity -------------------------------------------------------------
|
|
43
|
+
attr_reader :user_class
|
|
44
|
+
|
|
45
|
+
# --- Filtering ------------------------------------------------------------
|
|
46
|
+
attr_reader :default_filter_mode, :filter_adapter
|
|
47
|
+
attr_accessor :additional_words, :excluded_words
|
|
48
|
+
attr_reader :adapters, :filters
|
|
49
|
+
|
|
50
|
+
# --- Taxonomy (host-customizable) -----------------------------------------
|
|
51
|
+
# Override the in-app COMMUNITY report category list. nil ⇒ the gem default
|
|
52
|
+
# (Moderate::Report::DEFAULT_CATEGORIES). Adding a category here requires NO
|
|
53
|
+
# migration: `category` is validated in the model (Moderate::Report), not by a DB
|
|
54
|
+
# check constraint. (The DSA legal-reason/country taxonomies are regulator-defined
|
|
55
|
+
# and NOT overridable.) A plain accessor — any value here is coerced to strings and
|
|
56
|
+
# compared at validation time by Report.report_categories.
|
|
57
|
+
attr_accessor :report_categories
|
|
58
|
+
|
|
59
|
+
# --- Hooks (all no-op by default) ----------------------------------------
|
|
60
|
+
attr_accessor :audit, :notify, :on_block, :ban_handler
|
|
61
|
+
|
|
62
|
+
# --- Misc -----------------------------------------------------------------
|
|
63
|
+
attr_accessor :locale
|
|
64
|
+
|
|
65
|
+
# --- DSA notice form ------------------------------------------------------
|
|
66
|
+
# Documented in docs/dsa-notice-form.md. Held here so the engine/controller
|
|
67
|
+
# (written by other components) read them off the same Configuration object.
|
|
68
|
+
# `notice_guard` is the gem-absent fallback bot gate (see
|
|
69
|
+
# app/controllers/moderate/notices_controller.rb#verify_human!): a proc that
|
|
70
|
+
# receives the controller and returns truthy to allow the POST. nil/no-op ⇒ the
|
|
71
|
+
# form just works (the default). When the host installs `rails_cloudflare_turnstile`,
|
|
72
|
+
# the controller auto-uses Turnstile instead and this proc is bypassed.
|
|
73
|
+
attr_reader :parent_controller
|
|
74
|
+
attr_accessor :notice_form_enabled, :notice_rate_limit,
|
|
75
|
+
:notice_turnstile_site_key, :notice_turnstile_secret_key,
|
|
76
|
+
:notice_captcha_verifier, :notice_guard, :notice_human_verification_skip_if,
|
|
77
|
+
:appeal_form_enabled, :appeal_rate_limit, :appeal_guard,
|
|
78
|
+
:appeal_human_verification_skip_if, :appeal_return_path,
|
|
79
|
+
:transparency_report_enabled,
|
|
80
|
+
:signed_gid_purposes
|
|
81
|
+
|
|
82
|
+
def initialize
|
|
83
|
+
# Identity. "User" is the overwhelmingly common case; the host overrides it
|
|
84
|
+
# if their actor model is "Account", "Member", etc.
|
|
85
|
+
@user_class = "User"
|
|
86
|
+
|
|
87
|
+
# Filtering defaults. :block is the safe default per docs (reject objectionable
|
|
88
|
+
# writes); :wordlist is the offline, zero-dependency default text adapter.
|
|
89
|
+
@default_filter_mode = :block
|
|
90
|
+
@filter_adapter = :wordlist
|
|
91
|
+
@additional_words = []
|
|
92
|
+
@excluded_words = []
|
|
93
|
+
|
|
94
|
+
# Community report category override. nil ⇒ Moderate::Report::DEFAULT_CATEGORIES.
|
|
95
|
+
# No migration needed to add a category — `category` is validated in the model.
|
|
96
|
+
@report_categories = nil
|
|
97
|
+
|
|
98
|
+
# Adapters registry: name (Symbol) => adapter (an object responding to
|
|
99
|
+
# `classify`, OR a String class name to constantize lazily at use time, so we
|
|
100
|
+
# don't force the built-in adapter files to be loaded before the initializer
|
|
101
|
+
# runs). Seeded with the ONE built-in (the offline :wordlist) the README
|
|
102
|
+
# documents; OpenAI/Rekognition/etc. are reference adapters in examples/ a host
|
|
103
|
+
# copies in and registers — they are not shipped, loaded, or a dependency.
|
|
104
|
+
#
|
|
105
|
+
# The class behind this name is the gem's own adapter; we reference it by string
|
|
106
|
+
# to keep this file decoupled from its load order (and there is no
|
|
107
|
+
# `Moderate::Adapters` alias namespace — point straight at the Filters class).
|
|
108
|
+
@adapters = {
|
|
109
|
+
wordlist: "Moderate::Filters::Wordlist"
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
# Per-field filter policies, keyed by [class_name_string, field_string].
|
|
113
|
+
@filters = {}
|
|
114
|
+
|
|
115
|
+
# Hooks — no-ops by default. These exact signatures are documented:
|
|
116
|
+
# audit/notify take a single Moderate::Event
|
|
117
|
+
# on_block/ban_handler take keyword args
|
|
118
|
+
@audit = ->(_event) {}
|
|
119
|
+
@notify = ->(_event) {}
|
|
120
|
+
@on_block = ->(blocker:, blocked:, at:) {}
|
|
121
|
+
@ban_handler = ->(user:, by:, reason:) {}
|
|
122
|
+
|
|
123
|
+
# Misc. nil locale ⇒ follow I18n.default_locale at use time.
|
|
124
|
+
@locale = nil
|
|
125
|
+
|
|
126
|
+
# Engine controller defaults. The parent controller defaults to a stock base so
|
|
127
|
+
# the engine works even on API-only apps — the same `parent_controller`
|
|
128
|
+
# indirection Devise and api_keys use.
|
|
129
|
+
@parent_controller = "::ActionController::Base"
|
|
130
|
+
|
|
131
|
+
# DSA notice-form defaults (see docs/dsa-notice-form.md). The form is on by
|
|
132
|
+
# default; both bot-gates no-op when their keys are blank; the rate limit is a
|
|
133
|
+
# sane per-IP throttle.
|
|
134
|
+
@notice_form_enabled = true
|
|
135
|
+
@notice_rate_limit = { max: 5, within: 3600 } # 1.hour, expressed in seconds to avoid an ActiveSupport dependency here
|
|
136
|
+
@notice_turnstile_site_key = nil
|
|
137
|
+
@notice_turnstile_secret_key = nil
|
|
138
|
+
@notice_captcha_verifier = nil
|
|
139
|
+
# Gem-absent fallback bot gate. nil ⇒ no extra gate (the form just works); the
|
|
140
|
+
# controller only consults it when rails_cloudflare_turnstile is NOT installed.
|
|
141
|
+
@notice_guard = nil
|
|
142
|
+
@notice_human_verification_skip_if = nil
|
|
143
|
+
|
|
144
|
+
# DSA internal complaint / appeal form defaults. Same shape as the notice
|
|
145
|
+
# form: public route, optional bot gate, runtime rate limit, and a redirect
|
|
146
|
+
# target the host can choose.
|
|
147
|
+
@appeal_form_enabled = true
|
|
148
|
+
@appeal_rate_limit = { max: 10, within: 60 }
|
|
149
|
+
@appeal_guard = nil
|
|
150
|
+
@appeal_human_verification_skip_if = nil
|
|
151
|
+
@appeal_return_path = "/"
|
|
152
|
+
|
|
153
|
+
# The public Art. 24 transparency report. OFF by default — opt in with
|
|
154
|
+
# `config.transparency_report_enabled = true`. A *live* transparency portal is
|
|
155
|
+
# not itself a legal requirement: the DSA obligation is to *publish* a report at
|
|
156
|
+
# least annually (a static page/file is fine), and micro/small enterprises are
|
|
157
|
+
# exempt from the transparency tier entirely (Art. 15(2) / Art. 19). So we don't
|
|
158
|
+
# publicly expose moderation counts unless the host explicitly turns it on. When
|
|
159
|
+
# off, the mounted `/transparency` route 404s; the aggregation stays queryable in
|
|
160
|
+
# code so a host can still build/publish its own report.
|
|
161
|
+
@transparency_report_enabled = false
|
|
162
|
+
|
|
163
|
+
@signed_gid_purposes = %i[appeal confirm_notice unsubscribe]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parent_controller=(value)
|
|
167
|
+
name = value.is_a?(Class) ? value.name : value.to_s
|
|
168
|
+
raise ArgumentError, "parent_controller can't be blank" if name.strip.empty?
|
|
169
|
+
|
|
170
|
+
@parent_controller = name
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# --- Validating setters ---------------------------------------------------
|
|
174
|
+
|
|
175
|
+
# user_class is stored as a String (constantized lazily by `Moderate.user_class`).
|
|
176
|
+
# We accept a Class or a String and reject blanks, so a `nil`/"" slip surfaces
|
|
177
|
+
# at the assignment line instead of as a cryptic NameError much later.
|
|
178
|
+
def user_class=(value)
|
|
179
|
+
name = value.is_a?(Class) ? value.name : value.to_s
|
|
180
|
+
raise ArgumentError, "user_class can't be blank" if name.strip.empty?
|
|
181
|
+
|
|
182
|
+
@user_class = name
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# default_filter_mode normalizes and validates against FILTER_MODES.
|
|
186
|
+
def default_filter_mode=(value)
|
|
187
|
+
@default_filter_mode = normalize_mode(value)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# filter_adapter normalizes to a symbol; existence is checked in `validate!`
|
|
191
|
+
# (it may legitimately name an adapter the host registers later in the same
|
|
192
|
+
# block, so we can't insist it already exists at assignment time).
|
|
193
|
+
def filter_adapter=(value)
|
|
194
|
+
@filter_adapter = normalize_name(value)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- Adapters -------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
# Register a host adapter under a name of the host's choosing. The adapter is
|
|
200
|
+
# any object responding to `classify(value) → Moderate::Result`. The name is
|
|
201
|
+
# what gets recorded as `Moderate::Flag#source`, so it shows in the queue.
|
|
202
|
+
#
|
|
203
|
+
# Both arg orders are accepted for ergonomics:
|
|
204
|
+
# config.register_adapter :openai, OpenAIModerator.new
|
|
205
|
+
# config.register_adapter(:openai, OpenAIModerator.new)
|
|
206
|
+
def register_adapter(name, adapter)
|
|
207
|
+
key = normalize_name(name)
|
|
208
|
+
unless adapter.is_a?(String) || adapter.is_a?(Class) || adapter.respond_to?(:classify)
|
|
209
|
+
raise ArgumentError, "adapter for #{key.inspect} must respond to #classify"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
@adapters[key] = adapter
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Look up a registered adapter object by name, constantizing a String/Class
|
|
216
|
+
# reference (the built-ins) on first use. Returns nil for an unknown name —
|
|
217
|
+
# callers decide whether that's an error (the validator/classify path raises a
|
|
218
|
+
# helpful message; see Moderate.classify).
|
|
219
|
+
def adapter_for(name)
|
|
220
|
+
key = normalize_name(name)
|
|
221
|
+
ref = @adapters[key]
|
|
222
|
+
return nil if ref.nil?
|
|
223
|
+
|
|
224
|
+
resolve_adapter(ref).tap do |adapter|
|
|
225
|
+
@adapters[key] = adapter unless adapter.equal?(ref)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def adapter_registered?(name)
|
|
230
|
+
@adapters.key?(normalize_name(name))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# --- Filters --------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
# Declare a per-field filter policy in the initializer — the twin of
|
|
236
|
+
# `moderates :field, with:, mode:` on the model. Stores the policy keyed by
|
|
237
|
+
# [class_name, field] so `filter_policy_for` can resolve it (including up the
|
|
238
|
+
# ancestor chain) at classify time.
|
|
239
|
+
def filter(class_name, field, with: nil, mode: nil)
|
|
240
|
+
name = class_name.is_a?(Class) ? class_name.name : class_name.to_s
|
|
241
|
+
field_s = field.to_s
|
|
242
|
+
adapter = with.nil? ? @filter_adapter : normalize_name(with)
|
|
243
|
+
resolved_mode = mode.nil? ? @default_filter_mode : normalize_mode(mode)
|
|
244
|
+
|
|
245
|
+
policy = FilterPolicy.new(class_name: name, field: field_s, adapter: adapter, mode: resolved_mode)
|
|
246
|
+
@filters[[name, field_s]] = policy
|
|
247
|
+
policy
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# --- Validation -----------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
# Cross-field validation run at the end of `Moderate.configure`. The per-setter
|
|
253
|
+
# checks already caught most typos; this catches the things that need the whole
|
|
254
|
+
# block resolved:
|
|
255
|
+
# - the default text adapter must actually be registered
|
|
256
|
+
# - every per-field filter must name a registered adapter
|
|
257
|
+
# - a :block-mode filter must use a SYNCHRONOUS adapter — you can't reject a
|
|
258
|
+
# save on a background result, so :block + an async adapter (e.g. a remote
|
|
259
|
+
# classifier) is a configuration error. Async adapters run in :flag mode.
|
|
260
|
+
# (README: "`:block` requires a synchronous adapter".)
|
|
261
|
+
def validate!
|
|
262
|
+
validate_adapter_name!(@filter_adapter, context: "filter_adapter")
|
|
263
|
+
|
|
264
|
+
@filters.each_value do |policy|
|
|
265
|
+
validate_adapter_name!(policy.adapter, context: "filter #{policy.class_name}##{policy.field}")
|
|
266
|
+
validate_block_mode_adapter!(policy)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
true
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
private
|
|
273
|
+
|
|
274
|
+
# Normalize a free-form mode into one of FILTER_MODES, raising a plain-English
|
|
275
|
+
# ArgumentError otherwise. The normalization ("Block"/" block " → :block) is
|
|
276
|
+
# the ecosystem-wide convention.
|
|
277
|
+
def normalize_mode(value)
|
|
278
|
+
mode = value.to_s.strip.downcase.to_sym
|
|
279
|
+
unless FILTER_MODES.include?(mode)
|
|
280
|
+
raise ArgumentError, "default_filter_mode must be one of: #{FILTER_MODES.join(', ')}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
mode
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def normalize_name(value)
|
|
287
|
+
value.to_s.strip.downcase.to_sym
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def validate_adapter_name!(name, context:)
|
|
291
|
+
return if adapter_registered?(name)
|
|
292
|
+
|
|
293
|
+
raise ArgumentError,
|
|
294
|
+
"unknown filter adapter #{name.inspect} for #{context} — the only built-in is :wordlist; " \
|
|
295
|
+
"register your own with `config.register_adapter #{name.inspect}, MyAdapter.new`"
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# A :block filter needs a synchronous adapter. We treat an adapter as
|
|
299
|
+
# synchronous unless it explicitly declares `synchronous? == false` (an adapter
|
|
300
|
+
# may expose a `synchronous?`/`async?` predicate — the built-in Filters::Base
|
|
301
|
+
# does — and we honor it). Adapters that don't answer the predicate are assumed
|
|
302
|
+
# synchronous — the conservative default that keeps simple adapters working.
|
|
303
|
+
def validate_block_mode_adapter!(policy)
|
|
304
|
+
return unless policy.block?
|
|
305
|
+
|
|
306
|
+
adapter = resolve_adapter_safely(policy.adapter)
|
|
307
|
+
return if adapter.nil? # unknown adapter already raised above
|
|
308
|
+
|
|
309
|
+
return unless adapter.respond_to?(:synchronous?)
|
|
310
|
+
return if adapter.synchronous?
|
|
311
|
+
|
|
312
|
+
raise ConfigurationError,
|
|
313
|
+
"filter #{policy.class_name}##{policy.field} uses mode :block with the async adapter " \
|
|
314
|
+
"#{policy.adapter.inspect}. :block must reject a save synchronously, which an async adapter " \
|
|
315
|
+
"can't do — use mode: :flag (allow the write, classify in a job, file a Moderate::Flag)."
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Turn an adapters-registry value into an adapter object. Strings/Classes are
|
|
319
|
+
# constantized and used directly when they expose class-level `classify`
|
|
320
|
+
# (Moderate::Filters::Base style), otherwise instantiated so a host can
|
|
321
|
+
# register a plain class whose instances implement `#classify`.
|
|
322
|
+
def resolve_adapter(ref)
|
|
323
|
+
adapter = case ref
|
|
324
|
+
when String then ref.constantize
|
|
325
|
+
when Class then ref
|
|
326
|
+
else
|
|
327
|
+
return ref
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
adapter.respond_to?(:classify) ? adapter : adapter.new
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Like resolve_adapter but never raises (a built-in whose file isn't loaded yet
|
|
334
|
+
# shouldn't blow up validation) — returns nil if it can't be resolved.
|
|
335
|
+
def resolve_adapter_safely(name)
|
|
336
|
+
adapter_for(name)
|
|
337
|
+
rescue NameError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
|
|
5
|
+
module Moderate
|
|
6
|
+
# The Rails engine that wires `moderate` into a host app.
|
|
7
|
+
#
|
|
8
|
+
# It's an ISOLATED engine (`isolate_namespace Moderate`) for the same reason
|
|
9
|
+
# Devise's is: the gem ships a mountable public DSA notice form
|
|
10
|
+
# (`mount Moderate::Engine => "/legal"`, see docs/dsa-notice-form.md) with its
|
|
11
|
+
# own controllers, views, helpers, and `moderate_`-prefixed tables. Isolation
|
|
12
|
+
# keeps every one of those from colliding with the host app's namespace, and
|
|
13
|
+
# gives the host the `moderate.` URL-helper proxy for the mounted routes.
|
|
14
|
+
#
|
|
15
|
+
# NOTE: the engine is intentionally thin. The host can use the whole gem
|
|
16
|
+
# (reporting, blocking, filtering, the queue) WITHOUT mounting anything — the
|
|
17
|
+
# mount is only needed for the public Art. 16 notice form. Everything here is
|
|
18
|
+
# plumbing that runs whether or not the engine is mounted.
|
|
19
|
+
class Engine < ::Rails::Engine
|
|
20
|
+
isolate_namespace Moderate
|
|
21
|
+
|
|
22
|
+
# The autoloadable code that needs the host's ActiveRecord/ApplicationRecord and
|
|
23
|
+
# the host's `config.user_class` to exist before it loads — the four AR models,
|
|
24
|
+
# the three model concerns, the async classify job, the filter adapters, and the
|
|
25
|
+
# service objects — lives under `lib/moderate/{models,jobs,filters,services}`,
|
|
26
|
+
# NOT under the engine's `app/` tree. That's a deliberate gem-packaging choice
|
|
27
|
+
# (everything ships under `lib/`), but it means we have to teach the host's
|
|
28
|
+
# Zeitwerk loader about that subtree ourselves, with three corrections:
|
|
29
|
+
#
|
|
30
|
+
# 1. NAMESPACE. A bare `eager_load_paths << ".../lib/moderate"` would map the
|
|
31
|
+
# directory to the TOP-LEVEL namespace, so Zeitwerk would expect
|
|
32
|
+
# `lib/moderate/configuration.rb` to define `::Configuration` (not
|
|
33
|
+
# `Moderate::Configuration`) and blow up with "uninitialized constant
|
|
34
|
+
# Configuration" during eager load. We push the dir mapped to the `Moderate`
|
|
35
|
+
# module instead, so `lib/moderate/models/report.rb` → `Moderate::Report`.
|
|
36
|
+
# (Zeitwerk's `push_dir(path, namespace:)` is exactly for this — see
|
|
37
|
+
# https://github.com/fxn/zeitwerk#custom-root-namespaces.)
|
|
38
|
+
#
|
|
39
|
+
# 2. COLLAPSED DIRECTORIES. `models/`, `models/concerns/`, and `jobs/` are
|
|
40
|
+
# organizational, not namespace, segments — `report.rb` must be
|
|
41
|
+
# `Moderate::Report`, NOT `Moderate::Models::Report`; `actor.rb` must be
|
|
42
|
+
# `Moderate::Actor`, not `Moderate::Models::Concerns::Actor`. We `collapse`
|
|
43
|
+
# them so they contribute no namespace. (`services/` and `filters/` are
|
|
44
|
+
# KEPT, because the code intentionally namespaces `Moderate::Services::*`
|
|
45
|
+
# and `Moderate::Filters::*`.)
|
|
46
|
+
#
|
|
47
|
+
# 3. IGNORED FILES. The gem's SPINE (`version`, `errors`, `label`, `result`,
|
|
48
|
+
# `event`, `configuration`, `engine`, `macros`) is `require_relative`'d from
|
|
49
|
+
# `lib/moderate.rb` so the value objects work in a plain-Ruby context with
|
|
50
|
+
# no Rails — those files MUST NOT also be managed by Zeitwerk (a constant
|
|
51
|
+
# can't be both manually required and autoloaded). The 0.x compatibility
|
|
52
|
+
# shims (`text`, `text_validator`, `word_list`) are ignored too: they either
|
|
53
|
+
# define non-conventional constants (`text_validator.rb` reopens
|
|
54
|
+
# `ActiveModel::Validations`, not a `Moderate::*` constant) or are legacy and
|
|
55
|
+
# loaded only on demand.
|
|
56
|
+
#
|
|
57
|
+
# We do this on the host's MAIN autoloader (`Rails.autoloaders.main`) inside an
|
|
58
|
+
# initializer scheduled BEFORE `:set_autoload_paths`, the same point Rails wires
|
|
59
|
+
# up its own roots — so our `push_dir`/`collapse`/`ignore` are in place before
|
|
60
|
+
# Zeitwerk's `setup`/`eager_load` ever run.
|
|
61
|
+
LIB_ROOT = File.expand_path("..", __dir__) # => .../lib
|
|
62
|
+
MODERATE_LIB = File.expand_path("moderate", LIB_ROOT) # => .../lib/moderate
|
|
63
|
+
|
|
64
|
+
# Spine + 0.x-compat files Zeitwerk must NOT manage (they're require_relative'd
|
|
65
|
+
# from the spine or define non-conventional constants).
|
|
66
|
+
ZEITWERK_IGNORED = %w[
|
|
67
|
+
version.rb errors.rb label.rb result.rb event.rb
|
|
68
|
+
configuration.rb engine.rb macros.rb
|
|
69
|
+
text.rb text_validator.rb word_list.rb
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
initializer "moderate.autoload", before: :set_autoload_paths do
|
|
73
|
+
loader = Rails.autoloaders.main
|
|
74
|
+
|
|
75
|
+
# Files the spine already requires (or that define top-level constants) — tell
|
|
76
|
+
# Zeitwerk to leave them alone so it doesn't try to (re)manage their constants.
|
|
77
|
+
ZEITWERK_IGNORED.each do |file|
|
|
78
|
+
path = File.join(MODERATE_LIB, file)
|
|
79
|
+
loader.ignore(path) if File.exist?(path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `models`, `models/concerns`, and `jobs` are organizational dirs that must not
|
|
83
|
+
# appear in the constant path (Moderate::Report, not Moderate::Models::Report).
|
|
84
|
+
%w[models models/concerns jobs].each do |dir|
|
|
85
|
+
path = File.join(MODERATE_LIB, dir)
|
|
86
|
+
loader.collapse(path) if File.directory?(path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Push lib/moderate as an autoload root mapped to the Moderate namespace, so
|
|
90
|
+
# lib/moderate/foo.rb → Moderate::Foo (and, with the collapses above,
|
|
91
|
+
# lib/moderate/models/report.rb → Moderate::Report).
|
|
92
|
+
loader.push_dir(MODERATE_LIB, namespace: Moderate)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Eager-load the same subtree in production (and in the test suite, which sets
|
|
96
|
+
# `config.eager_load = true`) so autoload/NameError problems surface as a boot
|
|
97
|
+
# failure rather than a mid-request error. `push_dir` above already makes it
|
|
98
|
+
# autoloadable; adding it to eager_load_paths makes the boot-time pass cover it.
|
|
99
|
+
config.eager_load_paths << MODERATE_LIB
|
|
100
|
+
|
|
101
|
+
# Make the host's migrations:install task pick up the gem's migration template
|
|
102
|
+
# location, and ensure the gem's own migration directory is on the path. The
|
|
103
|
+
# primary install path is still `rails generate moderate:install` (which copies
|
|
104
|
+
# the adaptive migration into the host's db/migrate); appending the path here is
|
|
105
|
+
# belt-and-suspenders so engine-style `moderate:install:migrations` also works.
|
|
106
|
+
initializer "moderate.migrations" do |app|
|
|
107
|
+
unless app.root.to_s == root.to_s
|
|
108
|
+
config.paths["db/migrate"].expanded.each do |path|
|
|
109
|
+
app.config.paths["db/migrate"] << path
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Register the model macros on ActiveRecord, the canonical Rails way.
|
|
115
|
+
#
|
|
116
|
+
# `ActiveSupport.on_load(:active_record)` defers until ActiveRecord::Base is
|
|
117
|
+
# actually defined, so we never force-load AR at boot and we play nicely with
|
|
118
|
+
# the host's load order. Once it fires, every model gains `has_reporting_and_blocking`,
|
|
119
|
+
# `has_reportable_content`, and `moderates` as class methods (the macros that lazily
|
|
120
|
+
# include Moderate::Actor / Moderate::Reportable / Moderate::ContentFilterable).
|
|
121
|
+
#
|
|
122
|
+
# `Moderate::Macros` is require_relative'd by the spine, so the constant resolves
|
|
123
|
+
# the instant this hook fires.
|
|
124
|
+
initializer "moderate.active_record" do
|
|
125
|
+
ActiveSupport.on_load(:active_record) do
|
|
126
|
+
extend Moderate::Macros
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Surface the gem's I18n files (filter validation messages, the DSA taxonomy
|
|
131
|
+
# labels, the notice-form copy) to the host's I18n load path. The locale files
|
|
132
|
+
# ship under the engine's conventional config/locales (provided by other
|
|
133
|
+
# components); listing the glob here is harmless if none exist yet.
|
|
134
|
+
initializer "moderate.locales" do |app|
|
|
135
|
+
app.config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.{rb,yml}").to_s]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# Base class for every error this gem raises. Host apps can rescue
|
|
5
|
+
# `Moderate::Error` to catch anything the gem throws without coupling to a
|
|
6
|
+
# specific subclass.
|
|
7
|
+
#
|
|
8
|
+
# NOTE: 0.x already defined `Moderate::Error` (a bare StandardError used by the
|
|
9
|
+
# profanity word-list downloader). We keep the exact same constant so any host
|
|
10
|
+
# code that rescued it on the old gem keeps working — see "Upgrading from 0.x"
|
|
11
|
+
# in the README. The class is just re-homed here in 1.0.
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
|
|
14
|
+
# Raised by `Configuration#validate!` (called at the end of `Moderate.configure`)
|
|
15
|
+
# when the initializer block contains a bad value — an unknown filter mode, an
|
|
16
|
+
# unregistered adapter name, a blank `user_class`, etc.
|
|
17
|
+
#
|
|
18
|
+
# We deliberately *also* raise plain `ArgumentError` from the validating setters
|
|
19
|
+
# themselves (per docs/configuration.md: "raises a plain-English ArgumentError
|
|
20
|
+
# immediately"), so a typo fails fast at the assignment line. `ConfigurationError`
|
|
21
|
+
# exists for the cases where validation can only run once the whole block is
|
|
22
|
+
# known (e.g. a `:block`-mode filter pointed at an async adapter, which needs
|
|
23
|
+
# both the mode and the adapter resolved together). It subclasses `Error` so a
|
|
24
|
+
# blanket `rescue Moderate::Error` still catches it.
|
|
25
|
+
class ConfigurationError < Error; end
|
|
26
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Moderate
|
|
6
|
+
# The stable envelope every notifiable/auditable moment travels in.
|
|
7
|
+
#
|
|
8
|
+
# `Moderate.notify` and `Moderate.audit` both hand the host's hook ONE of these,
|
|
9
|
+
# so a single `config.notify` lambda can `case event.name` and fan out to email
|
|
10
|
+
# (goodmail), admin alerts (telegrama), and in-app + push (noticed) without any
|
|
11
|
+
# of those channels knowing about each other. See docs/notifications.md.
|
|
12
|
+
#
|
|
13
|
+
# The fields are the contract the docs promise the host can rely on:
|
|
14
|
+
# event.name # Symbol — which moment, e.g. :report_decision
|
|
15
|
+
# event.subject # the record this is about (a Report / Flag / Block / Appeal / Notice)
|
|
16
|
+
# event.actor # who triggered it (a moderator, a user, or nil for system events)
|
|
17
|
+
# event.recipients # Array — already resolved to the right people to notify
|
|
18
|
+
# event.payload # Hash of event-specific context, ALWAYS including :summary
|
|
19
|
+
# event.occurred_at # when it happened
|
|
20
|
+
# event.to_h # the whole envelope as a Hash (for logging, tests, noticed params)
|
|
21
|
+
#
|
|
22
|
+
# Immutable value object (`Data.define`, Ruby 3.2+). The host never constructs
|
|
23
|
+
# one — the gem's services do — the host only reads it.
|
|
24
|
+
Event = Data.define(:name, :subject, :actor, :recipients, :payload, :occurred_at, :id) do
|
|
25
|
+
# Keyword constructor with forgiving defaults so internal call sites stay terse.
|
|
26
|
+
# `recipients` is coerced to a compacted Array (an event may target one user,
|
|
27
|
+
# several, or none — `content_flagged` has no user recipient by design, so an
|
|
28
|
+
# empty array is normal and correct). `payload` is symbolized for stable access
|
|
29
|
+
# (hooks read `event.payload[:summary]`).
|
|
30
|
+
def initialize(name:, subject: nil, actor: nil, recipients: nil, payload: nil,
|
|
31
|
+
occurred_at: Time.now, id: SecureRandom.uuid)
|
|
32
|
+
super(
|
|
33
|
+
name: name.to_sym,
|
|
34
|
+
subject: subject,
|
|
35
|
+
actor: actor,
|
|
36
|
+
recipients: Array(recipients).compact,
|
|
37
|
+
payload: symbolize(payload),
|
|
38
|
+
occurred_at: occurred_at,
|
|
39
|
+
id: id
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The whole envelope as a plain Hash. docs/notifications.md tells hosts to pass
|
|
44
|
+
# `event.to_h` (not the raw object) into `noticed`, because noticed serializes
|
|
45
|
+
# params to the database and a Hash of GlobalID-able records + scalars stores
|
|
46
|
+
# cleanly. Keep this a flat, predictable shape.
|
|
47
|
+
def to_h
|
|
48
|
+
{
|
|
49
|
+
name: name,
|
|
50
|
+
subject: subject,
|
|
51
|
+
actor: actor,
|
|
52
|
+
recipients: recipients,
|
|
53
|
+
payload: payload,
|
|
54
|
+
occurred_at: occurred_at,
|
|
55
|
+
id: id
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The one-line, redaction-safe description of what happened. Built for the
|
|
60
|
+
# admin Telegram ping ("Telegrama.send_message(event.payload[:summary])"), so
|
|
61
|
+
# we surface it as a first-class reader rather than making every hook reach
|
|
62
|
+
# into the payload Hash. Falls back to the event name if a producer forgot it.
|
|
63
|
+
def summary
|
|
64
|
+
payload[:summary] || name.to_s
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def symbolize(payload)
|
|
70
|
+
return {} if payload.nil?
|
|
71
|
+
|
|
72
|
+
payload.to_h.transform_keys(&:to_sym)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|