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,
sig/moderate.rbs
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_dirs 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.beta2"
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.audithook (no-op by default). -
.blocked_ids_for(user) ⇒ Object
The single source-of-truth list of user ids "related to"
uservia 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
Listingpolicy coversListing::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.notifyhook. -
.register_adapter(name, adapter) ⇒ Object
Register a filter adapter at runtime (the facade twin of
config.register_adapter, so a host can call eitherModerate.register_adapter(...)orconfig.register_adapter(...)). -
.register_reportable(klass) ⇒ Object
Auto-discovered set of classes that declared themselves reportable (via the
has_reportable_contentmacro orinclude 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_blockside-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_classLAZILY 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 |