moderate 1.0.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7604220d418947fa60f707ff40e41635b58f0e1dddb73b5e79d44ab4f37caf46
4
- data.tar.gz: 48f55fa68865042efffe9c733f19ac031f89f8d1eaa1a91e03ef5f5dfaee6f48
3
+ metadata.gz: 855c6c868845a30cb2cc8ace230bb926b77b530eff47ee975b3137ceea0e6278
4
+ data.tar.gz: 87ba2fc546cadd8cdf0e0b53aca9936e2627f810c4ea685884c1742a09ea9565
5
5
  SHA512:
6
- metadata.gz: cc7dca23c94332145107036a6fa411034f78aa17b8006341fc50ad5937ef6163cc147a02df68fd726806ca9eb5ad4b0bc922846fe1bb8c55a151a301a552d0bd
7
- data.tar.gz: 6d0405cf47e6e8256e43bbf0b17d478ecb905451d1795a0e64dacccddf406ae2865d41e2e97ca574251be961f4b19890673bef53e94d9c40b499c8a3f0e503f2
6
+ metadata.gz: 416181ed2e12216dbd154511a5eec55d0f2e4ff9d6840c9f29a3b1b8e1ef126b241dbfa8f8d5145270a5af1cd97949bd1105ea41829da3f5a163ecaa89692885
7
+ data.tar.gz: fe0e4aadad27c758d72928ff95148a992b65e79eef3ba30946a8c76438b9e580536585baa03fa2ddafc363cf4741084291af25e3448de59b6d59298f5dffb09e
data/.rubocop.yml CHANGED
@@ -101,3 +101,10 @@ Lint/UselessAccessModifier:
101
101
  # under a `raise`, or indented under a keyword); the code uses both.
102
102
  Layout/LineEndStringConcatenationIndentation:
103
103
  Enabled: false
