From 6d892748f00dbad347fea1e443875f2b3e1f0d19 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 14 May 2026 14:29:11 +0100 Subject: [PATCH 1/9] Add basic views --- app/controllers/moderator_controller.rb | 10 ++++++++++ app/views/moderator/pii_correlation.html.erb | 18 ++++++++++++++++++ .../moderator/pii_correlation.template.erb | 0 config/initializers/mime_types.rb | 2 ++ config/routes.rb | 1 + 5 files changed, 31 insertions(+) create mode 100644 app/views/moderator/pii_correlation.html.erb create mode 100644 app/views/moderator/pii_correlation.template.erb diff --git a/app/controllers/moderator_controller.rb b/app/controllers/moderator_controller.rb index c2f649e7f..b2b00213d 100644 --- a/app/controllers/moderator_controller.rb +++ b/app/controllers/moderator_controller.rb @@ -89,6 +89,16 @@ def handle_spammy_users redirect_to mod_spammers_path end + def pii_correlation + @user = User.find(params[:id]) + respond_to do |format| + format.html + format.template do + @target = User.find_by(id: params[:target_id]) + end + end + end + private def set_post diff --git a/app/views/moderator/pii_correlation.html.erb b/app/views/moderator/pii_correlation.html.erb new file mode 100644 index 000000000..0b421ee43 --- /dev/null +++ b/app/views/moderator/pii_correlation.html.erb @@ -0,0 +1,18 @@ +

PII correlation for <%= user_link @user %>

+

+ This tool displays correlations between personally-identifying information across user accounts. This includes email + address and IP addresses. Please select a user against whom to compare. +

