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,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The macros lazily mix the gem's concerns into a host model.
|
|
4
|
+
#
|
|
5
|
+
# We DON'T `require_relative` the concern files here, even though this file is
|
|
6
|
+
# itself require_relative'd by the spine (lib/moderate.rb) at gem-load time. The
|
|
7
|
+
# concerns (Moderate::Actor / Reportable / ContentFilterable) live under
|
|
8
|
+
# `lib/moderate/models/concerns/` and are AUTOLOADED by Zeitwerk (the engine
|
|
9
|
+
# `push_dir`s that subtree under the `Moderate` namespace with the `models` and
|
|
10
|
+
# `concerns` dirs collapsed). Requiring them here too would double-manage the same
|
|
11
|
+
# constants and make Zeitwerk raise on its eager-load pass ("already defined").
|
|
12
|
+
#
|
|
13
|
+
# This is safe because the macro methods below only REFERENCE the concern constants
|
|
14
|
+
# inside their bodies (`include Moderate::Actor`), which run when a host model calls
|
|
15
|
+
# `has_reporting_and_blocking`/`has_reportable_content`/`moderates` — long after
|
|
16
|
+
# boot, when the autoloader is fully wired. So Zeitwerk autoloads each concern
|
|
17
|
+
# lazily on first use.
|
|
18
|
+
module Moderate
|
|
19
|
+
# The class-level DSL the gem adds to every ActiveRecord model.
|
|
20
|
+
#
|
|
21
|
+
# The engine does `ActiveSupport.on_load(:active_record) { extend Moderate::Macros }`,
|
|
22
|
+
# so `has_reporting_and_blocking`, `has_reportable_content`, and `moderates` become
|
|
23
|
+
# class methods on ActiveRecord::Base — readable plain-English declarations that
|
|
24
|
+
# sit alongside the rest of a host's stack (`has_credits`, `has_wallets`, `has_api_keys`):
|
|
25
|
+
#
|
|
26
|
+
# class User < ApplicationRecord; has_reporting_and_blocking; end
|
|
27
|
+
# class Listing < ApplicationRecord; has_reportable_content :title, :description; end
|
|
28
|
+
# class Message < ApplicationRecord; moderates :body, mode: :flag; end
|
|
29
|
+
#
|
|
30
|
+
# Each macro is exact sugar for an `include` + a declaration — the README
|
|
31
|
+
# documents both forms as equivalent. The macros are deliberately thin: they
|
|
32
|
+
# lazily include the right concern (only models that opt in pay for it) and then
|
|
33
|
+
# forward to that concern's declaration method. All behavior lives in the
|
|
34
|
+
# concerns, never here.
|
|
35
|
+
module Macros
|
|
36
|
+
# `has_reporting_and_blocking` — make this model an ACTOR in the Trust & Safety
|
|
37
|
+
# system: it can report content/users and block/unblock other users
|
|
38
|
+
# (report!/block!/unblock!/blocks?/blocked_by?/blocked_with?, plus the block &
|
|
39
|
+
# report associations), and — since the actor is usually itself reportable and is
|
|
40
|
+
# the be-banned target — it's also made reportable. One macro, both halves.
|
|
41
|
+
#
|
|
42
|
+
# Equivalent to `include Moderate::Actor`. Idempotent: re-declaring (or both
|
|
43
|
+
# macro + explicit include) won't double-include.
|
|
44
|
+
def has_reporting_and_blocking
|
|
45
|
+
include Moderate::Actor unless include?(Moderate::Actor)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# `has_reportable_content(*fields)` — mark this model as REPORTABLE content,
|
|
49
|
+
# optionally narrowing to specific fields. Bare `has_reportable_content` (no
|
|
50
|
+
# fields) means "the whole record is reportable" (the field whitelist stays
|
|
51
|
+
# empty, and a blank reported_field is then allowed — see
|
|
52
|
+
# Reportable#reportable_field_allowed?).
|
|
53
|
+
#
|
|
54
|
+
# Equivalent to `include Moderate::Reportable` + `reportable_fields(*fields)`.
|
|
55
|
+
def has_reportable_content(*fields)
|
|
56
|
+
include Moderate::Reportable unless include?(Moderate::Reportable)
|
|
57
|
+
reportable_fields(*fields) if fields.any?
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# `moderates(*fields, mode:, with:)` — filter one or more fields before they're
|
|
62
|
+
# saved. `mode:`/`with:` default to nil so the field inherits the global
|
|
63
|
+
# `config.default_filter_mode` / `config.filter_adapter` (resolved inside
|
|
64
|
+
# `config.filter`), letting a bare `moderates :body` Just Work.
|
|
65
|
+
#
|
|
66
|
+
# Two things happen, per field:
|
|
67
|
+
# 1. The field is registered on the model (Moderate::ContentFilterable), so
|
|
68
|
+
# the :block validation and :flag after_commit hook run for it.
|
|
69
|
+
# 2. A Configuration FilterPolicy is recorded keyed by [class_name, field],
|
|
70
|
+
# so `Moderate.filter_policy_for` (which the concern consults at
|
|
71
|
+
# validate/commit time, walking the ancestor chain) can find the field's
|
|
72
|
+
# adapter + mode. This is the exact twin of `config.filter "Class", :field,
|
|
73
|
+
# with:, mode:` in the initializer — same storage, same resolution.
|
|
74
|
+
#
|
|
75
|
+
# Equivalent to `include Moderate::ContentFilterable` + `moderates_fields(*fields)`
|
|
76
|
+
# plus the per-field policy registration.
|
|
77
|
+
def moderates(*fields, mode: nil, with: nil)
|
|
78
|
+
include Moderate::ContentFilterable unless include?(Moderate::ContentFilterable)
|
|
79
|
+
moderates_fields(*fields)
|
|
80
|
+
|
|
81
|
+
fields.each do |field|
|
|
82
|
+
# `config.filter` normalizes/validates the adapter+mode and stores the
|
|
83
|
+
# policy; passing `self` (the class) lets it record the class NAME string.
|
|
84
|
+
Moderate.config.filter(self, field, with: with, mode: mode)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# A DSA Art. 20 internal complaint against a moderation decision.
|
|
5
|
+
#
|
|
6
|
+
# The Digital Services Act requires platforms to give affected users an
|
|
7
|
+
# "effective internal complaint-handling system" that is FREE, ELECTRONIC, open
|
|
8
|
+
# for AT LEAST SIX MONTHS after the decision, and decided by a HUMAN (not "solely
|
|
9
|
+
# on the basis of automated means"). This model encodes each of those:
|
|
10
|
+
# - free + electronic: it's just a record + a queue (no payment path exists);
|
|
11
|
+
# - six-month window: an appeal is refused once the report's appeal window has
|
|
12
|
+
# closed (`report_must_still_be_appealable`);
|
|
13
|
+
# - human-decided: there is no auto-decide path — uphold!/reject! (in a service)
|
|
14
|
+
# require a moderator and a note;
|
|
15
|
+
# - against a DECISION: an appeal can only be opened on an already-resolved
|
|
16
|
+
# report (`report_must_be_closed`).
|
|
17
|
+
# Source: https://eur-lex.europa.eu/eli/reg/2022/2065/oj (Art. 20)
|
|
18
|
+
class Appeal < ApplicationRecord
|
|
19
|
+
self.table_name = "moderate_appeals"
|
|
20
|
+
|
|
21
|
+
# Validated by the `validates :status, inclusion` below — NOT a DB constraint, so
|
|
22
|
+
# the vocabulary can grow without a host migration. `upheld` overturns the original
|
|
23
|
+
# decision; `rejected` confirms it.
|
|
24
|
+
STATUSES = %w[open upheld rejected].freeze
|
|
25
|
+
|
|
26
|
+
# Who lodged the complaint. `notifier` = the person who filed the original
|
|
27
|
+
# notice; `affected_user` = the content owner whose content was actioned; the
|
|
28
|
+
# rest are operational. Validated by the model (inclusion), not a DB constraint.
|
|
29
|
+
SOURCES = %w[notifier affected_user admin other].freeze
|
|
30
|
+
|
|
31
|
+
belongs_to :report, class_name: "Moderate::Report"
|
|
32
|
+
# Appellant may be a logged-in user OR an emailed notifier (public DSA notices
|
|
33
|
+
# come from non-users), so it's optional and we also carry name/email columns.
|
|
34
|
+
belongs_to :appellant, class_name: Moderate.config.user_class, optional: true
|
|
35
|
+
belongs_to :resolved_by, class_name: Moderate.config.user_class, optional: true
|
|
36
|
+
|
|
37
|
+
before_validation :normalize_strings
|
|
38
|
+
before_validation :hydrate_appellant_contact, if: :appellant
|
|
39
|
+
before_validation :capture_snapshot, on: :create
|
|
40
|
+
# Default the JSON `snapshot` to {} before save. The migration makes it NOT NULL,
|
|
41
|
+
# but MySQL 8+ forbids a DEFAULT on a JSON column, so without this an appeal whose
|
|
42
|
+
# `capture_snapshot` produced an empty hash that got dropped (or any direct build)
|
|
43
|
+
# could write NULL and trip a NotNullViolation. (SQLite/PostgreSQL get the {}
|
|
44
|
+
# default from the migration; coalescing is harmless there.)
|
|
45
|
+
before_save :default_json_columns
|
|
46
|
+
|
|
47
|
+
scope :open, -> { where(status: "open") }
|
|
48
|
+
scope :upheld, -> { where(status: "upheld") }
|
|
49
|
+
scope :rejected, -> { where(status: "rejected") }
|
|
50
|
+
# `pending` mirrors `open`, named for the queue's vocabulary (an appeal awaiting
|
|
51
|
+
# a human decision) — `Moderate::Appeal.pending` is the documented admin scope.
|
|
52
|
+
scope :pending, -> { where(status: "open") }
|
|
53
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
54
|
+
|
|
55
|
+
validates :status, inclusion: { in: STATUSES }
|
|
56
|
+
validates :source, inclusion: { in: SOURCES }
|
|
57
|
+
# Reuse the Report's message length cap so a complaint and a notice share one
|
|
58
|
+
# limit (a model-level length validation; `reason` carries no DB constraint).
|
|
59
|
+
validates :reason, presence: true, length: { maximum: Report::MESSAGE_MAX_LENGTH }
|
|
60
|
+
# The complainant must be reachable to receive the appeal decision (Art. 20
|
|
61
|
+
# requires informing them of the outcome), so an email is mandatory here even
|
|
62
|
+
# though the original notice's may not have been.
|
|
63
|
+
validates :appellant_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
64
|
+
validates :resolution_note, presence: true, if: :closed?
|
|
65
|
+
validate :report_must_be_closed
|
|
66
|
+
validate :report_must_still_be_appealable
|
|
67
|
+
|
|
68
|
+
def open?
|
|
69
|
+
status == "open"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def closed?
|
|
73
|
+
status.in?(%w[upheld rejected])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def upheld?
|
|
77
|
+
status == "upheld"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def rejected?
|
|
81
|
+
status == "rejected"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Decide this appeal — model-level sugar over Moderate::Services::ResolveAppeal,
|
|
85
|
+
# so a host can write `appeal.uphold!(by: moderator, note: "…")` /
|
|
86
|
+
# `appeal.reject!(by: moderator, note: "…")` instead of constructing the service.
|
|
87
|
+
# The service does the real work (audit, the appeal-decision notification, and —
|
|
88
|
+
# for an upheld appeal — reversing the original decision); these only forward.
|
|
89
|
+
def uphold!(by:, note:)
|
|
90
|
+
Moderate::Services::ResolveAppeal.new(self, by: by).uphold!(note: note)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def reject!(by:, note:)
|
|
94
|
+
Moderate::Services::ResolveAppeal.new(self, by: by).reject!(note: note)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# See the before_save comment: keep the NOT-NULL JSON `snapshot` non-null on MySQL.
|
|
100
|
+
def default_json_columns
|
|
101
|
+
self.snapshot ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_strings
|
|
105
|
+
self.status = status.to_s.squish.presence || "open"
|
|
106
|
+
self.source = source.to_s.squish.presence || "notifier"
|
|
107
|
+
self.appellant_name = appellant_name.to_s.squish.presence
|
|
108
|
+
self.appellant_email = appellant_email.to_s.squish.presence&.downcase
|
|
109
|
+
self.reason = reason.to_s.gsub(/\r\n?/, "\n").strip.presence
|
|
110
|
+
self.resolution_note = resolution_note.to_s.strip.presence
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Carry the logged-in appellant's contact onto the appeal so the decision can be
|
|
114
|
+
# delivered the same way whether the appellant is a user or an emailed notifier.
|
|
115
|
+
# Read via `try` so the host's user class only needs whichever attribute it has.
|
|
116
|
+
def hydrate_appellant_contact
|
|
117
|
+
self.appellant_email ||= appellant.try(:email)
|
|
118
|
+
self.appellant_name ||= appellant.try(:display_name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Snapshot the DECISION being appealed at the moment the appeal is filed, so the
|
|
122
|
+
# complaint is anchored to exactly what was decided even if the report is later
|
|
123
|
+
# re-resolved. Mirrors the Report's evidence-snapshot philosophy.
|
|
124
|
+
def capture_snapshot
|
|
125
|
+
self.snapshot = {
|
|
126
|
+
report_id: report_id,
|
|
127
|
+
report_status: report&.status,
|
|
128
|
+
report_resolution_basis: report&.resolution_basis,
|
|
129
|
+
report_resolution_actions: report&.resolution_actions,
|
|
130
|
+
report_resolved_at: report&.resolved_at&.iso8601,
|
|
131
|
+
captured_at: Time.current.iso8601
|
|
132
|
+
}.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# You can only appeal a DECISION — an appeal presupposes the report was resolved.
|
|
136
|
+
# We key off `resolved_at` (set when a moderator acts) rather than `status` so a
|
|
137
|
+
# report reopened for any reason can't be appealed in limbo.
|
|
138
|
+
def report_must_be_closed
|
|
139
|
+
return if report&.resolved_at.present?
|
|
140
|
+
|
|
141
|
+
errors.add(:report, I18n.t("moderate.errors.appeal_report_must_be_closed", default: "decision can only be appealed after it is made"))
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Enforce the DSA Art. 20 six-month window: refuse appeals filed after the
|
|
145
|
+
# report's `appeal_deadline_at` (stamped to decision-time + APPEAL_WINDOW). If no
|
|
146
|
+
# deadline was stamped yet, we don't block — the window simply hasn't been opened.
|
|
147
|
+
def report_must_still_be_appealable
|
|
148
|
+
return if report.blank? || report.appeal_deadline_at.blank?
|
|
149
|
+
return if Time.current <= report.appeal_deadline_at
|
|
150
|
+
|
|
151
|
+
errors.add(:report, I18n.t("moderate.errors.appeal_window_expired", default: "the appeal window for this decision has closed"))
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# The abstract base class every model the gem ships inherits from
|
|
5
|
+
# (Moderate::Report / Block / Flag / Appeal). It plays the exact role an engine's
|
|
6
|
+
# `app/models/<engine>/application_record.rb` normally plays:
|
|
7
|
+
#
|
|
8
|
+
# - It is `abstract_class`, so it never maps to a table of its own; it only
|
|
9
|
+
# exists to give the gem's records a single, gem-owned ancestor.
|
|
10
|
+
#
|
|
11
|
+
# - It inherits from the HOST's `::ActiveRecord::Base`, NOT from the host's
|
|
12
|
+
# `::ApplicationRecord`. That matters: the gem must not pick up host
|
|
13
|
+
# concerns/defaults bolted onto the app's ApplicationRecord (multitenancy
|
|
14
|
+
# scopes, default associations, etc.) — its tables are infrastructure, owned
|
|
15
|
+
# by the gem, and should behave identically in every host. (This is why the
|
|
16
|
+
# dummy's own `ApplicationRecord` doc note says the gem's models inherit from
|
|
17
|
+
# `Moderate::ApplicationRecord`, not from the host base.)
|
|
18
|
+
#
|
|
19
|
+
# WHY this class has to exist as a separate file (and isn't just `ActiveRecord::Base`
|
|
20
|
+
# inline in each model): the gem's models are written as `class Report <
|
|
21
|
+
# ApplicationRecord` *inside* `module Moderate`. Ruby constant lookup resolves the
|
|
22
|
+
# bare `ApplicationRecord` to `Moderate::ApplicationRecord` first (the lexically
|
|
23
|
+
# enclosing namespace), and Zeitwerk autoloads it from this file. Defining it here
|
|
24
|
+
# — rather than letting the lookup fall through to the host's top-level
|
|
25
|
+
# `::ApplicationRecord` — keeps the gem's base independent of the host's, which is
|
|
26
|
+
# the whole point. The reference to `::ActiveRecord::Base` is fully qualified so it
|
|
27
|
+
# can never be re-bound to a `Moderate::ActiveRecord` by the same lookup quirk.
|
|
28
|
+
class ApplicationRecord < ::ActiveRecord::Base
|
|
29
|
+
self.abstract_class = true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# The bidirectional safety edge between two users — the table behind every
|
|
5
|
+
# "block" feature and behind `Moderate.blocked_ids_for`.
|
|
6
|
+
#
|
|
7
|
+
# A block is DIRECTED in the data (`blocker` → `blocked`) but BIDIRECTIONAL in
|
|
8
|
+
# effect: once either side blocks, neither should see or reach the other. That
|
|
9
|
+
# asymmetry-in-storage / symmetry-in-meaning is the whole subtlety of blocking,
|
|
10
|
+
# and it lives in exactly one place — the `related_user_ids` SSOT query below —
|
|
11
|
+
# so search, messaging, profiles, and feeds never hand-roll their own block SQL
|
|
12
|
+
# (and never get the direction half-right, which is the classic blocking bug).
|
|
13
|
+
#
|
|
14
|
+
# Apple Guideline 1.2(c) and Google Play's UGC policy both require an in-app way
|
|
15
|
+
# to block abusive users; this model is the mechanism.
|
|
16
|
+
# Apple: https://developer.apple.com/app-store/review/guidelines/#user-generated-content
|
|
17
|
+
# Google: https://support.google.com/googleplay/android-developer/answer/9876937
|
|
18
|
+
class Block < ApplicationRecord
|
|
19
|
+
self.table_name = "moderate_blocks"
|
|
20
|
+
|
|
21
|
+
# Both ends resolve to the host's configured user class (read lazily as a String
|
|
22
|
+
# from config, same pattern as every other actor association in the gem). NOT
|
|
23
|
+
# optional: a block edge with a missing end is meaningless.
|
|
24
|
+
belongs_to :blocker, class_name: Moderate.config.user_class
|
|
25
|
+
belongs_to :blocked, class_name: Moderate.config.user_class
|
|
26
|
+
|
|
27
|
+
# A user can block another user at most once. The DB also enforces this with a
|
|
28
|
+
# unique index (`index_moderate_blocks_on_blocker_id_and_blocked_id`); the model
|
|
29
|
+
# validation gives a friendly error instead of a raw RecordNotUnique, and the
|
|
30
|
+
# `block!` entrypoint uses find_or_initialize to stay idempotent regardless.
|
|
31
|
+
validates :blocked_id, uniqueness: { scope: :blocker_id }
|
|
32
|
+
validate :cannot_block_self
|
|
33
|
+
|
|
34
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
35
|
+
|
|
36
|
+
# Find the edge between two users in EITHER direction (used to answer
|
|
37
|
+
# "blocked_with?" — is there a block between us, regardless of who blocked whom).
|
|
38
|
+
scope :between, ->(user_a, user_b) {
|
|
39
|
+
return none if user_a.blank? || user_b.blank?
|
|
40
|
+
|
|
41
|
+
where(blocker_id: user_a.id, blocked_id: user_b.id)
|
|
42
|
+
.or(where(blocker_id: user_b.id, blocked_id: user_a.id))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Idempotent block. Creating the same edge twice is a no-op that returns the
|
|
46
|
+
# existing record, so callers never have to check first. Everything runs in one
|
|
47
|
+
# transaction so the audit entry and the on_block side effect commit atomically
|
|
48
|
+
# with the row; the notify fires AFTER the transaction so a slow/failing mailer
|
|
49
|
+
# can't roll back the block itself.
|
|
50
|
+
#
|
|
51
|
+
# On a real (new) block we:
|
|
52
|
+
# 1. fire the host's `on_block` side-effect hook (e.g. tear down a pending
|
|
53
|
+
# invite, leave a shared room) — host-defined, no-op by default;
|
|
54
|
+
# 2. audit "block created" with both ids;
|
|
55
|
+
# 3. notify `:user_blocked`.
|
|
56
|
+
# Re-blocking an existing edge skips all three (nothing actually changed).
|
|
57
|
+
def self.block!(blocker:, blocked:)
|
|
58
|
+
raise ArgumentError, "blocker is required" if blocker.blank?
|
|
59
|
+
raise ArgumentError, "blocked is required" if blocked.blank?
|
|
60
|
+
|
|
61
|
+
# Self-block is a NO-OP, not an exception. Blocking yourself is meaningless, and
|
|
62
|
+
# a UI that wires a generic "block this user" affordance shouldn't have to guard
|
|
63
|
+
# the degenerate "block myself" case — so we return an UNPERSISTED, invalid
|
|
64
|
+
# record (carrying the `cannot_block_self` validation error for inspection)
|
|
65
|
+
# rather than raising. No row is written, no on_block/audit/notify fires. (The
|
|
66
|
+
# DB CHECK constraint `moderate_blocks_no_self_block` is the belt-and-suspenders
|
|
67
|
+
# backstop if a caller bypasses this path.)
|
|
68
|
+
if blocker.id.present? && blocker.id == blocked.id
|
|
69
|
+
block = new(blocker: blocker, blocked: blocked)
|
|
70
|
+
block.valid? # populate errors[:blocked] with the self-block message
|
|
71
|
+
return block
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
block = nil
|
|
75
|
+
created = false
|
|
76
|
+
|
|
77
|
+
transaction do
|
|
78
|
+
block = find_or_initialize_by(blocker: blocker, blocked: blocked)
|
|
79
|
+
created = block.new_record?
|
|
80
|
+
block.save! if created
|
|
81
|
+
|
|
82
|
+
if created
|
|
83
|
+
# Side-effect hook FIRST, inside the transaction: if the host's on_block
|
|
84
|
+
# raises, the whole block rolls back rather than leaving a half-applied
|
|
85
|
+
# state (edge saved but invite not torn down).
|
|
86
|
+
on_block_payload = audit_payload_from_on_block(
|
|
87
|
+
Moderate.run_on_block(blocker: blocker, blocked: blocked, at: block.created_at)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
Moderate.audit(
|
|
91
|
+
name: :user_blocked,
|
|
92
|
+
subject: block,
|
|
93
|
+
actor: blocker,
|
|
94
|
+
payload: on_block_payload.merge(
|
|
95
|
+
blocker_id: blocker.id,
|
|
96
|
+
blocked_id: blocked.id,
|
|
97
|
+
summary: "user #{blocker.id} blocked user #{blocked.id}"
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Notify outside the transaction (see class doc): notify must never be able to
|
|
104
|
+
# roll back the action it's announcing.
|
|
105
|
+
if created
|
|
106
|
+
Moderate.notify(
|
|
107
|
+
:user_blocked,
|
|
108
|
+
subject: block,
|
|
109
|
+
actor: blocker,
|
|
110
|
+
payload: { blocker_id: blocker.id, blocked_id: blocked.id, summary: "user #{blocker.id} blocked user #{blocked.id}" }
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
block
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Remove a block. Returns false if there was nothing to remove (already
|
|
118
|
+
# unblocked), true otherwise — so it's safe to call unconditionally.
|
|
119
|
+
def self.unblock!(blocker:, blocked:)
|
|
120
|
+
block = find_by(blocker: blocker, blocked: blocked)
|
|
121
|
+
return false if block.blank?
|
|
122
|
+
|
|
123
|
+
transaction do
|
|
124
|
+
block.destroy!
|
|
125
|
+
Moderate.audit(
|
|
126
|
+
name: :user_unblocked,
|
|
127
|
+
subject: block,
|
|
128
|
+
actor: blocker,
|
|
129
|
+
payload: {
|
|
130
|
+
blocker_id: blocker.id,
|
|
131
|
+
blocked_id: blocked.id,
|
|
132
|
+
summary: "user #{blocker.id} unblocked user #{blocked.id}"
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Moderate.notify(
|
|
138
|
+
:user_unblocked,
|
|
139
|
+
subject: block,
|
|
140
|
+
actor: blocker,
|
|
141
|
+
payload: { blocker_id: blocker.id, blocked_id: blocked.id, summary: "user #{blocker.id} unblocked user #{blocked.id}" }
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# THE source of truth for "everyone this user is on a block edge with" — both
|
|
148
|
+
# the people they blocked AND the people who blocked them (bidirectional). This
|
|
149
|
+
# is what `Moderate.blocked_ids_for` delegates to, and what hosts use to hide
|
|
150
|
+
# blocked pairs from each other everywhere:
|
|
151
|
+
#
|
|
152
|
+
# Post.where.not(user_id: Moderate.blocked_ids_for(current_user)) # via the facade
|
|
153
|
+
#
|
|
154
|
+
# Returns an AR relation of user ids (NOT a loaded array) so callers can compose
|
|
155
|
+
# it into a `where.not(user_id: ...)` subquery that runs entirely in the database
|
|
156
|
+
# — no N user ids round-tripped into Ruby just to be sent back as a giant IN list.
|
|
157
|
+
#
|
|
158
|
+
# The UNION is the cleanest portable way to express "blocked_id where I'm the
|
|
159
|
+
# blocker, OR blocker_id where I'm the blocked" as a single id set; we build it
|
|
160
|
+
# against the model's OWN `quoted_table_name` (not a hard-coded "moderate_blocks")
|
|
161
|
+
# so a host that renames/prefixes the table still gets correct SQL.
|
|
162
|
+
def self.related_user_ids(user)
|
|
163
|
+
user_model = Moderate.user_class
|
|
164
|
+
return user_model.none.select(:id) if user.blank?
|
|
165
|
+
|
|
166
|
+
user_table = user_model.quoted_table_name
|
|
167
|
+
user_primary_key = connection.quote_column_name(user_model.primary_key)
|
|
168
|
+
block_table = quoted_table_name
|
|
169
|
+
|
|
170
|
+
user_model
|
|
171
|
+
.where(
|
|
172
|
+
"#{user_table}.#{user_primary_key} IN (
|
|
173
|
+
SELECT blocked_id FROM #{block_table} WHERE blocker_id = :user_id
|
|
174
|
+
UNION
|
|
175
|
+
SELECT blocker_id FROM #{block_table} WHERE blocked_id = :user_id
|
|
176
|
+
)",
|
|
177
|
+
user_id: user.id
|
|
178
|
+
)
|
|
179
|
+
.select(:id)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
def self.audit_payload_from_on_block(result)
|
|
185
|
+
return {} if result.nil?
|
|
186
|
+
return result if result.is_a?(Hash)
|
|
187
|
+
return result.to_h if result.respond_to?(:to_h)
|
|
188
|
+
|
|
189
|
+
{}
|
|
190
|
+
rescue ArgumentError, TypeError
|
|
191
|
+
{}
|
|
192
|
+
end
|
|
193
|
+
private_class_method :audit_payload_from_on_block
|
|
194
|
+
|
|
195
|
+
# The DB has a CHECK constraint (`moderate_blocks_no_self_block`) too; this gives
|
|
196
|
+
# the friendly validation error before the row ever reaches the database.
|
|
197
|
+
def cannot_block_self
|
|
198
|
+
return if blocker_id.blank? || blocked_id.blank? || blocker_id != blocked_id
|
|
199
|
+
|
|
200
|
+
errors.add(:blocked, I18n.t("moderate.errors.cannot_block_self", default: "You can't block yourself"))
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Moderate
|
|
4
|
+
# The "person who acts" in the Trust & Safety system: the model that reports
|
|
5
|
+
# other content, blocks other actors, gets reported, gets banned. Backs the
|
|
6
|
+
# `has_reporting_and_blocking` macro (and its documented equivalent,
|
|
7
|
+
# `include Moderate::Actor`).
|
|
8
|
+
#
|
|
9
|
+
# This is the one model the gem treats as the actor/identity, configured via
|
|
10
|
+
# `config.user_class`. There's normally exactly one such model per app ("User",
|
|
11
|
+
# "Account", "Member"), and it is BOTH an actor and itself reportable — Apple
|
|
12
|
+
# 1.2 and Google Play UGC both require reporting and blocking *users*, not just
|
|
13
|
+
# content (docs/compliance.md). So including Actor also includes
|
|
14
|
+
# Moderate::Reportable, giving a user the reportable contract with sensible
|
|
15
|
+
# actor-flavored defaults (a user IS its own `reported_owner`).
|
|
16
|
+
#
|
|
17
|
+
# Everything host-specific (what "banned" means, whether a block tears down a
|
|
18
|
+
# pending invite) is delegated to the configured hooks via the Moderate facade,
|
|
19
|
+
# never hard-coded here — keeping the actor host-agnostic.
|
|
20
|
+
module Actor
|
|
21
|
+
extend ActiveSupport::Concern
|
|
22
|
+
|
|
23
|
+
# A user is also reportable. Pulling Reportable in here means `has_reporting_and_blocking`
|
|
24
|
+
# alone gives you both halves (act AND be-acted-on) without a second macro.
|
|
25
|
+
include Moderate::Reportable
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
# Blocks this actor initiated, and blocks filed against this actor. Two
|
|
29
|
+
# associations on the one Moderate::Block table, distinguished by which
|
|
30
|
+
# foreign key points here. `dependent: :destroy` cleans up the edges if the
|
|
31
|
+
# account is hard-deleted (a soft-delete/ban leaves them, which is correct —
|
|
32
|
+
# the safety edge should outlive a suspension).
|
|
33
|
+
has_many :moderate_initiated_blocks,
|
|
34
|
+
class_name: "Moderate::Block",
|
|
35
|
+
foreign_key: :blocker_id,
|
|
36
|
+
inverse_of: :blocker,
|
|
37
|
+
dependent: :destroy
|
|
38
|
+
has_many :moderate_received_blocks,
|
|
39
|
+
class_name: "Moderate::Block",
|
|
40
|
+
foreign_key: :blocked_id,
|
|
41
|
+
inverse_of: :blocked,
|
|
42
|
+
dependent: :destroy
|
|
43
|
+
|
|
44
|
+
# Reports this actor filed, and reports filed against this actor. `nullify`
|
|
45
|
+
# (not destroy): a report is legal/evidentiary and must survive either party
|
|
46
|
+
# deleting their account — we just detach the foreign key. `moderate_reports`
|
|
47
|
+
# is the README-documented reader for "reports against me / my content."
|
|
48
|
+
has_many :moderate_submitted_reports,
|
|
49
|
+
class_name: "Moderate::Report",
|
|
50
|
+
foreign_key: :reporter_id,
|
|
51
|
+
inverse_of: :reporter,
|
|
52
|
+
dependent: :nullify
|
|
53
|
+
has_many :moderate_reports,
|
|
54
|
+
class_name: "Moderate::Report",
|
|
55
|
+
foreign_key: :reported_user_id,
|
|
56
|
+
inverse_of: :reported_user,
|
|
57
|
+
dependent: :nullify
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# --- Reporting ------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
# File a report from this actor against a piece of content (or another actor —
|
|
63
|
+
# a user with `has_reporting_and_blocking` is itself reportable).
|
|
64
|
+
#
|
|
65
|
+
# current_user.report!(@message, category: :harassment, details: "...")
|
|
66
|
+
# current_user.report!(@other_user, category: :impersonation)
|
|
67
|
+
#
|
|
68
|
+
# We build and persist a Moderate::Report; the Report model owns the rest of
|
|
69
|
+
# the lifecycle the README promises — snapshotting the offending content so
|
|
70
|
+
# evidence survives edits/deletes, inferring the responsible owner, sending the
|
|
71
|
+
# reporter a receipt, and dropping it into the queue. Keeping that logic IN the
|
|
72
|
+
# model (not here) means the public DSA notice intake and this in-app path share
|
|
73
|
+
# one source of truth.
|
|
74
|
+
#
|
|
75
|
+
# `details:` is the README's name for the reporter's free-text reason; it maps
|
|
76
|
+
# onto the Report's `message`. Extra keyword args (e.g. `field:`) pass straight
|
|
77
|
+
# through, so this stays forward-compatible with the Report model's attributes.
|
|
78
|
+
def report!(reportable, category:, details: nil, **attributes)
|
|
79
|
+
attributes[:message] = details if details && !attributes.key?(:message)
|
|
80
|
+
reported_field = attributes.delete(:reported_field)
|
|
81
|
+
field = attributes.delete(:field)
|
|
82
|
+
reported_field ||= field
|
|
83
|
+
|
|
84
|
+
# An in-app reporter attests to good faith IMPLICITLY by choosing to report —
|
|
85
|
+
# there's no separate checkbox in the in-app flow (that's the public DSA notice
|
|
86
|
+
# form's job). The Report model requires `good_faith_confirmed` to be truthy
|
|
87
|
+
# (Art. 16(2)(d)), so we set it here for the community path unless the caller
|
|
88
|
+
# already passed it. (A host that wants an explicit in-app attestation can still
|
|
89
|
+
# override by passing `good_faith_confirmed:` in `attributes`.)
|
|
90
|
+
attributes[:good_faith_confirmed] = true unless attributes.key?(:good_faith_confirmed)
|
|
91
|
+
|
|
92
|
+
report = Moderate::Report.new(
|
|
93
|
+
reporter: self,
|
|
94
|
+
reportable: reportable,
|
|
95
|
+
category: category.to_s,
|
|
96
|
+
intake_kind: "community",
|
|
97
|
+
**attributes
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
intake = Moderate::Services::IntakeReport.new(
|
|
101
|
+
report: report,
|
|
102
|
+
reporter: self,
|
|
103
|
+
reportable: reportable,
|
|
104
|
+
reported_field: reported_field
|
|
105
|
+
)
|
|
106
|
+
return report if intake.save
|
|
107
|
+
|
|
108
|
+
raise ActiveRecord::RecordInvalid, report
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# --- Blocking -------------------------------------------------------------
|
|
112
|
+
#
|
|
113
|
+
# Blocking is a BIDIRECTIONAL safety edge: once either side blocks, neither
|
|
114
|
+
# should see or reach the other. The single source of truth for "who can't see
|
|
115
|
+
# whom" is `Moderate.blocked_ids_for`, which reads BOTH directions — so these
|
|
116
|
+
# predicates expose each direction, and `blocked_with?` is the one you check in
|
|
117
|
+
# features.
|
|
118
|
+
|
|
119
|
+
# Block `other` (idempotent, audited, fires the `on_block` hook). The actual
|
|
120
|
+
# create/audit/notify is owned by Moderate::Block.block! so there's one block
|
|
121
|
+
# write path for the whole gem.
|
|
122
|
+
def block!(other)
|
|
123
|
+
Moderate::Block.block!(blocker: self, blocked: other)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Lift a block this actor placed on `other`. No-op (returns false) if no such
|
|
127
|
+
# block exists.
|
|
128
|
+
def unblock!(other)
|
|
129
|
+
Moderate::Block.unblock!(blocker: self, blocked: other)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Did I block them? (one direction)
|
|
133
|
+
def blocks?(other)
|
|
134
|
+
return false if other.blank?
|
|
135
|
+
|
|
136
|
+
moderate_initiated_blocks.exists?(blocked_id: other.id)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Did they block me? (the other direction)
|
|
140
|
+
def blocked_by?(other)
|
|
141
|
+
return false if other.blank?
|
|
142
|
+
|
|
143
|
+
moderate_received_blocks.exists?(blocker_id: other.id)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Is there a block edge in EITHER direction? This is the predicate to check in
|
|
147
|
+
# product code ("can these two interact?") — blocking is symmetric for
|
|
148
|
+
# visibility/reachability even though only one side pressed the button. A
|
|
149
|
+
# self-check is never "blocked."
|
|
150
|
+
def blocked_with?(other)
|
|
151
|
+
return false if other.blank? || other.id == id
|
|
152
|
+
|
|
153
|
+
blocks?(other) || blocked_by?(other)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# --- Reportable defaults for an actor -------------------------------------
|
|
157
|
+
#
|
|
158
|
+
# A user is reportable; these override Moderate::Reportable's defaults with
|
|
159
|
+
# actor-appropriate behavior. Hosts can override again for richer copy/rules.
|
|
160
|
+
|
|
161
|
+
# A user is responsible for themselves — so a report against a user (e.g. for
|
|
162
|
+
# impersonation) attributes to and notifies that same user.
|
|
163
|
+
def reported_owner
|
|
164
|
+
self
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# You can never report yourself, and a field still has to be reportable. This
|
|
168
|
+
# tightens Reportable's default with the self-report guard, so the
|
|
169
|
+
# `moderate_report_link` helper hides the control on your own profile.
|
|
170
|
+
def report_visible_to?(viewer, field:)
|
|
171
|
+
viewer.present? && viewer.id != id && reportable_field_allowed?(field)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|