104
+
105
+ # The gem's own migrations are copied into host apps, many of them on Rails' omakase
106
+ # style, which spaces array brackets (`[ :a, :b ]`); they follow that style, checked by
107
+ # test/generators/omakase_style_test.rb.
108
+ Layout/SpaceInsideArrayLiteralBrackets:
109
+ Exclude:
110
+ - "db/migrate/**/*"
data/CHANGELOG.md CHANGED
@@ -4,6 +4,39 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [1.0.1] - 2026-10-19
8
+
9
+ ### Upgrading from 1.0.0
10
+
11
+ - **One migration: run `bin/rails moderate:install:migrations` (if your app holds copies of
12
+ the gem's migrations, `*.moderate.rb`; boot tells you if you forget), then
13
+ `bin/rails db:migrate`.** `db/migrate/20261019120000_change_moderate_classifications_record_id_to_string.rb`
14
+ makes `moderate_classifications.record_id` a string and caps it and `digest` at 64
15
+ characters (which keeps the table's unique index within MySQL's 3072-byte key limit).
16
+ Existing rows keep their ids, as strings. Polymorphic ids compare bytewise on every
17
+ database (on MySQL and Trilogy the column is `utf8mb4_bin`, as `pending_key` is). Guarded
18
+ (a no-op when the column is already a string) and reversible: `down` turns `record_id`
19
+ back into your primary-key type, and first refuses with `ActiveRecord::IrreversibleMigration`,
20
+ naming how many, while any row holds an id that type can't hold (for bigint: anything but
21
+ an integer within the signed 64-bit range, negatives included; for uuid: anything but the
22
+ canonical 8-4-4-4-12 form, in either case); delete those
23
+ classification rows (bookkeeping, re-created on the next classification) and run it
24
+ again.
25
+
26
+ ### Fixed
27
+
28
+ - **Attachment classification works in apps that mix uuid and bigint primary keys.**
29
+ 1.0.0 created `moderate_classifications.record_id` with the app's one primary-key type,
30
+ so records of the other kind couldn't be claimed or marked classified. It is a string
31
+ now (above).
32
+ - **The migrations an app receives pass a Rails omakase app's RuboCop.** The gem's
33
+ migrations and the install template use omakase's array-bracket spacing, and the copies
34
+ `rails generate moderate:install` writes keep the blank line after the magic comment,
35
+ as `bin/rails moderate:install:migrations` (Rails' own copy) does. A test runs
36
+ rubocop-rails-omakase on both kinds of copies and on the generated initializer, so a host
37
+ needs no exclude for them. The README also explains the `.moderate` suffix Rails adds to
38
+ copied migrations' names in `db:migrate:status`.
39
+
7
40
  ## [1.0.0] - 2026-10-19
8
41
 
9
42
  The first stable release. The public API is frozen until 2.0: everything listed in the
data/README.md CHANGED
@@ -527,6 +527,10 @@ Audit events (`Moderate::Event::AUDIT_NAMES`): `:report_received` · `:appeal_re
527
527
 
528
528
  Every hook receives one `Moderate::Event`: `#name` · `#subject` · `#actor` · `#recipients` · `#payload` (always with `:summary`) · `#occurred_at` · `#id` · `#summary` · `#to_h`. The contract for `config.notify`: it is called once per event, after the transaction that caused it commits (never for a rolled-back change); a truthy return means "handed off" (for a DSA notice the gem then treats the Art. 16(4) receipt as sent); a decision notice counts as delivered only when your notifier confirms it with `Moderate.delivered!(report, :decision | :affected_user)` from the mail's actual delivery, which stamps `decision_notified_at` / `affected_user_notified_at` once and starts the appeal window from then (DSA Art. 20(2)); an exception is swallowed, audited as `:notify_failed`, and never rolls back the decision. `config.audit` follows the same rules and its return value is ignored. Payload keys may be added in a minor release, never removed or renamed.
529
529
 
530
+ ## Upgrading within 1.0
531
+
532
+ **1.0.1 adds a migration** (`moderate_classifications.record_id` becomes a string, so apps that mix uuid and bigint primary keys classify attachments on both): run `bin/rails moderate:install:migrations` if your app holds copies of the gem's migrations, then `bin/rails db:migrate`. The copies follow the RuboCop style of a new Rails app (rubocop-rails-omakase), so they need no exclude. In `bin/rails db:migrate:status` a copied migration's name ends in `.moderate` (`Add pending key to moderate flags.moderate`): that is Rails' own naming for migrations copied from an engine, not a broken copy.
533
+
530
534
  ## Upgrading from a 1.0 beta
531
535
 
532
536
  **1.0.0 adds a migration** (retention columns and `moderate_classifications`): run `bin/rails moderate:install:migrations` if your app holds copies of the gem's migrations (`*.moderate.rb`, as every app installed from 1.0.0.beta4 on does), then `bin/rails db:migrate`. An app without copies gets it through the engine, like beta4's. **A missed copy is no longer silent:** at boot, an app that holds copies but not one of this version's migrations raises `Moderate::ConfigurationError` naming it and the command that copies it (under a rake task, such as that command itself, it logs instead); `config.migration_copies_check = :log` or `:off` changes that.
@@ -102,7 +102,7 @@ class AddPendingKeyToModerateFlags < ActiveRecord::Migration[7.1]
102
102
  newest[key] = flag if newest[key].nil? || flag.created_at > newest[key].created_at
103
103
  end
104
104
  taken = Flag.where.not(pending_key: nil).pluck(:pending_key).to_set
105
- newest.except(*taken).to_h { |key, flag| [flag.id, key] }
105
+ newest.except(*taken).to_h { |key, flag| [ flag.id, key ] }
106
106
  end
107
107
 
108
108
  # The keys to write and the keys already written, in a temporary table whose key
@@ -98,6 +98,6 @@ class AddRetentionAndClassificationsToModerateTables < ActiveRecord::Migration[8
98
98
  def key_types
99
99
  config = Rails.configuration.generators
100
100
  setting = config.options[config.orm][:primary_key_type]
101
- [setting || :primary_key, setting || :bigint]
101
+ [ setting || :primary_key, setting || :bigint ]
102
102
  end
103
103
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 1.0.1: moderate_classifications.record_id becomes a string, so an app that mixes
4
+ # uuid and bigint primary keys records classifications for every kind of record (1.0.0
5
+ # created it with the app's one primary-key type). `digest` (always 16 hex characters)
6
+ # and `record_id` (a uuid is 36, a bigint at most 20) are capped at 64 characters, so
7
+ # the unique index on (record_type, record_id, field, digest) stays within MySQL's
8
+ # 3072-byte key limit.
9
+ #
10
+ # On MySQL (and Trilogy) record_id is utf8mb4_bin, so ids compare bytewise, as they do
11
+ # on PostgreSQL and SQLite: "abc" and "ABC" are two records.
12
+ #
13
+ # Ships with the gem: `rails generate moderate:install` copies it after the others,
14
+ # an app holding copies gets it with `bin/rails moderate:install:migrations`, and an
15
+ # app without copies gets it through the engine. Guarded, so it is a no-op when the
16
+ # column is already a string, and reversible: `down` turns record_id back into the
17
+ # app's primary-key type, and first refuses (naming how many) if any row holds an id
18
+ # of the other key kind, which that type can't keep.
19
+ class ChangeModerateClassificationsRecordIdToString < ActiveRecord::Migration[8.0]
20
+ # The table as it is, not the app's model: a migration must not depend on code.
21
+ class Classification < ActiveRecord::Base
22
+ self.table_name = "moderate_classifications"
23
+ end
24
+
25
+ # The canonical 8-4-4-4-12 form, either case: what Rails writes for a uuid key. (Other
26
+ # forms PostgreSQL accepts as uuid input, braces or no hyphens, aren't expected here.)
27
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/i
28
+ BIGINT = /\A-?\d+\z/
29
+ BIGINT_RANGE = (-(2**63))..((2**63) - 1)
30
+
31
+ def up
32
+ return unless table_exists?(:moderate_classifications)
33
+ return if column_type(:record_id) == :string
34
+
35
+ change_column :moderate_classifications, :digest, :string, limit: 64, null: false
36
+ change_column :moderate_classifications, :record_id, :string, limit: 64, null: false, **binary, **cast("varchar")
37
+ end
38
+
39
+ def down
40
+ return unless table_exists?(:moderate_classifications)
41
+ return unless column_type(:record_id) == :string
42
+
43
+ key_type = key_type_setting || :bigint
44
+ refuse_unfit_ids!(key_type)
45
+ change_column :moderate_classifications, :record_id, key_type, null: false, **cast(key_type == :uuid ? "uuid" : "bigint")
46
+ change_column :moderate_classifications, :digest, :string, null: false
47
+ end
48
+
49
+ private
50
+
51
+ # Rows whose record_id the app's key type can't hold (a uuid in a bigint app, or the
52
+ # reverse): rolling back would fail midway on PostgreSQL and MySQL, or keep the text
53
+ # in an integer column on SQLite.
54
+ def refuse_unfit_ids!(key_type)
55
+ unfit = unfit_ids_count(key_type)
56
+ return if unfit.zero?
57
+
58
+ raise ActiveRecord::IrreversibleMigration,
59
+ "#{unfit} moderate_classifications row(s) hold a record_id that isn't a #{key_type} id, so record_id " \
60
+ "can't go back to #{key_type}. Delete those classification rows first; they are bookkeeping, " \
61
+ "re-created on the next classification."
62
+ end
63
+
64
+ def unfit_ids_count(key_type)
65
+ Classification.reset_column_information
66
+ unfit = 0
67
+ Classification.in_batches(of: 1000) do |batch|
68
+ unfit += batch.pluck(:record_id).count { |id| !fits?(id.to_s, key_type) }
69
+ end
70
+ unfit
71
+ end
72
+
73
+ # A uuid in the canonical form; a bigint: an optionally negative integer within the
74
+ # signed 64-bit range (a longer one would fail the conversion, not this refusal).
75
+ def fits?(id, key_type)
76
+ return UUID.match?(id) if key_type == :uuid
77
+
78
+ BIGINT.match?(id) && BIGINT_RANGE.cover?(id.to_i)
79
+ end
80
+
81
+ def mysql?
82
+ connection.adapter_name.downcase.match?(/mysql|trilogy/)
83
+ end
84
+
85
+ # Bytewise comparison on MySQL, whose default collation ignores case.
86
+ def binary
87
+ mysql? ? { collation: "utf8mb4_bin" } : {}
88
+ end
89
+
90
+ def column_type(name)
91
+ connection.columns(:moderate_classifications).find { |column| column.name == name.to_s }&.type
92
+ end
93
+
94
+ # PostgreSQL needs the conversion spelled out; MySQL and SQLite convert by themselves.
95
+ def cast(type)
96
+ connection.adapter_name.downcase.include?("postg") ? { using: "record_id::#{type}" } : {}
97
+ end
98
+
99
+ def key_type_setting
100
+ config = Rails.configuration.generators
101
+ config.options[config.orm][:primary_key_type]
102
+ end
103
+ end
@@ -13,6 +13,7 @@ group :development do
13
13
  gem "rubocop", "~> 1.0"
14
14
  gem "rubocop-minitest", "~> 0.35"
15
15
  gem "rubocop-performance", "~> 1.0"
16
+ gem "rubocop-rails-omakase", require: false
16
17
  gem "standard"
17
18
  end
18
19
 
@@ -12,6 +12,7 @@ group :development do
12
12
  gem "rubocop", "~> 1.0"
13
13
  gem "rubocop-minitest", "~> 0.35"
14
14
  gem "rubocop-performance", "~> 1.0"
15
+ gem "rubocop-rails-omakase", require: false
15
16
  gem "standard"
16
17
  end
17
18
 
@@ -66,11 +66,15 @@ module Moderate
66
66
 
67
67
  private
68
68
 
69
- # As Rails' engine copy writes it: magic comments first, then where it came from.
69
+ # As Rails' engine copy writes it: magic comments first (with the blank line after
70
+ # them that the source has, which RuboCop's omakase style asks for), then where it
71
+ # came from, then the rest.
70
72
  def copied_migration(source, version)
71
73
  body = File.read(source)
72
74
  magic = body[/\A(?:#.*\b(?:coding|frozen_string_literal):.*\n)+/].to_s
73
- "#{magic}# This migration comes from moderate (originally #{version})\n#{body.delete_prefix(magic)}"
75
+ rest = body.delete_prefix(magic)
76
+ magic += "\n" if !magic.empty? && rest.start_with?("\n")
77
+ "#{magic}# This migration comes from moderate (originally #{version})\n#{rest.delete_prefix("\n")}"
74
78
  end
75
79
 
76
80
  def migration_version
@@ -70,12 +70,12 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
70
70
  t.timestamps
71
71
  end
72
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"
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
79
  add_index :moderate_reports, :notifier_email, name: "index_moderate_reports_on_notifier_email"
80
80
  add_index :moderate_reports, :resolved_at, name: "index_moderate_reports_on_resolved_at"
81
81
  add_index :moderate_reports, :appeal_deadline_at, name: "index_moderate_reports_on_appeal_deadline_at"
@@ -106,7 +106,7 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
106
106
  t.timestamps
107
107
  end
108
108
 
109
- add_index :moderate_blocks, [:blocker_id, :blocked_id], unique: true, name: "index_moderate_blocks_on_blocker_id_and_blocked_id"
109
+ add_index :moderate_blocks, [ :blocker_id, :blocked_id ], unique: true, name: "index_moderate_blocks_on_blocker_id_and_blocked_id"
110
110
  add_index :moderate_blocks, :created_at, name: "index_moderate_blocks_on_created_at"
111
111
 
112
112
  add_check_constraint :moderate_blocks,
@@ -147,9 +147,9 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
147
147
  t.timestamps
148
148
  end
149
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"
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
153
 
154
154
  # moderate_flags.pending_key (at most one PENDING flag per content + field) is NOT
155
155
  # here: the gem's own AddPendingKeyToModerateFlags creates it, for a fresh install
@@ -188,8 +188,8 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
188
188
  t.timestamps
189
189
  end
190
190
 
191
- add_index :moderate_appeals, [:report_id, :created_at], name: "index_moderate_appeals_on_report_id_and_created_at"
192
- add_index :moderate_appeals, [:status, :created_at], name: "index_moderate_appeals_on_status_and_created_at"
191
+ add_index :moderate_appeals, [ :report_id, :created_at ], name: "index_moderate_appeals_on_report_id_and_created_at"
192
+ add_index :moderate_appeals, [ :status, :created_at ], name: "index_moderate_appeals_on_status_and_created_at"
193
193
  add_index :moderate_appeals, :appellant_email, name: "index_moderate_appeals_on_appellant_email"
194
194
 
195
195
  # The 1.0 retention columns (redacted_at, held_until) and moderate_classifications
@@ -209,7 +209,7 @@ class CreateModerateTables < ActiveRecord::Migration<%= migration_version %>
209
209
  setting = config.options[config.orm][:primary_key_type]
210
210
  primary_key_type = setting || :primary_key
211
211
  foreign_key_type = setting || :bigint
212
- [primary_key_type, foreign_key_type]
212
+ [ primary_key_type, foreign_key_type ]
213
213
  end
214
214
 
215
215
  def json_column_type
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moderate
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moderate
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
10
+ date: 2026-10-19 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activerecord
@@ -141,6 +141,7 @@ files:
141
141
  - context7.json
142
142
  - db/migrate/20261018200000_add_pending_key_to_moderate_flags.rb
143
143
  - db/migrate/20261018210000_add_retention_and_classifications_to_moderate_tables.rb
144
+ - db/migrate/20261019120000_change_moderate_classifications_record_id_to_string.rb
144
145
  - docs/chats.md
145
146
  - docs/compliance.md
146
147
  - docs/configuration.md
@@ -216,7 +217,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
216
217
  - !ruby/object:Gem::Version
217
218
  version: '0'
218
219
  requirements: []
219
- rubygems_version: 3.6.9
220
+ rubygems_version: 3.6.2
220
221
  specification_version: 4
221
222
  summary: Let your Rails users report content and block each other (Trust & Safety)
222
223
  test_files: []