+ + +
+
+
+ + +
+
+ +
+
+
diff --git a/app/views/moderator/pii_correlation.template.erb b/app/views/moderator/pii_correlation.template.erb new file mode 100644 index 000000000..e69de29bb diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index dc1899682..eeb9ef7d1 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -2,3 +2,5 @@ # Add new mime types for use in respond_to blocks: # Mime::Type.register "text/richtext", :rtf + +Mime::Type.register('text/html+template', :template) diff --git a/config/routes.rb b/config/routes.rb index fc4c5ca46..9523121e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -226,6 +226,7 @@ get '/:id/mod/activity-log', to: 'users#full_log', as: :full_user_log post '/:id/hellban', to: 'admin#hellban', as: :hellban_user get '/:id/avatar/:size', to: 'users#avatar', as: :user_auto_avatar + get '/:id/mod/pii', to: 'moderator#pii_correlation', as: :mod_pii_correlation end post 'notifications/:id/read', to: 'notifications#read', as: :read_notifications From 2d7cd26e2dd7f59997ae5639110a31b1b23dcf4e Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 14 May 2026 17:02:49 +0100 Subject: [PATCH 2/9] Add Redis cache methods --- app/jobs/update_user_stats_job.rb | 7 ++ lib/namespaced_env_cache.rb | 13 +++ lib/redis_cache_hash_methods.rb | 103 ++++++++++++++++++++++++ test/jobs/update_user_stats_job_test.rb | 7 ++ 4 files changed, 130 insertions(+) create mode 100644 app/jobs/update_user_stats_job.rb create mode 100644 lib/redis_cache_hash_methods.rb create mode 100644 test/jobs/update_user_stats_job_test.rb diff --git a/app/jobs/update_user_stats_job.rb b/app/jobs/update_user_stats_job.rb new file mode 100644 index 000000000..f5e23b173 --- /dev/null +++ b/app/jobs/update_user_stats_job.rb @@ -0,0 +1,7 @@ +class UpdateUserStatsJob < ApplicationJob + queue_as :default + + def perform(*) + + end +end diff --git a/lib/namespaced_env_cache.rb b/lib/namespaced_env_cache.rb index 571ca1d98..68473e58f 100644 --- a/lib/namespaced_env_cache.rb +++ b/lib/namespaced_env_cache.rb @@ -1,5 +1,10 @@ +require_relative 'redis_cache_hash_methods' + module QPixel class NamespacedEnvCache < ActiveSupport::Cache::Store + include RedisCacheHashMethods + attr_reader :underlying + def initialize(underlying) @underlying = underlying @getters = {} @@ -144,6 +149,14 @@ def self.supports_cache_versioning? true end + def method_missing(name, *args, **opts, &block) + @underlying.send(name, *args, **opts, &block) + end + + def respond_to_missing?(name, *) + @underlying.respond_to?(name) + end + private # Raises an error if a given collection is not cacheable diff --git a/lib/redis_cache_hash_methods.rb b/lib/redis_cache_hash_methods.rb new file mode 100644 index 000000000..b94923c8e --- /dev/null +++ b/lib/redis_cache_hash_methods.rb @@ -0,0 +1,103 @@ +module QPixel + ## + # Class for inclusion in a +RedisCacheStore+ or +NamespacedEnvCache+ to add Redis hash methods. + # If the class is not a +RedisCacheStore+ or +NamespacedEnvCache+, these methods will still be added but will + # raise at runtime. The cache implementation must be using +ConnectionPool+. + module RedisCacheHashMethods + ## + # Set a hash value. + # @param hash_key [String] The name of the hash + # @param key [String] The key within the hash + # @param value [String] The key's value + # @return [Integer] The number of keys that were added to the hash + def hset(hash_key, key, value) + with_redis do |rd| + rd.hset hash_key, key, value + end + end + + ## + # Set multiple hash values. + # @param hash_key [String] The name of the hash + # @param data [Hash] Keys and values to add to the hash + # @return [String] 'OK' + def hmset(hash_key, data) + with_redis do |rd| + rd.hmset hash_key, data.to_a.flatten + end + end + + ## + # Get a hash value. + # @param hash_key [String] The name of the hash + # @param key [String] The key within the hash + # @return [String] The key's value + def hget(hash_key, key) + with_redis do |rd| + rd.hget hash_key, key + end + end + + ## + # Get multiple hash values. + # @param hash_key [String] The name of the hash + # @param *keys [String] Keys within the hash to retrieve + # @return [Hash] Keys and values from the hash + def hmget(hash_key, *keys) + with_redis do |rd| + values = rd.hmget hash_key, *keys + keys.zip(values).to_h + end + end + + ## + # Get all hash values. + # @param hash_key [String] The name of the hash + # @return [Hash] The hash's values + def hgetall(hash_key) + with_redis do |rd| + rd.hgetall hash_key + end + end + + ## + # Delete a hash value, or the entire hash. + # @param hash_key [String] The name of the hash + # @param *keys [String] Keys within the hash to delete. If none are provided, the entire hash is deleted. + # @return [Integer] The number of keys that were removed from the hash + def hdel(hash_key, *keys) + with_redis do |rd| + if keys.size.zero? + rd.del hash_key + else + rd.hdel hash_key, *keys + end + end + end + + private + + ## + # Check a connection out of the connection pool and provides it to the block to run Redis commands. + # @yield [Redis::Client] + def with_redis + reject_unless_redis_cache! + redis_cache_store = ActiveSupport::Cache::RedisCacheStore + redis_cache = is_a?(redis_cache_store) ? self : underlying + redis_cache.redis.with do |rd| + yield rd + end + end + + ## + # Raises an error unless the current class is a +RedisCacheStore+, or is a +NamespacedEnvCache+ that is backed by + # a +RedisCacheStore+. + # @raise [NotImplementedError] + def reject_unless_redis_cache! + redis_cache_store = ActiveSupport::Cache::RedisCacheStore + unless is_a?(redis_cache_store) || (respond_to?(:underlying) && underlying.is_a?(redis_cache_store)) + raise NotImplementedError, "This cache implementation is not backed by Redis and cannot use Hash methods." + end + end + end +end \ No newline at end of file diff --git a/test/jobs/update_user_stats_job_test.rb b/test/jobs/update_user_stats_job_test.rb new file mode 100644 index 000000000..767ff2a3f --- /dev/null +++ b/test/jobs/update_user_stats_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UpdateUserStatsJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 07af4964904aece58b430ed024b4da40d15e0809 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 14 May 2026 17:25:58 +0100 Subject: [PATCH 3/9] Test hash methods --- test/lib/redis_cache_hash_methods_test.rb | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 test/lib/redis_cache_hash_methods_test.rb diff --git a/test/lib/redis_cache_hash_methods_test.rb b/test/lib/redis_cache_hash_methods_test.rb new file mode 100644 index 000000000..3e99f29bd --- /dev/null +++ b/test/lib/redis_cache_hash_methods_test.rb @@ -0,0 +1,24 @@ +require 'test_helper' + +class RedisCacheHashMethodsTest < ActiveSupport::TestCase + test 'redis cache hash methods' do + assert_nil Rails.cache.read('test_hash') + assert_equal 1, Rails.cache.hset('test_hash', 'key', 'value') + assert_equal 'value', Rails.cache.hget('test_hash', 'key') + assert_equal 'OK', Rails.cache.hmset('test_hash', { 'key2' => 'value2', 'key3' => 'value3' }) + assert_equal({ 'key' => 'value', 'key2' => 'value2', 'key3' => 'value3' }, + Rails.cache.hmget('test_hash', 'key', 'key2', 'key3')) + assert_equal({ 'key' => 'value', 'key2' => 'value2', 'key3' => 'value3' }, + Rails.cache.hgetall('test_hash')) + assert_equal 1, Rails.cache.hdel('test_hash', 'key3') + assert_equal 1, Rails.cache.hdel('test_hash') + assert_nil Rails.cache.read('test_hash') + end + + test 'rejects calls on unimplemented caches' do + cache = QPixel::NamespacedEnvCache.new(ActiveSupport::Cache::MemoryStore.new) + assert_raises NotImplementedError do + cache.hgetall('test_hash') + end + end +end From 0972d7eec329129cf2c43fcd042162e09903f919 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Thu, 14 May 2026 17:42:22 +0100 Subject: [PATCH 4/9] Finish user stats job --- app/jobs/update_user_stats_job.rb | 6 +++++- config/schedule.rb | 4 ++++ scripts/run_user_stats.rb | 1 + 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 scripts/run_user_stats.rb diff --git a/app/jobs/update_user_stats_job.rb b/app/jobs/update_user_stats_job.rb index f5e23b173..22146c4c9 100644 --- a/app/jobs/update_user_stats_job.rb +++ b/app/jobs/update_user_stats_job.rb @@ -2,6 +2,10 @@ class UpdateUserStatsJob < ApplicationJob queue_as :default def perform(*) - + domains = User.all.select(:email) + .group_by { |u| u.email&.split('@')[1] } + .to_h { |d, u| [d, u.size] } + .reject { |d, _u| d.include? 'localhost' } + Rails.cache.hmset('user_email_domains', domains) end end diff --git a/config/schedule.rb b/config/schedule.rb index d8472d7b7..81c156ae5 100644 --- a/config/schedule.rb +++ b/config/schedule.rb @@ -34,6 +34,10 @@ runner 'scripts/run_new_thread_followers_cleanup.rb' end +every 7.days, at: '05:00' do + runner 'scripts/run_user_stats.rb' +end + every 6.hours do runner 'scripts/recalc_abilities.rb' end diff --git a/scripts/run_user_stats.rb b/scripts/run_user_stats.rb new file mode 100644 index 000000000..6fc6cc0d1 --- /dev/null +++ b/scripts/run_user_stats.rb @@ -0,0 +1 @@ +UpdateUserStatsJob.perform_later From b1dae4c04bf7c587292add46ec39e99860fe2fca Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 15 May 2026 21:48:34 +0100 Subject: [PATCH 5/9] Start on the front end --- Gemfile | 2 + Gemfile.lock | 3 + app/assets/javascripts/moderator.js | 10 +++ app/helpers/moderator_helper.rb | 25 +++++++ app/views/moderator/pii_correlation.html.erb | 2 + .../moderator/pii_correlation.template.erb | 72 +++++++++++++++++++ 6 files changed, 114 insertions(+) diff --git a/Gemfile b/Gemfile index 5ac084766..0b7cef728 100644 --- a/Gemfile +++ b/Gemfile @@ -2,6 +2,7 @@ source 'https://rubygems.org' ruby '>= 3.3', '< 3.5' # Essential gems: servers, adapters, Rails + Rails requirements +gem 'bcrypt', '~> 3.1' gem 'coffee-rails', '~> 5.0.0' gem 'connection_pool', '< 3.0' # mperham/connection_pool#210 gem 'counter_culture', '~> 3.2' @@ -38,6 +39,7 @@ gem 'groupdate', '~> 6.1' # View stuff. gem 'diffy', '~> 3.4' +gem 'ipaddress', '~> 0.8' gem 'jbuilder', '~> 2.11' gem 'rqrcode', '~> 2.1' gem 'will_paginate', '~> 3.3' diff --git a/Gemfile.lock b/Gemfile.lock index 46abd4a4d..179c132ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,7 @@ GEM mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) io-console (0.8.2) + ipaddress (0.8.3) irb (1.17.0) pp (>= 0.6.0) prism (>= 1.3.0) @@ -497,6 +498,7 @@ DEPENDENCIES aws-sdk-s3 (~> 1.208) aws-sdk-sns (~> 1.72) aws-ses-v4 + bcrypt (~> 3.1) byebug (~> 11.1) capybara (~> 3.38) chartkick (~> 4.2) @@ -512,6 +514,7 @@ DEPENDENCIES flamegraph (~> 0.9) groupdate (~> 6.1) image_processing (~> 1.12) + ipaddress (~> 0.8) jbuilder (~> 2.11) jquery-rails (~> 4.5.0) letter_opener_web (~> 2.0) diff --git a/app/assets/javascripts/moderator.js b/app/assets/javascripts/moderator.js index dad768bc4..3ad6e0b2c 100644 --- a/app/assets/javascripts/moderator.js +++ b/app/assets/javascripts/moderator.js @@ -47,4 +47,14 @@ $(() => { checkbox.checked = action === 'all'; }); }); + + QPixel.DOM.addSelectorListener('submit', '#pii-correlation-form', async (ev) => { + ev.preventDefault(); + + const targetId = document.querySelector('input[name="target_id"]').value; + const resp = await QPixel.fetch(`${location.pathname}?format=template&target_id=${targetId}`); + const html = await resp.text(); + + document.querySelector('.js-correlation-container').innerHTML = html; + }); }); diff --git a/app/helpers/moderator_helper.rb b/app/helpers/moderator_helper.rb index a472c57ea..ff47355cd 100644 --- a/app/helpers/moderator_helper.rb +++ b/app/helpers/moderator_helper.rb @@ -16,4 +16,29 @@ def text_bg(cls, content = nil, **opts, &block) tag.span content, class: ["has-background-color-#{cls}", opts[:class]].join(' ') end end + + ## + # Split an IP address into an array of hashed octets (well, hexadecets for IPv6). + # @param ip [String] The IP address to process. + # @param salting_user [User] A user from which to source a salt for hashing. For hashes to be directly comparable, you + # must use the same user for each IP address you wish to compare, even if sourced from a different user. + # @return [[String, [String?]]] The IP address family, and an array of hashed octets. + def split_hash_ip(ip, salting_user) + begin + addr = IPAddress.parse(ip) + rescue ArgumentError + return ['', []] + end + splat = if addr.ipv6? + addr.hexs + else + addr.octets + end + salt = BCrypt::Password.new(salting_user.encrypted_password).salt + splat = splat.map { |p| Digest::SHA2.hexdigest(salt + p.to_s) } + [ + addr.ipv6? ? 'IPv6' : 'IPv4', + splat + ] + end end diff --git a/app/views/moderator/pii_correlation.html.erb b/app/views/moderator/pii_correlation.html.erb index 0b421ee43..c37be7c0c 100644 --- a/app/views/moderator/pii_correlation.html.erb +++ b/app/views/moderator/pii_correlation.html.erb @@ -16,3 +16,5 @@ + +
diff --git a/app/views/moderator/pii_correlation.template.erb b/app/views/moderator/pii_correlation.template.erb index e69de29bb..77ad53a12 100644 --- a/app/views/moderator/pii_correlation.template.erb +++ b/app/views/moderator/pii_correlation.template.erb @@ -0,0 +1,72 @@ +

