Module: Moderate
- Defined in:
- lib/moderate.rb,
lib/moderate/text.rb,
lib/moderate/event.rb,
lib/moderate/label.rb,
lib/moderate/engine.rb,
lib/moderate/errors.rb,
lib/moderate/macros.rb,
lib/moderate/result.rb,
lib/moderate/version.rb,
lib/moderate/word_list.rb,
lib/moderate/models/flag.rb,
lib/moderate/filters/base.rb,
lib/moderate/models/block.rb,
lib/moderate/configuration.rb,
lib/moderate/models/appeal.rb,
lib/moderate/models/report.rb,
lib/moderate/filters/wordlist.rb,
lib/moderate/jobs/classify_job.rb,
app/helpers/moderate/engine_helper.rb,
lib/moderate/models/concerns/actor.rb,
lib/moderate/services/resolve_flag.rb,
lib/moderate/services/intake_appeal.rb,
lib/moderate/services/intake_notice.rb,
lib/moderate/services/intake_report.rb,
lib/moderate/services/resolve_appeal.rb,
lib/moderate/services/resolve_report.rb,
lib/moderate/models/application_record.rb,
lib/generators/moderate/views_generator.rb,
lib/moderate/models/concerns/reportable.rb,
lib/generators/moderate/install_generator.rb,
app/controllers/moderate/appeals_controller.rb,
app/controllers/moderate/notices_controller.rb,
app/controllers/concerns/moderate/moderation.rb,
app/controllers/moderate/application_controller.rb,
lib/moderate/models/concerns/content_filterable.rb,
app/controllers/moderate/transparency_reports_controller.rb
Overview
The macros lazily mix the gem’s concerns into a host model.
We DON’T ‘require_relative` the concern files here, even though this file is itself require_relative’d by the spine (lib/moderate.rb) at gem-load time. The concerns (Moderate::Actor / Reportable / ContentFilterable) live under ‘lib/moderate/models/concerns/` and are AUTOLOADED by Zeitwerk (the engine `push_dir`s that subtree under the `Moderate` namespace with the `models` and `concerns` dirs collapsed). Requiring them here too would double-manage the same constants and make Zeitwerk raise on its eager-load pass (“already defined”).
This is safe because the macro methods below only REFERENCE the concern constants inside their bodies (‘include Moderate::Actor`), which run when a host model calls `has_reporting_and_blocking`/`has_reportable_content`/`moderates` — long after boot, when the autoloader is fully wired. So Zeitwerk autoloads each concern lazily on first use.
Defined Under Namespace
Modules: Actor, ContentFilterable, EngineHelper, Filters, Generators, Macros, Moderation, Reportable, Services Classes: Appeal, AppealsController, ApplicationController, ApplicationRecord, Block, ClassifyJob, Configuration, ConfigurationError, Engine, Error, Event, Flag, Label, NoticesController, Report, Result, Text, TransparencyReportsController, WordList
Constant Summary collapse
- VERSION =
"1.0.0.beta1"
Class Method Summary collapse
-
.apply_ban(user:, by:, reason:) ⇒ Object
Apply a ban via the host’s ‘ban_handler` (suspend!, soft-delete, flip a flag, whatever “banned” means in the host’s domain).
-
.audit(event_or_name = nil, **payload) ⇒ Object
Dispatch an auditable moment to the host’s ‘config.audit` hook (no-op by default).
-
.blocked_ids_for(user) ⇒ Object
The single source-of-truth list of user ids “related to” ‘user` via a block edge — i.e.
-
.classify(value, policy: nil) ⇒ Object
Classify a value (text or image) and return a Moderate::Result.
-
.config ⇒ Object
(also: configuration)
The singleton Configuration.
-
.configure {|config| ... } ⇒ Object
The host’s entry point: ‘Moderate.configure do |config| …
-
.filter_policy_for(record_or_class, field) ⇒ Object
Resolve the FilterPolicy for a given record/class + field, walking the ancestor chain so a policy declared on a base/STI parent applies to subclasses (this is why a marketplace’s ‘Listing` policy covers `Listing::Featured`, etc. — host-agnostically).
-
.locale ⇒ Object
The locale for copy the gem generates itself, falling back to the app’s default.
-
.notify(event_or_name, **payload) ⇒ Object
Dispatch a notifiable moment to the host’s ‘config.notify` hook.
-
.register_adapter(name, adapter) ⇒ Object
Register a filter adapter at runtime (the facade twin of ‘config.register_adapter`, so a host can call either `Moderate.register_adapter(…)` or `config.register_adapter(…)`).
-
.register_reportable(klass) ⇒ Object
Auto-discovered set of classes that declared themselves reportable (via the ‘has_reportable_content` macro or `include Moderate::Reportable`).
-
.reportable_classes ⇒ Object
The reportable classes, constantized on demand.
-
.reset! ⇒ Object
Reset to a pristine, fully-defaulted Configuration.
-
.run_on_block(blocker:, blocked:, at:) ⇒ Object
Run the optional ‘on_block` side-effect hook (cancel a pending invite, leave a shared room, …).
-
.transparency(from: nil, to: nil) ⇒ Object
DSA Art.
-
.user_class ⇒ Object
The actor model (who reports/blocks/gets reported/gets banned), resolved by constantizing ‘config.user_class` LAZILY on first use.
Class Method Details
.apply_ban(user:, by:, reason:) ⇒ Object
Apply a ban via the host’s ‘ban_handler` (suspend!, soft-delete, flip a flag, whatever “banned” means in the host’s domain). Keyword-arg signature. No-op by default — the surrounding decision still audits and notifies even if no ban is wired, so the action is never silently dropped (docs/configuration.md).
212 213 214 215 216 217 218 219 220 221 222 223 |
# File 'lib/moderate.rb', line 212 def apply_ban(user:, by:, reason:) result = config.ban_handler.call(user: user, by: by, reason: reason) payload = { user_id: user&.id, reason: reason, summary: "user #{user&.id || '(unknown)'} banned" }.compact audit(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload) notify(:user_banned, subject: user, actor: by, recipients: [user].compact, payload: payload) result end |
.audit(event_or_name = nil, **payload) ⇒ Object
Dispatch an auditable moment to the host’s ‘config.audit` hook (no-op by default). Same Event envelope as notify, so a host can point both hooks at the same `case`. Like notify, an audit hook exception is swallowed (turned into a logged warning) so it can never roll back the action it’s recording — audit is observational, never load-bearing.
190 191 192 193 194 195 196 197 |
# File 'lib/moderate.rb', line 190 def audit(event_or_name = nil, **payload) event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload) config.audit.call(event) true rescue => error logger&.warn("[moderate] audit hook failed for #{event&.name}: #{error.class}: #{error.}") false end |
.blocked_ids_for(user) ⇒ Object
The single source-of-truth list of user ids “related to” ‘user` via a block edge — i.e. everyone this user has blocked AND everyone who has blocked them (the edge is bidirectional; once either side blocks, neither should see the other). The host enforces blocking everywhere with one query:
Post.where.not(user_id: Moderate.blocked_ids_for(current_user))
Delegates to Moderate::Block (the model that owns the block SQL) so the join logic lives in exactly one place. Returns an empty array for a blank user.
236 237 238 239 240 |
# File 'lib/moderate.rb', line 236 def blocked_ids_for(user) return [] if user.nil? Block.(user) end |
.classify(value, policy: nil) ⇒ Object
Classify a value (text or image) and return a Moderate::Result.
Moderate.classify("some sketchy text") # uses the default adapter
Moderate.classify(value, policy: some_policy) # uses the policy's adapter
Adapter selection precedence:
1. the adapter named on the passed `policy` (per-field config / `moderates`)
2. the global `config.filter_adapter` default
The adapter contract is the whole point of the gem’s filtering design: ANY object responding to ‘classify(value) → Moderate::Result` is a valid adapter, so the built-in wordlist/image backends and a host’s registered remote classifier are perfectly interchangeable. We tolerate an adapter that returns a plain Hash (a common simpler shape) by funneling it through Result.new, so older/simpler adapters keep working.
259 260 261 262 263 264 265 266 267 |
# File 'lib/moderate.rb', line 259 def classify(value, policy: nil) adapter_name = policy&.adapter || config.filter_adapter adapter = config.adapter_for(adapter_name) raise ConfigurationError, "no filter adapter registered for #{adapter_name.inspect}" if adapter.nil? raw = adapter.classify(value) coerce_result(raw, source: adapter_name) end |
.config ⇒ Object Also known as: configuration
The singleton Configuration. Lazily built so merely requiring the gem (before any initializer runs) yields a fully-defaulted, usable config.
52 53 54 |
# File 'lib/moderate.rb', line 52 def config @config ||= Configuration.new end |
.configure {|config| ... } ⇒ Object
The host’s entry point: ‘Moderate.configure do |config| … end`.
We ‘yield` the live config object (so every assignment lands on the singleton) and then run `validate!` ONCE at the end — this is the documented behavior in docs/configuration.md: “The block is validated at the end of `configure`, so a typo’d mode or unknown adapter raises a plain-English ArgumentError immediately instead of failing mysteriously later.” Per-setter checks already fired on assignment; this final pass catches cross-field problems (e.g. a :block filter on an async adapter).
70 71 72 73 74 |
# File 'lib/moderate.rb', line 70 def configure yield config if block_given? config.validate! config end |
.filter_policy_for(record_or_class, field) ⇒ Object
Resolve the FilterPolicy for a given record/class + field, walking the ancestor chain so a policy declared on a base/STI parent applies to subclasses (this is why a marketplace’s ‘Listing` policy covers `Listing::Featured`, etc. — host-agnostically). Falls back to an `:off` policy when nothing is declared, so callers can treat “no policy” and “:off” identically.
274 275 276 277 278 279 280 281 282 283 284 285 286 287 |
# File 'lib/moderate.rb', line 274 def filter_policy_for(record_or_class, field) klass = record_or_class.is_a?(Class) ? record_or_class : record_or_class.class field_s = field.to_s policy = klass.ancestors.filter_map do |ancestor| next unless ancestor.respond_to?(:name) && ancestor.name config.filters[[ancestor.name, field_s]] end.first policy || Configuration::FilterPolicy.new( class_name: klass.name, field: field_s, adapter: config.filter_adapter, mode: :off ) end |
.locale ⇒ Object
The locale for copy the gem generates itself, falling back to the app’s default. Read lazily so a host setting I18n.default_locale after our boot still wins.
299 300 301 |
# File 'lib/moderate.rb', line 299 def locale config.locale || (defined?(I18n) ? I18n.default_locale : :en) end |
.notify(event_or_name, **payload) ⇒ Object
Dispatch a notifiable moment to the host’s ‘config.notify` hook.
Accepts either a ready-made Moderate::Event or an event NAME plus payload —the gem’s services mostly call ‘Moderate.notify(:report_received, subject:, recipients:, …)`, but a pre-built Event is accepted too. We always hand the hook a Moderate::Event so the host’s single ‘case event.name` works uniformly.
RETURNS a “delivered” boolean. This exists specifically for legal-email gating: DSA Art. 16(4) requires a confirmation of receipt for a notice, and the notice flow needs to know whether the confirmation actually went out so it can fall back (e.g. show an on-screen receipt) if the host hasn’t wired a mailer. “Delivered” means the hook ran without raising and returned a truthy value — the default no-op hook returns nil ⇒ false, which correctly signals “nothing was sent.”
We never let a host hook’s exception bubble into a moderation action (a slow or broken mailer must not roll back a decision). On error we audit the failure and return false.
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/moderate.rb', line 162 def notify(event_or_name, **payload) event = event_or_name.is_a?(Event) ? event_or_name : Event.new(name: event_or_name, **payload) begin result = config.notify.call(event) # A lambda may legitimately return a delivery handle, a job, true, etc. # Anything truthy counts as delivered; nil/false counts as not-delivered. result ? true : false rescue => error audit( name: :notify_failed, subject: event.subject, payload: { event: event.name, error_class: error.class.name, error_message: error., summary: "notify hook failed for #{event.name}: #{error.class}" } ) false end end |
.register_adapter(name, adapter) ⇒ Object
Register a filter adapter at runtime (the facade twin of ‘config.register_adapter`, so a host can call either `Moderate.register_adapter(…)` or `config.register_adapter(…)`).
292 293 294 |
# File 'lib/moderate.rb', line 292 def register_adapter(name, adapter) config.register_adapter(name, adapter) end |
.register_reportable(klass) ⇒ Object
Auto-discovered set of classes that declared themselves reportable (via the ‘has_reportable_content` macro or `include Moderate::Reportable`). The Reportable concern calls `Moderate.register_reportable(self)` on inclusion, so there’s NO manual registry to maintain — exactly what the README promises (“Reportable classes are auto-discovered from the ‘has_reportable_content` macro — no manual registry.”).
Stored as a Set of STRING class names (not Class objects) so we never pin a class in memory across a Zeitwerk reload in development; we constantize on read.
123 124 125 126 127 128 129 |
# File 'lib/moderate.rb', line 123 def register_reportable(klass) name = klass.is_a?(Class) ? klass.name : klass.to_s return if name.nil? || name.empty? reportable_registry << name name end |
.reportable_classes ⇒ Object
The reportable classes, constantized on demand. We rescue a NameError per entry so a class that was registered then removed (a dev-time edit) doesn’t break the whole list.
134 135 136 137 138 139 140 |
# File 'lib/moderate.rb', line 134 def reportable_classes reportable_registry.filter_map do |name| name.constantize rescue NameError nil end end |
.reset! ⇒ Object
Reset to a pristine, fully-defaulted Configuration. The primary consumer is the test suite (‘Moderate.reset!` between cases); it’s documented as part of the public API. We also drop the cached user-class constant so a test that swaps ‘config.user_class` doesn’t see a stale lazily-memoized class.
IMPORTANT: we do NOT clear the reportable REGISTRY here. Reportable classes are discovered once, at MODEL LOAD time (the ‘has_reportable_content` macro / `include Moderate::Reportable` runs `Moderate.register_reportable(self)` on inclusion). In a booted app (and the eager-loaded test suite) the models load exactly once, so wiping the registry on every `reset!` would leave `Moderate.reportable_classes` permanently empty after the first reset — the macros would never re-run to repopulate it. The registry is a load-time FACT, not configuration, so it correctly survives a config reset.
89 90 91 92 93 |
# File 'lib/moderate.rb', line 89 def reset! @config = Configuration.new @user_class = nil self end |
.run_on_block(blocker:, blocked:, at:) ⇒ Object
Run the optional ‘on_block` side-effect hook (cancel a pending invite, leave a shared room, …). Keyword-arg signature per docs/configuration.md. `at:` is the block row’s creation time so hosts can apply time-aware teardown without reaching back into the database. Kept as a facade method so Moderate::Block has one call site and doesn’t reach into config internals. No-op by default.
204 205 206 |
# File 'lib/moderate.rb', line 204 def run_on_block(blocker:, blocked:, at:) config.on_block.call(blocker: blocker, blocked: blocked, at: at) end |
.transparency(from: nil, to: nil) ⇒ Object
DSA Art. 24 transparency aggregation for a period — the numbers a host publishes (notices received by intake/ground, actions taken, automated-means usage, appeal outcomes, median handling times). This is the queryable building block: the public ‘/transparency` page (off by default — see `config.transparency_report_enabled`) renders this, and a host that keeps the page off can still call this to publish its own report in its own format.
309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 |
# File 'lib/moderate.rb', line 309 def transparency(from: nil, to: nil) to ||= Time.respond_to?(:current) ? Time.current : Time.now from ||= to - (365 * 24 * 60 * 60) reports = Moderate::Report.where(created_at: from..to) appeals = Moderate::Appeal.where(created_at: from..to) flags = Moderate::Flag.where(created_at: from..to) { period: { from: from, to: to }, notices_by_intake: reports.group(:intake_kind).count, dsa_notices_by_legal_reason: reports.where(intake_kind: "dsa").group(:legal_reason).count, actions_by_basis: reports.where.not(resolved_at: nil).group(:resolution_basis).count, automated_flags_by_source: flags.group(:source).count, appeals_by_status: appeals.group(:status).count, median_notice_action_seconds: transparency_median(reports.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)), median_appeal_action_seconds: transparency_median(appeals.where.not(resolved_at: nil).pluck(:created_at, :resolved_at)) } end |
.user_class ⇒ Object
The actor model (who reports/blocks/gets reported/gets banned), resolved by constantizing ‘config.user_class` LAZILY on first use. Lazy on purpose: the initializer that sets `config.user_class = “User”` runs before the User model is necessarily loaded, so we must not constantize at configure time.
Memoized, but cleared by ‘reset!` and re-derived if the configured name changes (guards against a stale constant in long-lived processes/tests).
104 105 106 107 108 109 110 111 |
# File 'lib/moderate.rb', line 104 def user_class name = config.user_class if @user_class.nil? || @user_class_name != name @user_class = name.constantize @user_class_name = name end @user_class end |