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
@@ -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