Comparing with <%= user_link @target %>

+ +

Email address

+ + + + + + + + + + + + <% user_handle = Digest::SHA2.hexdigest(@user.email.split('@')[0]) %> + <% target_handle = Digest::SHA2.hexdigest(@target.email.split('@')[0]) %> + + + + + + + + + +
<%= @user.rtl_safe_username %><%= @target.rtl_safe_username %>
Handle + + <%= user_handle[0..7] %> + + + + <%= target_handle[0..7] %> + +
Domain + <%= Digest::SHA2.hexdigest(@user.email.split('@')[1])[0..7] %>
+ <% domain_users = Rails.cache.hget 'user_email_domains', @user.email.split('@')[1] %> + <% if domain_users.nil? %> + (unknown number of users) + <% else %> + Used by <%= pluralize(domain_users, 'user') %> + <% end %> +
+ <%= Digest::SHA2.hexdigest(@target.email.split('@')[1])[0..7] %>
+ <% domain_users = Rails.cache.hget 'user_email_domains', @target.email.split('@')[1] %> + <% if domain_users.nil? %> + (unknown number of users) + <% else %> + Used by <%= pluralize(domain_users, 'user') %> + <% end %> +

+ +

IP addresses

+ +<% user_current_family, user_current_ip = split_hash_ip(@user.current_sign_in_ip, @user) %> +<% target_current_family, target_current_ip = split_hash_ip(@target.current_sign_in_ip, @user) %> +Current sign-in
+ + + + + + + + + +
<%= @user.rtl_safe_username %> + <%= user_current_family %>
+ <%= user_current_ip.map { |p| p[0..3] }.join(user_current_family == 'IPv4' ? '.' : ':') %> +
<%= @target.rtl_safe_username %> + <%= target_current_family %>
+ <%= target_current_ip.map { |p| p[0..3] }.join(target_current_family == 'IPv4' ? '.' : ':') %> +
From 50c5eaf83a639723fc683d2f31ffb556a360d81e Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 18 May 2026 17:24:17 +0100 Subject: [PATCH 6/9] Finish correlation template --- Gemfile.lock | 5 ++ app/views/moderator/pii_correlation.html.erb | 5 ++ .../moderator/pii_correlation.template.erb | 86 +++++++++++++++---- lib/redis_cache_hash_methods.rb | 8 +- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 179c132ad..e4aaccc71 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,6 +270,7 @@ GEM rack (>= 2.2.3) rack-protection orm_adapter (0.5.0) + ostruct (0.6.3) parallel (2.0.1) parser (3.3.11.1) ast (~> 2.4.1) @@ -394,6 +395,9 @@ GEM rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) + ruby-prof (2.0.4) + base64 + ostruct ruby-progressbar (1.13.0) ruby-saml (1.18.1) nokogiri (>= 1.13.10) @@ -543,6 +547,7 @@ DEPENDENCIES rubocop (~> 1) rubocop-rails (~> 2.15) rubocop-rake (~> 0.7.1) + ruby-prof (~> 2.0) ruby-progressbar (~> 1.11) sass-rails (~> 6.0) selenium-webdriver (~> 4.7) diff --git a/app/views/moderator/pii_correlation.html.erb b/app/views/moderator/pii_correlation.html.erb index c37be7c0c..49f3c6dc3 100644 --- a/app/views/moderator/pii_correlation.html.erb +++ b/app/views/moderator/pii_correlation.html.erb @@ -17,4 +17,9 @@ +

