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