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