+ Information is hashed to protect users' privacy. + Text highlighted in red indicates matching data. +

+
diff --git a/app/views/moderator/pii_correlation.template.erb b/app/views/moderator/pii_correlation.template.erb index 77ad53a12..16c8b4ee0 100644 --- a/app/views/moderator/pii_correlation.template.erb +++ b/app/views/moderator/pii_correlation.template.erb @@ -5,13 +5,13 @@ - <%= @user.rtl_safe_username %> - <%= @target.rtl_safe_username %> + Handle + Domain - Handle + <%= @user.rtl_safe_username %> <% user_handle = Digest::SHA2.hexdigest(@user.email.split('@')[0]) %> <% target_handle = Digest::SHA2.hexdigest(@target.email.split('@')[0]) %> @@ -19,14 +19,6 @@ <%= user_handle[0..7] %> - - - <%= target_handle[0..7] %> - - - - - Domain <%= Digest::SHA2.hexdigest(@user.email.split('@')[1])[0..7] %>
<% domain_users = Rails.cache.hget 'user_email_domains', @user.email.split('@')[1] %> @@ -36,6 +28,14 @@ Used by <%= pluralize(domain_users, 'user') %> <% end %> + + + <%= @target.rtl_safe_username %> + + + <%= target_handle[0..7] %> + + <%= Digest::SHA2.hexdigest(@target.email.split('@')[1])[0..7] %>
<% domain_users = Rails.cache.hget 'user_email_domains', @target.email.split('@')[1] %> @@ -52,21 +52,75 @@

