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,36 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 7.2.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "web-console"
11
+ gem "standard"
12
+ gem "rubocop", "~> 1.0"
13
+ gem "rubocop-minitest", "~> 0.35"
14
+ gem "rubocop-performance", "~> 1.0"
15
+ end
16
+
17
+ group :test do
18
+ gem "minitest", "~> 5.0"
19
+ gem "mocha"
20
+ gem "simplecov", require: false
21
+ gem "activejob"
22
+ gem "actionmailer"
23
+ gem "activestorage"
24
+ gem "sqlite3"
25
+ gem "pg"
26
+ gem "mysql2"
27
+ gem "bootsnap", require: false
28
+ gem "puma"
29
+ gem "importmap-rails"
30
+ gem "sprockets-rails"
31
+ gem "stimulus-rails"
32
+ gem "turbo-rails"
33
+ gem "rdoc", ">= 7.0"
34
+ end
35
+
36
+ gemspec path: "../"
@@ -0,0 +1,36 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rake", "~> 13.0"
6
+ gem "rails", "~> 8.1.0"
7
+
8
+ group :development do
9
+ gem "appraisal"
10
+ gem "web-console"
11
+ gem "standard"
12
+ gem "rubocop", "~> 1.0"
13
+ gem "rubocop-minitest", "~> 0.35"
14
+ gem "rubocop-performance", "~> 1.0"
15
+ end
16
+
17
+ group :test do
18
+ gem "minitest", "~> 5.0"
19
+ gem "mocha"
20
+ gem "simplecov", require: false
21
+ gem "activejob"
22
+ gem "actionmailer"
23
+ gem "activestorage"
24
+ gem "sqlite3"
25
+ gem "pg"
26
+ gem "mysql2"
27
+ gem "bootsnap", require: false
28
+ gem "puma"
29
+ gem "importmap-rails"
30
+ gem "sprockets-rails"
31
+ gem "stimulus-rails"
32
+ gem "turbo-rails"
33
+ gem "rdoc", ">= 7.0"
34
+ end
35
+
36
+ gemspec path: "../"
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module Moderate
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+ desc "Install moderate migrations and initializer"
13
+
14
+ def self.next_migration_number(dir)
15
+ ActiveRecord::Generators::Base.next_migration_number(dir)
16
+ end
17
+
18
+ def create_migration_file
19
+ migration_template "create_moderate_tables.rb.erb", File.join(db_migrate_path, "create_moderate_tables.rb")
20
+ end
21
+
22
+ def create_initializer
23
+ template "initializer.rb", "config/initializers/moderate.rb"
24
+ end
25
+
26
+ def display_post_install_message
27
+ say "\n🛡️ The `moderate` gem has been installed.", :green
28
+ say "\nTo complete the setup:"
29
+
30
+ say " 1. Run 'rails db:migrate' to create the moderation tables."
31
+ say " ⚠️ You must run migrations before starting your app!", :yellow
32
+
33
+ say " 2. Tell `moderate` who your users are in config/initializers/moderate.rb:"
34
+ say " config.user_class = \"User\""
35
+
36
+ say " 3. Add the mixins to your models:"
37
+ say " class User < ApplicationRecord"
38
+ say " include Moderate::Actor # can report, block, and be blocked"
39
+ say " end"
40
+ say ""
41
+ say " class Message < ApplicationRecord"
42
+ say " include Moderate::Reportable # can be reported"
43
+ say " moderates :body # and filtered before save"
44
+ say " end"
45
+
46
+ say "\nYou now have reporting, blocking, filtering, and a moderation queue. 🚀\n", :green
47
+ end
48
+
49
+ private
50
+
51
+ def migration_version
52
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ primary_key_type, foreign_key_type = primary_and_foreign_key_types
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # moderate_reports
9
+ #
10
+ # A report/notice + an immutable evidence snapshot + decision metadata + the
11
+ # appeal window. Serves both in-app community reports and public DSA legal
12
+ # notices (distinguished by `intake_kind`).
13
+ # ---------------------------------------------------------------------------
14
+ create_table :moderate_reports, id: primary_key_type do |t|
15
+ # Who reported (a user), and who they reported (a user). Nullable because
16
+ # DSA public notices can come from non-users, and reported content does not
17
+ # always resolve to a single account.
18
+ t.references :reporter, type: foreign_key_type, null: true
19
+ t.references :reported_user, type: foreign_key_type, null: true
20
+
21
+ # The reported content (any Moderate::Reportable model), polymorphic.
22
+ # `index: false` because we declare the polymorphic index explicitly below
23
+ # (`index_moderate_reports_on_reportable`); without this, `t.references` would
24
+ # ALSO auto-create an index on [reportable_type, reportable_id], and the two
25
+ # collide ("index ... already exists") when the migration runs. Suppressing the
26
+ # auto-index leaves exactly the one named index this schema intends.
27
+ t.references :reportable, polymorphic: true, type: foreign_key_type, null: true, index: false
28
+
29
+ # Which field of the reportable was reported (e.g. "description").
30
+ t.string :reported_field
31
+
32
+ # In-app community category vs. DSA legal taxonomy.
33
+ t.string :intake_kind, null: false, default: "community"
34
+ t.string :category, null: false
35
+ t.string :content_type
36
+ t.string :legal_reason
37
+ t.string :legal_country_code
38
+
39
+ # The reporter's substantiated reason.
40
+ t.text :message, null: false
41
+ t.boolean :anonymous, null: false, default: false
42
+ t.boolean :good_faith_confirmed, null: false, default: false
43
+
44
+ # DSA public-notice notifier identity (when not an authenticated user).
45
+ t.string :notifier_name
46
+ t.string :notifier_email
47
+ t.string :reported_account_identifier
48
+ t.string :subject_url
49
+ t.send(json_column_type, :subject_urls, null: false, default: json_array_default)
50
+
51
+ # Immutable evidence snapshot + automated-processing metadata.
52
+ t.send(json_column_type, :snapshot, null: false, default: json_column_default)
53
+ t.send(json_column_type, :automated_processing, null: false, default: json_column_default)
54
+
55
+ # Lifecycle + decision.
56
+ t.string :status, null: false, default: "open"
57
+ t.datetime :acknowledged_at
58
+ t.references :resolved_by, type: foreign_key_type, null: true
59
+ t.datetime :resolved_at
60
+ t.string :resolution_basis
61
+ t.text :resolution_note
62
+ t.send(json_column_type, :resolution_actions, null: false, default: json_column_default)
63
+
64
+ # Statement-of-reasons / appeal window (DSA Art. 17 & 20).
65
+ t.string :decision_visibility
66
+ t.datetime :decision_notified_at
67
+ t.datetime :affected_user_notified_at
68
+ t.datetime :appeal_deadline_at
69
+
70
+ t.timestamps
71
+ end
72
+
73
+ add_index :moderate_reports, [:reportable_type, :reportable_id], name: "index_moderate_reports_on_reportable"
74
+ add_index :moderate_reports, [:reported_user_id, :status], name: "index_moderate_reports_on_reported_user_id_and_status"
75
+ add_index :moderate_reports, [:reporter_id, :created_at], name: "index_moderate_reports_on_reporter_id_and_created_at"
76
+ add_index :moderate_reports, [:status, :created_at], name: "index_moderate_reports_on_status_and_created_at"
77
+ add_index :moderate_reports, [:intake_kind, :created_at], name: "index_moderate_reports_on_intake_kind_and_created_at"
78
+ add_index :moderate_reports, [:legal_reason, :created_at], name: "index_moderate_reports_on_legal_reason_and_created_at"
79
+ add_index :moderate_reports, :notifier_email, name: "index_moderate_reports_on_notifier_email"
80
+ add_index :moderate_reports, :resolved_at, name: "index_moderate_reports_on_resolved_at"
81
+ add_index :moderate_reports, :appeal_deadline_at, name: "index_moderate_reports_on_appeal_deadline_at"
82
+
83
+ # NOTE: the value-list taxonomies (category, intake_kind, status, content_type,
84
+ # legal_reason, legal_country_code, resolution_basis) are validated in the MODELS
85
+ # (frozen constants + ActiveModel inclusion validations), NOT by DB check
86
+ # constraints. That's deliberate: a host must be able to add a community
87
+ # `category` (Report.report_categories via config.report_categories) or have the
88
+ # gem grow its taxonomy WITHOUT shipping a migration to widen a CHECK. So the only
89
+ # constraints here are STRUCTURAL — NOT NULLs (above), FKs, the unique block edge,
90
+ # the self-block CHECK, and this cheap message-length guardrail (a runaway free-text
91
+ # field is a DB-level concern worth keeping even though the model also caps it).
92
+ add_check_constraint :moderate_reports,
93
+ "#{char_length_fn}(message) <= 4000",
94
+ name: "moderate_reports_message_length_check"
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # moderate_blocks
98
+ #
99
+ # The bidirectional blocker/blocked edge, with a self-block check. This is the
100
+ # single source of truth behind Moderate.blocked_ids_for.
101
+ # ---------------------------------------------------------------------------
102
+ create_table :moderate_blocks, id: primary_key_type do |t|
103
+ t.references :blocker, type: foreign_key_type, null: false
104
+ t.references :blocked, type: foreign_key_type, null: false, index: { name: "index_moderate_blocks_on_blocked_id" }
105
+
106
+ t.timestamps
107
+ end
108
+
109
+ add_index :moderate_blocks, [:blocker_id, :blocked_id], unique: true, name: "index_moderate_blocks_on_blocker_id_and_blocked_id"
110
+ add_index :moderate_blocks, :created_at, name: "index_moderate_blocks_on_created_at"
111
+
112
+ add_check_constraint :moderate_blocks,
113
+ "blocker_id <> blocked_id",
114
+ name: "moderate_blocks_no_self_block"
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # moderate_flags
118
+ #
119
+ # System/auto-filter flags (source: text_filter / image_filter /
120
+ # external_classifier / manual). The queue both human admins and ML consumers
121
+ # read via `pending`.
122
+ # ---------------------------------------------------------------------------
123
+ create_table :moderate_flags, id: primary_key_type do |t|
124
+ # The flagged content (any Moderate::Reportable model), polymorphic.
125
+ t.references :flaggable, polymorphic: true, type: foreign_key_type, null: false
126
+ t.string :field, null: false
127
+
128
+ # Who owns the flagged content (a user), inferred from the flaggable.
129
+ t.references :owner, type: foreign_key_type, null: true
130
+
131
+ # Where the flag came from, and what it would do (:flag allows, :block rejects).
132
+ t.string :source, null: false
133
+ t.string :mode, null: false, default: "flag"
134
+
135
+ # Classifier output.
136
+ t.send(json_column_type, :categories, null: false, default: json_array_default)
137
+ t.send(json_column_type, :scores, null: false, default: json_column_default)
138
+ t.send(json_column_type, :context, null: false, default: json_column_default)
139
+ t.text :excerpt
140
+
141
+ # Lifecycle + decision.
142
+ t.string :status, null: false, default: "pending"
143
+ t.references :reviewed_by, type: foreign_key_type, null: true
144
+ t.datetime :reviewed_at
145
+ t.text :resolution_note
146
+
147
+ t.timestamps
148
+ end
149
+
150
+ add_index :moderate_flags, [:flaggable_type, :flaggable_id, :field], name: "index_moderate_flags_on_target_and_field"
151
+ add_index :moderate_flags, [:owner_id, :status], name: "index_moderate_flags_on_owner_id_and_status"
152
+ add_index :moderate_flags, [:status, :created_at], name: "index_moderate_flags_on_status_and_created_at"
153
+
154
+ # Flag's mode/source/status vocabularies are validated in Moderate::Flag
155
+ # (constants + inclusion validations), not by DB CHECK constraints — same
156
+ # migration-free-taxonomy rationale as moderate_reports above.
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # moderate_appeals
160
+ #
161
+ # DSA Art. 20 internal complaints against a decision. Free, electronic, open
162
+ # for at least 6 months, and decided by a human.
163
+ # ---------------------------------------------------------------------------
164
+ create_table :moderate_appeals, id: primary_key_type do |t|
165
+ t.references :report, type: foreign_key_type, null: false, foreign_key: { to_table: :moderate_reports }
166
+
167
+ # Who appealed: a user, or a named/emailed notifier for public notices.
168
+ t.references :appellant, type: foreign_key_type, null: true
169
+ t.string :appellant_name
170
+ t.string :appellant_email
171
+ t.string :source, null: false, default: "notifier"
172
+
173
+ t.text :reason, null: false
174
+ t.send(json_column_type, :snapshot, null: false, default: json_column_default)
175
+
176
+ # Lifecycle + decision.
177
+ t.string :status, null: false, default: "open"
178
+ t.references :resolved_by, type: foreign_key_type, null: true
179
+ t.datetime :resolved_at
180
+ t.text :resolution_note
181
+ t.datetime :decision_notified_at
182
+
183
+ t.timestamps
184
+ end
185
+
186
+ add_index :moderate_appeals, [:report_id, :created_at], name: "index_moderate_appeals_on_report_id_and_created_at"
187
+ add_index :moderate_appeals, [:status, :created_at], name: "index_moderate_appeals_on_status_and_created_at"
188
+ add_index :moderate_appeals, :appellant_email, name: "index_moderate_appeals_on_appellant_email"
189
+
190
+ # Appeal's source/status vocabularies are validated in Moderate::Appeal
191
+ # (constants + inclusion validations), not by DB CHECK constraints — same
192
+ # migration-free-taxonomy rationale as moderate_reports above.
193
+ end
194
+
195
+ private
196
+
197
+ def primary_and_foreign_key_types
198
+ config = Rails.configuration.generators
199
+ setting = config.options[config.orm][:primary_key_type]
200
+ primary_key_type = setting || :primary_key
201
+ foreign_key_type = setting || :bigint
202
+ [primary_key_type, foreign_key_type]
203
+ end
204
+
205
+ def json_column_type
206
+ return :jsonb if connection.adapter_name.downcase.include?("postgresql")
207
+
208
+ :json
209
+ end
210
+
211
+ # MySQL 8+ doesn't allow default values on JSON columns.
212
+ # Returns an empty-hash default for SQLite/PostgreSQL, nil for MySQL.
213
+ # Models handle nil metadata gracefully by defaulting to {} in their accessors.
214
+ def json_column_default
215
+ return nil if connection.adapter_name.downcase.include?("mysql")
216
+
217
+ {}
218
+ end
219
+
220
+ # Same MySQL caveat as `json_column_default`, but for list-shaped columns,
221
+ # which default to an empty array on SQLite/PostgreSQL.
222
+ def json_array_default
223
+ return nil if connection.adapter_name.downcase.include?("mysql")
224
+
225
+ []
226
+ end
227
+
228
+ # The SQL function for COUNTING CHARACTERS in a check constraint, chosen per
229
+ # adapter so the message-length guard is portable:
230
+ # - SQLite has no `char_length`; its `length(text)` already counts CHARACTERS.
231
+ # - PostgreSQL & MySQL 8+ both provide `char_length` (true character count,
232
+ # unlike MySQL's `length`, which counts BYTES). Using `char_length` there
233
+ # makes the 4000 cap a real character cap on every adapter, not a byte cap.
234
+ def char_length_fn
235
+ connection.adapter_name.downcase.include?("sqlite") ? "length" : "char_length"
236
+ end
237
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ Moderate.configure do |config|
4
+ # ==========================================================================
5
+ # WHO ARE YOUR USERS?
6
+ # ==========================================================================
7
+ #
8
+ # The model that reports, blocks, gets reported, and gets banned. This is the
9
+ # model where you `include Moderate::Actor`. Stored as a string and resolved
10
+ # lazily, so it works no matter when your app boots.
11
+ #
12
+ # Default: "User"
13
+ config.user_class = "User"
14
+
15
+ # ==========================================================================
16
+ # CONTENT FILTERING
17
+ # ==========================================================================
18
+ #
19
+ # The default mode used by `moderates :field` when you don't pass `mode:`.
20
+ #
21
+ # :off - no check (filtering disabled for fields that don't override it)
22
+ # :block - reject the save with a validation error if the filter trips
23
+ # :flag - let the save through, then create a Moderate::Flag after commit
24
+ # for human or automated review (great for DMs, where you never
25
+ # want to block someone mid-conversation)
26
+ #
27
+ # Default: :block
28
+ # config.default_filter_mode = :block
29
+
30
+ # The default text adapter used by `moderates :field` and `Moderate.classify`.
31
+ # Every adapter implements the same tiny contract — `classify(value) → Result`
32
+ # — so they're interchangeable per field. `moderate` ships exactly ONE built-in
33
+ # adapter; anything else (text-with-context, images, a hosted moderation API) is
34
+ # bring-your-own (`register_adapter`, below):
35
+ #
36
+ # :wordlist - fast, multilingual, offline wordlist (ships en/es). The ONLY
37
+ # built-in. Fast offline baseline; register a remote/contextual
38
+ # adapter when you need stronger checks.
39
+ #
40
+ # Default: :wordlist
41
+ # config.filter_adapter = :wordlist
42
+
43
+ # Per-field filter policies, declared in one place instead of (or in addition
44
+ # to) `moderates :field` in your models. Handy when you want all your T&S
45
+ # configuration to live in this initializer.
46
+ #
47
+ # Signature: filter <ClassName>, <field>, with: <adapter>, mode: <mode>
48
+ #
49
+ # config.filter "Message", :body, with: :wordlist, mode: :flag
50
+ # config.filter "Profile", :bio, with: :wordlist, mode: :block
51
+ # config.filter "Profile", :avatar, with: :rekognition, mode: :flag # a registered adapter (see below)
52
+
53
+ # Bring your own adapter — it's just an object that responds to `classify`,
54
+ # returning a Moderate::Result. Register it once, then reference it by name
55
+ # in `moderates` or `config.filter` with `with: :my_adapter`.
56
+ #
57
+ # Two ready-to-copy reference adapters ship under the gem's examples/ directory —
58
+ # OpenAI moderation (text + image, via the ruby_llm gem) and AWS Rekognition
59
+ # (images). They are NOT a dependency: copy one into your app, add its gem to your
60
+ # Gemfile, and register it here. Async adapters (a remote classifier) are only valid
61
+ # in :flag mode; :block needs the synchronous :wordlist.
62
+ #
63
+ # config.register_adapter :openai, OpenAIModerationAdapter.new
64
+ # config.register_adapter :my_adapter, MyAdapter.new
65
+
66
+ # Extra wordlist entries layered on top of the built-in lists, and entries to
67
+ # exclude (false positives you never want flagged in your domain). Both apply
68
+ # to the :wordlist adapter.
69
+ #
70
+ # config.additional_words = %w[customword anotherword]
71
+ # config.excluded_words = %w[scunthorpe assangea]
72
+
73
+ # Override the in-app COMMUNITY report category list (what a user picks from a
74
+ # "Report" sheet). Defaults to Moderate::Report::DEFAULT_CATEGORIES. Adding a
75
+ # category here requires NO migration — `category` is validated in the model. (The
76
+ # separate, regulator-defined DSA legal-reason taxonomy is NOT overridable.)
77
+ #
78
+ # config.report_categories = %w[harassment hate spam fraud my_custom_label]
79
+
80
+ # ==========================================================================
81
+ # AUDIT — one hook, recorded however you want
82
+ # ==========================================================================
83
+ #
84
+ # Called for every important action so you can write it to YOUR audit system.
85
+ # `moderate` never writes to your audit log directly — it just emits the event.
86
+ # No-op by default.
87
+ #
88
+ # The event carries a stable envelope:
89
+ # event.name # Symbol, e.g. :report_decision
90
+ # event.subject # the record acted on (a Report, Block, Flag, Appeal…)
91
+ # event.actor # who took the action (a moderator, a user, or nil/system)
92
+ # event.recipients # who should be notified (Array)
93
+ # event.payload # Hash of event-specific context (includes :summary)
94
+ # event.to_h # the whole envelope as a Hash
95
+ #
96
+ # config.audit = ->(event) { AuditLog.record!(event_type: event.name, data: event.payload) }
97
+
98
+ # ==========================================================================
99
+ # NOTIFY — one hook, fan out anywhere
100
+ # ==========================================================================
101
+ #
102
+ # Called for every notifiable event. Wire it once and fan out to email
103
+ # (goodmail), admin alerts (telegrama), in-app + push (noticed) — all from the
104
+ # same place. No-op by default.
105
+ #
106
+ # The full event vocabulary:
107
+ # :report_received :report_decision :affected_user_decision
108
+ # :appeal_received :appeal_decision
109
+ # :user_blocked :user_unblocked :user_banned
110
+ # :content_flagged :content_removed
111
+ #
112
+ # IMPORTANT: keep this fast. Use background jobs (deliver_later, perform_later)
113
+ # so notifications never block a moderation action.
114
+ #
115
+ # config.notify = ->(event) do
116
+ # case event.name
117
+ # when :report_received, :report_decision, :affected_user_decision
118
+ # # email the user — goodmail
119
+ # ModerationMailer.with(event: event).public_send(event.name).deliver_later
120
+ # when :content_flagged
121
+ # # ping admins — telegrama
122
+ # Telegrama.send_message("🚩 #{event.payload[:summary]}")
123
+ # end
124
+ # end
125
+
126
+ # ==========================================================================
127
+ # ON BLOCK — optional side effects when one user blocks another
128
+ # ==========================================================================
129
+ #
130
+ # Run extra teardown when a block happens (cancel a pending invite, leave a
131
+ # shared room, drop a follow…). No-op by default. Signature uses keyword args.
132
+ #
133
+ # config.on_block = ->(blocker:, blocked:, at:) { CancelPendingInvites.call(blocker, blocked, at: at) }
134
+
135
+ # ==========================================================================
136
+ # BAN HANDLER — how a "ban" is actually applied in YOUR app
137
+ # ==========================================================================
138
+ #
139
+ # `moderate` doesn't own your user lifecycle, so it never bans a user itself.
140
+ # When a moderator resolves a report with `ban_user: true`, this proc decides
141
+ # what "banned" means in your domain — suspend, soft-delete, flip a flag, etc.
142
+ # Signature uses keyword args. No-op by default (the action still audits).
143
+ #
144
+ # config.ban_handler = ->(user:, by:, reason:) { user.suspend!(reason: reason) }
145
+
146
+ # ==========================================================================
147
+ # PUBLIC FORM HUMAN VERIFICATION — optional per-request skips
148
+ # ==========================================================================
149
+ #
150
+ # The notice and appeal forms auto-use rails_cloudflare_turnstile when present,
151
+ # otherwise they fall back to notice_guard / appeal_guard. If one of your clients
152
+ # cannot render a browser challenge (for example a native shell or an edge-verified
153
+ # request), skip the human-verification gate for that request only. Nil by default.
154
+ #
155
+ # config.notice_human_verification_skip_if = ->(controller) {
156
+ # controller.request.user_agent.to_s.match?(/Hotwire Native/i)
157
+ # }
158
+ #
159
+ # config.appeal_human_verification_skip_if = ->(controller) {
160
+ # controller.request.user_agent.to_s.match?(/Hotwire Native/i)
161
+ # }
162
+
163
+ # ==========================================================================
164
+ # PUBLIC TRANSPARENCY REPORT (DSA Art. 24) — opt-in
165
+ # ==========================================================================
166
+ #
167
+ # OFF by default. A *live* transparency portal is not itself a legal requirement:
168
+ # the DSA obligation is to *publish* a report at least annually (a static page/file
169
+ # is fine), and micro/small enterprises are exempt from the transparency tier
170
+ # entirely (Art. 15(2) / Art. 19). Turn it on to expose the mounted
171
+ # `<mount>/transparency` page; left off, that route 404s and the counts are never
172
+ # published. The aggregation is still queryable in code so you can build your own.
173
+ #
174
+ # config.transparency_report_enabled = true
175
+
176
+ # ==========================================================================
177
+ # SIGNED LINKS — purposes for the signed Global IDs in emails & notices
178
+ # ==========================================================================
179
+ #
180
+ # `moderate` mints signed, single-purpose links (e.g. an appeal link in a
181
+ # decision email, a confirm-receipt link in a DSA notice). These purposes
182
+ # scope each signature so a link minted for one action can't be replayed for
183
+ # another. The defaults cover the built-in flows; add your own if you mint
184
+ # custom signed links against moderate records.
185
+ #
186
+ # Default: [:appeal, :confirm_notice, :unsubscribe]
187
+ # config.signed_gid_purposes = [:appeal, :confirm_notice, :unsubscribe]
188
+
189
+ # ==========================================================================
190
+ # LOCALE
191
+ # ==========================================================================
192
+ #
193
+ # The locale used for user-facing copy moderate generates on its own (filter
194
+ # validation messages, the DSA statement-of-reasons taxonomy labels, the
195
+ # notice-form strings). Defaults to your app's I18n.default_locale.
196
+ #
197
+ # config.locale = :en
198
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Moderate
6
+ module Generators
7
+ # `rails generate moderate:views` — eject the engine's overridable templates
8
+ # into the HOST app so they can be restyled. This is the Devise move
9
+ # (`rails g devise:views`), and it works for the same boring Rails reason:
10
+ # the host app's `app/views` sits AHEAD of any engine's view paths in the
11
+ # lookup chain, so a file copied to `app/views/moderate/notices/new.html.erb`
12
+ # SHADOWS the gem's bundled default automatically — no config, no registration.
13
+ # Delete your copy and the gem's default comes back. Upgrade the gem and your
14
+ # ejected copies are untouched (re-run only if you WANT the new defaults).
15
+ #
16
+ # `source_root` points at the engine's own `app/views`, so `copy_file` /
17
+ # `directory` read the exact templates the engine renders.
18
+ class ViewsGenerator < Rails::Generators::Base
19
+ source_root File.expand_path("../../../app/views", __dir__)
20
+
21
+ desc "Copy moderate's overridable notice-form views into your app so you can restyle them."
22
+
23
+ # Which groups to eject. Default copies the notice views and the engine layout
24
+ # (the full set). `--views form` copies just the field partial — the part a
25
+ # host most often wants to restyle — and `--views form layout` adds the layout.
26
+ class_option :views,
27
+ type: :array,
28
+ default: %w[notices layout],
29
+ desc: "Which view groups to copy (notices, form, layout)"
30
+
31
+ def copy_views
32
+ # The whole notices directory (new/show + any partials that ship with it).
33
+ directory "moderate/notices", "app/views/moderate/notices" if include?("notices")
34
+
35
+ # Just the field partial, for the "I only want to restyle the fields" path.
36
+ # Guarded by File.exist? so the generator doesn't fail if the partial isn't
37
+ # part of the shipped set in a given version.
38
+ if include?("form") && !include?("notices")
39
+ partial = "moderate/notices/_form.html.erb"
40
+ copy_file partial, "app/views/#{partial}" if engine_view_exists?(partial)
41
+ end
42
+
43
+ # The engine layout (only if one ships under layouts/moderate).
44
+ if include?("layout")
45
+ directory "layouts/moderate", "app/views/layouts/moderate" if engine_view_exists?("layouts/moderate")
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # True when the user asked for a given view group (case-insensitive).
52
+ def include?(group)
53
+ options[:views].map(&:to_s).include?(group)
54
+ end
55
+
56
+ # Whether a given path exists under the engine's view source_root, so we only
57
+ # try to copy templates that actually ship in this version.
58
+ def engine_view_exists?(relative_path)
59
+ File.exist?(File.join(self.class.source_root, relative_path))
60
+ end
61
+ end
62
+ end
63
+ end