IP addresses

<% user_current_family, user_current_ip = split_hash_ip(@user.current_sign_in_ip, @user) %> +<% user_joiner = user_current_family == 'IPv4' ? '.' : ':' %> <% target_current_family, target_current_ip = split_hash_ip(@target.current_sign_in_ip, @user) %> +<% target_joiner = target_current_family == 'IPv4' ? '.' : ':' %> Current sign-in
+ + + + + + + + + + + + + + + + + + + +
UserFamilyAddress
<%= @user.rtl_safe_username %><%= user_current_family %> + <% user_current_ip.map.with_index do |p, i| %> + + <%= p[0..3] %><%= user_joiner if i < user_current_ip.length - 1 %> + <% end %> +
<%= @target.rtl_safe_username %><%= target_current_family %> + <% target_current_ip.map.with_index do |p, i| %> + + <%= p[0..3] %><%= target_joiner if i < target_current_ip.length - 1 %> + <% end %> +

+ +<% user_last_family, user_last_ip = split_hash_ip(@user.last_sign_in_ip, @user) %> +<% user_joiner = user_last_family == 'IPv4' ? '.' : ':' %> +<% target_last_family, target_last_ip = split_hash_ip(@target.last_sign_in_ip, @user) %> +<% target_joiner = target_last_family == 'IPv4' ? '.' : ':' %> +Last sign-in
+ + + + + + + + + + + -
UserFamilyAddress
<%= @user.rtl_safe_username %><%= user_last_family %> - <%= user_current_family %>
- <%= user_current_ip.map { |p| p[0..3] }.join(user_current_family == 'IPv4' ? '.' : ':') %> + <% user_last_ip.map.with_index do |p, i| %> + + <%= p[0..3] %><%= user_joiner if i < user_last_ip.length - 1 %> + <% end %>
<%= @target.rtl_safe_username %><%= target_last_family %> - <%= target_current_family %>
- <%= target_current_ip.map { |p| p[0..3] }.join(target_current_family == 'IPv4' ? '.' : ':') %> + <% target_last_ip.map.with_index do |p, i| %> + + <%= p[0..3] %><%= target_joiner if i < target_last_ip.length - 1 %> + <% end %>
+ + \ No newline at end of file diff --git a/lib/redis_cache_hash_methods.rb b/lib/redis_cache_hash_methods.rb index b94923c8e..d080aca01 100644 --- a/lib/redis_cache_hash_methods.rb +++ b/lib/redis_cache_hash_methods.rb @@ -67,7 +67,7 @@ def hgetall(hash_key) # @return [Integer] The number of keys that were removed from the hash def hdel(hash_key, *keys) with_redis do |rd| - if keys.size.zero? + if keys.empty? rd.del hash_key else rd.hdel hash_key, *keys @@ -80,12 +80,12 @@ def hdel(hash_key, *keys) ## # Check a connection out of the connection pool and provides it to the block to run Redis commands. # @yield [Redis::Client] - def with_redis + def with_redis(&block) reject_unless_redis_cache! redis_cache_store = ActiveSupport::Cache::RedisCacheStore redis_cache = is_a?(redis_cache_store) ? self : underlying redis_cache.redis.with do |rd| - yield rd + block.call rd end end @@ -96,7 +96,7 @@ def with_redis def reject_unless_redis_cache! redis_cache_store = ActiveSupport::Cache::RedisCacheStore unless is_a?(redis_cache_store) || (respond_to?(:underlying) && underlying.is_a?(redis_cache_store)) - raise NotImplementedError, "This cache implementation is not backed by Redis and cannot use Hash methods." + raise NotImplementedError, 'This cache implementation is not backed by Redis and cannot use Hash methods.' end end end From 97e12df534b4710889065f96fc3edddfa6f13fcb Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 18 May 2026 17:28:23 +0100 Subject: [PATCH 7/9] Add to mod tools list --- app/views/users/mod.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/users/mod.html.erb b/app/views/users/mod.html.erb index 647bd7e1e..0ba0b4fc6 100644 --- a/app/views/users/mod.html.erb +++ b/app/views/users/mod.html.erb @@ -12,6 +12,7 @@
  • warnings and suspensions sent to user <% if @user.community_user.suspended? %>(includes lifting the suspension)<% end %>
  • warn or suspend user
  • <%= link_to 'vote summary', mod_vote_summary_path(@user) %>
  • +
  • <%= link_to 'compare PII', mod_pii_correlation_path(@user) %>
  • <% if current_user.developer %>
  • <%= link_to 'impersonate', start_impersonating_path(@user), class: 'is-yellow' %>
  • <% end %> From 5dcde6ed2c8e2231062887f5a95e9d67e7b871c9 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 18 May 2026 17:32:04 +0100 Subject: [PATCH 8/9] Add tests --- test/controllers/moderator_controller_test.rb | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/controllers/moderator_controller_test.rb b/test/controllers/moderator_controller_test.rb index 4b7eada40..00571f267 100644 --- a/test/controllers/moderator_controller_test.rb +++ b/test/controllers/moderator_controller_test.rb @@ -224,4 +224,28 @@ class ModeratorControllerTest < ActionController::TestCase assert_redirected_to mod_spammers_path assert_equal true, spammer.deleted end + + test 'pii correlation should be accessible' do + sign_in users(:moderator) + get :pii_correlation, params: { id: users(:standard_user).id } + assert_response(:success) + end + + test 'pii correlation should not be accessible to non-moderators' do + sign_in users(:standard_user) + get :pii_correlation, params: { id: users(:standard_user).id } + assert_response(:not_found) + end + + test 'pii correlation should not be accessible without sign-in' do + get :pii_correlation, params: { id: users(:standard_user).id } + assert_response(:not_found) + end + + test 'pii correlation template should work' do + sign_in users(:moderator) + get :pii_correlation, params: { id: users(:standard_user).id, format: 'template', + target_id: users(:spammer).id } + assert_response(:success) + end end From af047fbc97bf60cc363d4e1d8484dbf7111feefa Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 18 May 2026 18:06:38 +0100 Subject: [PATCH 9/9] Fix types and bundle --- Gemfile.lock | 5 ----- app/assets/javascripts/moderator.js | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e4aaccc71..179c132ad 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -270,7 +270,6 @@ GEM rack (>= 2.2.3) rack-protection orm_adapter (0.5.0) - ostruct (0.6.3) parallel (2.0.1) parser (3.3.11.1) ast (~> 2.4.1) @@ -395,9 +394,6 @@ GEM rubocop-rake (0.7.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) - ruby-prof (2.0.4) - base64 - ostruct ruby-progressbar (1.13.0) ruby-saml (1.18.1) nokogiri (>= 1.13.10) @@ -547,7 +543,6 @@ DEPENDENCIES rubocop (~> 1) rubocop-rails (~> 2.15) rubocop-rake (~> 0.7.1) - ruby-prof (~> 2.0) ruby-progressbar (~> 1.11) sass-rails (~> 6.0) selenium-webdriver (~> 4.7) diff --git a/app/assets/javascripts/moderator.js b/app/assets/javascripts/moderator.js index 3ad6e0b2c..b8cd53bdc 100644 --- a/app/assets/javascripts/moderator.js +++ b/app/assets/javascripts/moderator.js @@ -51,7 +51,7 @@ $(() => { QPixel.DOM.addSelectorListener('submit', '#pii-correlation-form', async (ev) => { ev.preventDefault(); - const targetId = document.querySelector('input[name="target_id"]').value; + const targetId = /** @type {HTMLInputElement}*/(document.querySelector('input[name="target_id"]')).value; const resp = await QPixel.fetch(`${location.pathname}?format=template&target_id=${targetId}`); const html = await resp.text();