From 2913cc73fc9a5f11d5cecc3b1f3709b916745f04 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 13:03:30 +0100 Subject: [PATCH 01/21] Block spammers on deletion --- app/controllers/users_controller.rb | 5 +- app/jobs/delete_user_job.rb | 21 ++ app/models/user.rb | 2 +- config/config/server_settings.yml | 9 +- config/config/spam_protection.yml | 3 + .../concerns/users/user_mod_tools_test.rb | 250 ++++++++++++++++++ test/controllers/users_controller_test.rb | 233 +--------------- test/fixtures/community_users.yml | 7 + test/fixtures/flags.yml | 8 + test/fixtures/posts.yml | 17 ++ test/fixtures/users.yml | 15 ++ test/jobs/delete_user_job_test.rb | 7 + 12 files changed, 348 insertions(+), 229 deletions(-) create mode 100644 app/jobs/delete_user_job.rb create mode 100644 config/config/spam_protection.yml create mode 100644 test/controllers/concerns/users/user_mod_tools_test.rb create mode 100644 test/jobs/delete_user_job_test.rb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 67306c404..db0f2e187 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -341,7 +341,10 @@ def soft_delete return end - @user.soft_delete(current_user) + max_rep = @user.community_users.maximum(:reputation) + check_threshold = AppConfig.spam_protection['deletion_block_max_rep'] + DeleteUserJob.with(user: @user, attribute_to: current_user, perform_spam_check: max_rep < check_threshold) + .perform_later else render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 return diff --git a/app/jobs/delete_user_job.rb b/app/jobs/delete_user_job.rb new file mode 100644 index 000000000..f009704d4 --- /dev/null +++ b/app/jobs/delete_user_job.rb @@ -0,0 +1,21 @@ +class DeleteUserJob < ApplicationJob + queue_as :default + + def perform(user, attribute_to, perform_spam_check: true) + user.soft_delete(attribute_to) + + if perform_spam_check + # Can't use model helper methods very easily here, because we want network-wide flags and that doesn't play + # nicely with default scopes. + flag_query = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: 'helpful' }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + if flag_query.any? + user.block('automatic block from spam check during deletion') + end + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 769d53983..aac66bed5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -453,7 +453,7 @@ def send_welcome_tour_message 'how this site works.', '/tour') end - def block(reason, length: 180.days, automatic: true) + def block(reason, length: 10.years, automatic: true) user_email = email user_ip = [last_sign_in_ip] diff --git a/config/config/server_settings.yml b/config/config/server_settings.yml index c788b315f..75ffd23f9 100644 --- a/config/config/server_settings.yml +++ b/config/config/server_settings.yml @@ -1,4 +1,11 @@ -registration_rate_limit: 300 +# Minimum number of seconds between registration attempts from the same IP. +registration_rate_limit: 3600 + +# Number of minutes that sudo mode will last for before asking for password re-entry. user_sudo_duration: 30 + +# Base domain for the network: if you have communities for a.example.com and b.example.com, this should be example.com. network_base_domain: codidact.com + +# File path on your server to which database backups will be saved while awaiting upload to S3. db_backups_path: /var/sql-backups diff --git a/config/config/spam_protection.yml b/config/config/spam_protection.yml new file mode 100644 index 000000000..30f05d8f4 --- /dev/null +++ b/config/config/spam_protection.yml @@ -0,0 +1,3 @@ +# When a user is deleted, helpful spam flags against the user's post will additionally cause the user to be fail-banned. +# Users with equal to or more than this reputation value in any community are exempt from this check. +deletion_block_max_rep: 100 diff --git a/test/controllers/concerns/users/user_mod_tools_test.rb b/test/controllers/concerns/users/user_mod_tools_test.rb new file mode 100644 index 000000000..cad45cec6 --- /dev/null +++ b/test/controllers/concerns/users/user_mod_tools_test.rb @@ -0,0 +1,250 @@ +module UserModToolsTest + extend ActiveSupport::Concern + + included do + test 'full_log should correctly apply single-type items filter' do + sign_in users(:moderator) + + model_map = { + 'posts' => Post, + 'comments' => Comment, + 'edits' => SuggestedEdit, + 'flags' => Flag, + 'warnings' => ModWarning + } + + model_map.each do |filter, model| + get :full_log, params: { id: users(:standard_user).id, filter: filter } + assert_response(:success) + items = assigns(:items) + + assert(items.all?(model)) + end + end + + test 'full_log\'s \'interesting\' filter should include deleted comments' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + deleted_comment = comments(:deleted) + + assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) + end + + test 'full_log\'s \'interesting\' filter should include declined flags' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + declined_flag = flags(:declined) + + assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) + end + + test 'role toggle should correctly grant & revoke moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, false + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, true + end + + test 'role toggle should correctly grant & revoke admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, false + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, true + end + + test 'role toggle should correctly grant & revoke global moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, true + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, false + end + + test 'role toggle should correctly grant & revoke global admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, true + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, false + end + + test 'full_log should only be accessible to mods or admins' do + mod = users(:moderator) + std = users(:standard_user) + + sign_in mod + get :full_log, params: { id: std.id } + assert_response(:success) + + sign_in std + get :full_log, params: { id: std.id } + assert_response(:not_found) + end + + test 'should allow moderator access to deleted account' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_account).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should allow moderator access to deleted profile' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_profile).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should get annotations' do + sign_in users(:admin) + get :annotations, params: { id: users(:standard_user).id } + assert_response(:success) + assert_not_nil assigns(:logs) + end + + test 'should annotate user' do + sign_in users(:admin) + post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } + assert_response(:found) + assert_redirected_to user_annotations_path(users(:standard_user)) + end + + test 'should get mod tools page' do + sign_in users(:moderator) + get :mod, params: { id: users(:standard_user).id } + assert_not_nil assigns(:user) + assert_response(:success) + end + + test 'should require authentication to access mod tools' do + sign_out :user + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'should require moderator status to access mod tools' do + sign_in users(:standard_user) + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'moderators and higher should be able to delete user profiles' do + std_usr = users(:standard_user) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_soft_delete_user('profile', std_usr) + @user = assigns(:user) + + assert_response(:success) + assert_not_nil @user + assert @user.community_user.deleted + end + end + + test 'should soft-delete user' do + sign_in users(:global_admin) + + try_soft_delete_user('user', users(:standard_user)) + + assert_response(:success) + assert_not_nil assigns(:user) + assert assigns(:user).deleted + end + + test 'only global moderators or admins should be able to soft-delete users' do + std_usr = users(:standard_user) + + ([nil] + users).each do |user| + if user.present? + sign_in(user) + end + + try_soft_delete_user('user', std_usr) + + if user&.at_least_global_moderator? + assert_json_success + elsif user&.at_least_moderator? + assert_json_failure(:forbidden) + else + assert_json_failure(:not_found) + end + end + end + + test 'should spam-block spammer on deletion' do + sign_in users(:global_admin) + spammer = users(:spammer) + + try_soft_delete_user('user', spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: spammer.email) + assert blocked_item.any?, + "Expected a BlockedItem for #{spammer.email} but none was found." + end + + test 'should not spam-block high-rep user on deletion' do + sign_in users(:global_admin) + high_rep_spammer = users(:high_rep_spammer) + + try_soft_delete_user('user', high_rep_spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: high_rep_spammer.email) + assert_not blocked_item.any?, + "Expected no BlockedItem for #{high_rep_spammer.email} but one was found." + end + end +end \ No newline at end of file diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 266c7f529..51243fe36 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -1,10 +1,12 @@ require 'test_helper' require_relative 'concerns/users/users_abilities_test' +require_relative 'concerns/users/user_mod_tools_test' class UsersControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers include ApplicationHelper include UsersAbilitiesTest + include UserModToolsTest test 'should get index' do [:html, :json].each do |format| @@ -62,72 +64,6 @@ class UsersControllerTest < ActionController::TestCase assert_equal assigns(:total_post_count), post_count end - test 'should get mod tools page' do - sign_in users(:moderator) - get :mod, params: { id: users(:standard_user).id } - assert_not_nil assigns(:user) - assert_response(:success) - end - - test 'should require authentication to access mod tools' do - sign_out :user - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'should require moderator status to access mod tools' do - sign_in users(:standard_user) - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'moderators and higher should be able to delete user profiles' do - std_usr = users(:standard_user) - - users.select(&:at_least_moderator?).each do |user| - sign_in(user) - - try_soft_delete_user('profile', std_usr) - @user = assigns(:user) - - assert_response(:success) - assert_not_nil @user - assert @user.community_user.deleted - end - end - - test 'should soft-delete user' do - sign_in users(:global_admin) - - try_soft_delete_user('user', users(:standard_user)) - - assert_response(:success) - assert_not_nil assigns(:user) - assert assigns(:user).deleted - end - - test 'only global moderators or admins should be able to soft-delete users' do - std_usr = users(:standard_user) - - ([nil] + users).each do |user| - if user.present? - sign_in(user) - end - - try_soft_delete_user('user', std_usr) - - if user&.at_least_global_moderator? - assert_json_success - elsif user&.at_least_moderator? - assert_json_failure(:forbidden) - else - assert_json_failure(:not_found) - end - end - end - test 'should require authentication to soft-delete user' do sign_out :user @@ -282,20 +218,6 @@ class UsersControllerTest < ActionController::TestCase assert_response(:not_found) end - test 'should get annotations' do - sign_in users(:admin) - get :annotations, params: { id: users(:standard_user).id } - assert_response(:success) - assert_not_nil assigns(:logs) - end - - test 'should annotate user' do - sign_in users(:admin) - post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } - assert_response(:found) - assert_redirected_to user_annotations_path(users(:standard_user)) - end - test 'should deny access to deleted account' do get :show, params: { id: users(:deleted_account).id } assert_response(:not_found) @@ -307,20 +229,6 @@ class UsersControllerTest < ActionController::TestCase assert_not_nil assigns(:user) end - test 'should allow moderator access to deleted account' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_account).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - - test 'should allow moderator access to deleted profile' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_profile).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - test 'my_activity should redirect to user activity or to sign in for anonymous access' do users.each do |user| sign_in user @@ -404,91 +312,6 @@ class UsersControllerTest < ActionController::TestCase assert_equal data['username'], mod.username end - test 'role toggle should correctly grant & revoke moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, false - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, true - end - - test 'role toggle should correctly grant & revoke admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, false - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, true - end - - test 'role toggle should correctly grant & revoke global moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, true - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, false - end - - test 'role toggle should correctly grant & revoke global admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, true - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, false - end - - test 'full_log should only be accessible to mods or admins' do - mod = users(:moderator) - std = users(:standard_user) - - sign_in mod - get :full_log, params: { id: std.id } - assert_response(:success) - - sign_in std - get :full_log, params: { id: std.id } - assert_response(:not_found) - end - test 'activity should correctly apply single-type items filter' do std = users(:standard_user) @@ -545,50 +368,6 @@ class UsersControllerTest < ActionController::TestCase end end - test 'full_log should correctly apply single-type items filter' do - sign_in users(:moderator) - - model_map = { - 'posts' => Post, - 'comments' => Comment, - 'edits' => SuggestedEdit, - 'flags' => Flag, - 'warnings' => ModWarning - } - - model_map.each do |filter, model| - get :full_log, params: { id: users(:standard_user).id, filter: filter } - assert_response(:success) - items = assigns(:items) - - assert(items.all?(model)) - end - end - - test 'full_log\'s \'interesting\' filter should include deleted comments' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - deleted_comment = comments(:deleted) - - assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) - end - - test 'full_log\'s \'interesting\' filter should include declined flags' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - declined_flag = flags(:declined) - - assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) - end - test 'set_preference should correclty save valid preferences' do sign_in users(:standard_user) @@ -639,9 +418,11 @@ def create_other_user # @param type [String] deletion type (user or profile) # @param user [User] user to soft delete def try_soft_delete_user(type, user) - delete :soft_delete, params: { id: user.id, - type: type, - format: :json } + perform_enqueued_jobs do + delete :soft_delete, params: { id: user.id, + type: type, + format: :json } + end end def try_save_preference(name, value, community: nil) diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index a333d475a..2e9817bbd 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -154,3 +154,10 @@ sample_merge_target: is_admin: false is_moderator: false reputation: 24 + +high_rep_spammer: + user: high_rep_spammer + community: sample + is_admin: false + is_moderator: false + reputation: 1000 diff --git a/test/fixtures/flags.yml b/test/fixtures/flags.yml index 80ef66b17..a9240110b 100644 --- a/test/fixtures/flags.yml +++ b/test/fixtures/flags.yml @@ -52,3 +52,11 @@ helpful_on_spam_question: user: standard_user community: sample status: helpful + +helpful_on_high_rep_spam: + reason: It's obviously spam + post: deleted_high_rep_spam (Post) + post_flag_type: spam + user: standard_user + community: sample + status: helpful diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 63a1dee77..5a3833e8e 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -577,6 +577,23 @@ deleted_spam_question: deleted_at: 2019-01-01T00:00:00.000000Z deleted_by: moderator +deleted_high_rep_spam: + post_type: question + title: Best spam in the neighborhood! Call 555-55-55 + body: Find the best spam in your neighborhood on Spam Central + body_markdown:

Find the best spam in your neighborhood on Spam Central

+ tags_cache: + - discussion + tags: + - discussion + user: high_rep_spammer + community: sample + category: main + license: cc_by_sa + deleted: true + deleted_at: 2019-01-01T00:00:00.000000Z + deleted_by: moderator + without_new_thread_followers: post_type: question title: This post does not have any new thread followers diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 6d3099919..4d919baf1 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -174,6 +174,21 @@ spammer: is_global_moderator: false confirmed_at: 2020-01-01T00:00:00.000000Z +high_rep_spammer: + email: high-rep-spammer@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: ReppySpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + profile_spammer: email: profile-spammer@example.com encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' diff --git a/test/jobs/delete_user_job_test.rb b/test/jobs/delete_user_job_test.rb new file mode 100644 index 000000000..4d3dfff29 --- /dev/null +++ b/test/jobs/delete_user_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class DeleteUserJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 15c9eb86c99e51790913cbd2a8f242e8c5fe7a77 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 13:43:23 +0100 Subject: [PATCH 02/21] That's for mailers --- app/controllers/users_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index db0f2e187..2e6d46520 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -343,8 +343,8 @@ def soft_delete max_rep = @user.community_users.maximum(:reputation) check_threshold = AppConfig.spam_protection['deletion_block_max_rep'] - DeleteUserJob.with(user: @user, attribute_to: current_user, perform_spam_check: max_rep < check_threshold) - .perform_later + DeleteUserJob.perform_later(user: @user, attribute_to: current_user, + perform_spam_check: max_rep < check_threshold) else render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 return From c272d8a080931f1c50bd63a429c23156a65447e4 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 13:45:14 +0100 Subject: [PATCH 03/21] Missed the ability fixtures --- test/fixtures/user_abilities.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/fixtures/user_abilities.yml b/test/fixtures/user_abilities.yml index 945e33ce7..c82618829 100644 --- a/test/fixtures/user_abilities.yml +++ b/test/fixtures/user_abilities.yml @@ -140,3 +140,11 @@ profile_spammer_everyone: profile_spammer_unrestricted: community_user: sample_profile_spammer ability: unrestricted + +hrs_eo: + community_user: high_rep_spammer + ability: everyone + +hrs_ur: + community_user: high_rep_spammer + ability: unrestricted From 6a86506e0c0b8a6ebdd25e70aa0fb1b66521e49a Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 13:45:59 +0100 Subject: [PATCH 04/21] Rubocop --- test/controllers/concerns/users/user_mod_tools_test.rb | 2 +- test/jobs/delete_user_job_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/controllers/concerns/users/user_mod_tools_test.rb b/test/controllers/concerns/users/user_mod_tools_test.rb index cad45cec6..5579d1218 100644 --- a/test/controllers/concerns/users/user_mod_tools_test.rb +++ b/test/controllers/concerns/users/user_mod_tools_test.rb @@ -247,4 +247,4 @@ module UserModToolsTest "Expected no BlockedItem for #{high_rep_spammer.email} but one was found." end end -end \ No newline at end of file +end diff --git a/test/jobs/delete_user_job_test.rb b/test/jobs/delete_user_job_test.rb index 4d3dfff29..74786ef5e 100644 --- a/test/jobs/delete_user_job_test.rb +++ b/test/jobs/delete_user_job_test.rb @@ -1,4 +1,4 @@ -require "test_helper" +require 'test_helper' class DeleteUserJobTest < ActiveJob::TestCase # test "the truth" do From a08ed7fec206ff2d2d2a813c1156bab2d2f16b31 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 13:52:50 +0100 Subject: [PATCH 05/21] Those aren't keyword arguments --- app/controllers/users_controller.rb | 3 +-- app/jobs/delete_user_job.rb | 7 +++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 2e6d46520..d08bd18fe 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -343,8 +343,7 @@ def soft_delete max_rep = @user.community_users.maximum(:reputation) check_threshold = AppConfig.spam_protection['deletion_block_max_rep'] - DeleteUserJob.perform_later(user: @user, attribute_to: current_user, - perform_spam_check: max_rep < check_threshold) + DeleteUserJob.perform_later(@user, current_user, perform_spam_check: max_rep < check_threshold) else render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 return diff --git a/app/jobs/delete_user_job.rb b/app/jobs/delete_user_job.rb index f009704d4..e6bc95d4a 100644 --- a/app/jobs/delete_user_job.rb +++ b/app/jobs/delete_user_job.rb @@ -1,6 +1,13 @@ class DeleteUserJob < ApplicationJob queue_as :default + ## + # Perform a network-wide soft-deletion of a user account. Also optionally checks for helpful spam flags against + # the target user and applies a spam block if found. The caller is responsible for managing thresholds for this spam + # check. + # @param user [User] user to soft-delete + # @param attribute_to [User] the user performing the deletion + # @param perform_spam_check [Boolean] whether to perform the spam check def perform(user, attribute_to, perform_spam_check: true) user.soft_delete(attribute_to) From 4e5c1e95c022986aeb1e4047db2c0a5beef736fb Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Fri, 14 Aug 2026 16:22:55 +0100 Subject: [PATCH 06/21] Add tests --- app/jobs/delete_user_job.rb | 4 +- .../concerns/users/user_mod_tools_test.rb | 250 ----------------- .../concerns/users/user_test_helpers.rb | 34 +++ .../concerns/users/users_abilities_test.rb | 54 ---- test/controllers/users/user_mod_tools_test.rb | 252 ++++++++++++++++++ .../controllers/users/users_abilities_test.rb | 56 ++++ test/controllers/users_controller_test.rb | 35 +-- 7 files changed, 346 insertions(+), 339 deletions(-) delete mode 100644 test/controllers/concerns/users/user_mod_tools_test.rb create mode 100644 test/controllers/concerns/users/user_test_helpers.rb delete mode 100644 test/controllers/concerns/users/users_abilities_test.rb create mode 100644 test/controllers/users/user_mod_tools_test.rb create mode 100644 test/controllers/users/users_abilities_test.rb diff --git a/app/jobs/delete_user_job.rb b/app/jobs/delete_user_job.rb index e6bc95d4a..e4b4e87a6 100644 --- a/app/jobs/delete_user_job.rb +++ b/app/jobs/delete_user_job.rb @@ -9,8 +9,6 @@ class DeleteUserJob < ApplicationJob # @param attribute_to [User] the user performing the deletion # @param perform_spam_check [Boolean] whether to perform the spam check def perform(user, attribute_to, perform_spam_check: true) - user.soft_delete(attribute_to) - if perform_spam_check # Can't use model helper methods very easily here, because we want network-wide flags and that doesn't play # nicely with default scopes. @@ -24,5 +22,7 @@ def perform(user, attribute_to, perform_spam_check: true) user.block('automatic block from spam check during deletion') end end + + user.soft_delete(attribute_to) end end diff --git a/test/controllers/concerns/users/user_mod_tools_test.rb b/test/controllers/concerns/users/user_mod_tools_test.rb deleted file mode 100644 index 5579d1218..000000000 --- a/test/controllers/concerns/users/user_mod_tools_test.rb +++ /dev/null @@ -1,250 +0,0 @@ -module UserModToolsTest - extend ActiveSupport::Concern - - included do - test 'full_log should correctly apply single-type items filter' do - sign_in users(:moderator) - - model_map = { - 'posts' => Post, - 'comments' => Comment, - 'edits' => SuggestedEdit, - 'flags' => Flag, - 'warnings' => ModWarning - } - - model_map.each do |filter, model| - get :full_log, params: { id: users(:standard_user).id, filter: filter } - assert_response(:success) - items = assigns(:items) - - assert(items.all?(model)) - end - end - - test 'full_log\'s \'interesting\' filter should include deleted comments' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - deleted_comment = comments(:deleted) - - assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) - end - - test 'full_log\'s \'interesting\' filter should include declined flags' do - sign_in users(:moderator) - - get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } - assert_response(:success) - items = assigns(:items) - - declined_flag = flags(:declined) - - assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) - end - - test 'role toggle should correctly grant & revoke moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, false - - post :role_toggle, params: { id: mod.id, role: 'mod' } - assert_response(:success) - - mod.reload - assert_equal mod.moderator?, true - end - - test 'role toggle should correctly grant & revoke admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, false - - post :role_toggle, params: { id: admin.id, role: 'admin' } - assert_response(:success) - - admin.reload - assert_equal admin.admin?, true - end - - test 'role toggle should correctly grant & revoke global moderator role' do - sign_in users(:global_admin) - - mod = users(:moderator) - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, true - - post :role_toggle, params: { id: mod.id, role: 'mod_global' } - assert_response(:success) - - mod.reload - assert_equal mod.global_moderator?, false - end - - test 'role toggle should correctly grant & revoke global admin role' do - sign_in users(:global_admin) - - admin = users(:admin) - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, true - - post :role_toggle, params: { id: admin.id, role: 'admin_global' } - assert_response(:success) - - admin.reload - assert_equal admin.global_admin?, false - end - - test 'full_log should only be accessible to mods or admins' do - mod = users(:moderator) - std = users(:standard_user) - - sign_in mod - get :full_log, params: { id: std.id } - assert_response(:success) - - sign_in std - get :full_log, params: { id: std.id } - assert_response(:not_found) - end - - test 'should allow moderator access to deleted account' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_account).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - - test 'should allow moderator access to deleted profile' do - sign_in users(:moderator) - get :show, params: { id: users(:deleted_profile).id } - assert_response(:success) - assert_not_nil assigns(:user) - end - - test 'should get annotations' do - sign_in users(:admin) - get :annotations, params: { id: users(:standard_user).id } - assert_response(:success) - assert_not_nil assigns(:logs) - end - - test 'should annotate user' do - sign_in users(:admin) - post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } - assert_response(:found) - assert_redirected_to user_annotations_path(users(:standard_user)) - end - - test 'should get mod tools page' do - sign_in users(:moderator) - get :mod, params: { id: users(:standard_user).id } - assert_not_nil assigns(:user) - assert_response(:success) - end - - test 'should require authentication to access mod tools' do - sign_out :user - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'should require moderator status to access mod tools' do - sign_in users(:standard_user) - get :mod, params: { id: users(:standard_user).id } - assert_nil assigns(:user) - assert_response(:not_found) - end - - test 'moderators and higher should be able to delete user profiles' do - std_usr = users(:standard_user) - - users.select(&:at_least_moderator?).each do |user| - sign_in(user) - - try_soft_delete_user('profile', std_usr) - @user = assigns(:user) - - assert_response(:success) - assert_not_nil @user - assert @user.community_user.deleted - end - end - - test 'should soft-delete user' do - sign_in users(:global_admin) - - try_soft_delete_user('user', users(:standard_user)) - - assert_response(:success) - assert_not_nil assigns(:user) - assert assigns(:user).deleted - end - - test 'only global moderators or admins should be able to soft-delete users' do - std_usr = users(:standard_user) - - ([nil] + users).each do |user| - if user.present? - sign_in(user) - end - - try_soft_delete_user('user', std_usr) - - if user&.at_least_global_moderator? - assert_json_success - elsif user&.at_least_moderator? - assert_json_failure(:forbidden) - else - assert_json_failure(:not_found) - end - end - end - - test 'should spam-block spammer on deletion' do - sign_in users(:global_admin) - spammer = users(:spammer) - - try_soft_delete_user('user', spammer) - - blocked_item = BlockedItem.where(item_type: 'email', value: spammer.email) - assert blocked_item.any?, - "Expected a BlockedItem for #{spammer.email} but none was found." - end - - test 'should not spam-block high-rep user on deletion' do - sign_in users(:global_admin) - high_rep_spammer = users(:high_rep_spammer) - - try_soft_delete_user('user', high_rep_spammer) - - blocked_item = BlockedItem.where(item_type: 'email', value: high_rep_spammer.email) - assert_not blocked_item.any?, - "Expected no BlockedItem for #{high_rep_spammer.email} but one was found." - end - end -end diff --git a/test/controllers/concerns/users/user_test_helpers.rb b/test/controllers/concerns/users/user_test_helpers.rb new file mode 100644 index 000000000..26df3ba69 --- /dev/null +++ b/test/controllers/concerns/users/user_test_helpers.rb @@ -0,0 +1,34 @@ +module UserTestHelpers + extend ActiveSupport::Concern + + included do + private + + def create_other_user + other_community = Community.create(host: 'other.qpixel.com', name: 'Other') + RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) + other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') + other_user.community_users.create!(community: other_community) + other_user + end + + # @param type [String] deletion type (user or profile) + # @param user [User] user to soft delete + def try_soft_delete_user(type, user) + perform_enqueued_jobs do + delete :soft_delete, params: { id: user.id, + type: type, + format: :json } + end + end + + def try_save_preference(name, value, community: nil) + post :set_preference, params: { + community: community, + name: name, + value: value, + format: :json + } + end + end +end diff --git a/test/controllers/concerns/users/users_abilities_test.rb b/test/controllers/concerns/users/users_abilities_test.rb deleted file mode 100644 index 295e922e4..000000000 --- a/test/controllers/concerns/users/users_abilities_test.rb +++ /dev/null @@ -1,54 +0,0 @@ -module UsersAbilitiesTest - extend ActiveSupport::Concern - - included do - test 'mod_privilege_action: grant as new' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:flag_curate).internal_id, do: 'grant', - id: users(:standard_user).id } - assert_response(:success) - assert users(:standard_user).community_user.ability?(abilities(:flag_curate).internal_id), - "User was not granted expected ability #{abilities(:flag_curate).internal_id}" - end - - test 'mod_privilege_action: grant as unsuspend' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:edit_posts).internal_id, do: 'grant', - id: users(:enabled_2fa).id } - assert_response(:success) - assert users(:enabled_2fa).community_user.ability?(abilities(:edit_posts).internal_id), - "User was not granted expected ability #{abilities(:edit_posts).internal_id}" - end - - test 'mod_privilege_action: suspend' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'suspend', - id: users(:standard_user).id, duration: -1 } - assert_response(:success) - assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), - "User still has ability #{abilities(:unrestricted).internal_id} that should have been suspended" - end - - test 'mod_privilege_action: delete' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'delete', - id: users(:standard_user).id } - assert_response(:success) - assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), - "User still has ability #{abilities(:unrestricted).internal_id} that should have been deleted" - end - - test 'mod_privilege_action: unrecognized action' do - sign_in users(:moderator) - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', - id: users(:standard_user).id } - assert_response(:not_found) - end - - test 'mod_privilege_action: require moderator' do - post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', - id: users(:standard_user).id } - assert_response(:not_found) - end - end -end diff --git a/test/controllers/users/user_mod_tools_test.rb b/test/controllers/users/user_mod_tools_test.rb new file mode 100644 index 000000000..5e0fa9b9b --- /dev/null +++ b/test/controllers/users/user_mod_tools_test.rb @@ -0,0 +1,252 @@ +require 'test_helper' +require_relative '../concerns/users/user_test_helpers' + +class UsersControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include UserTestHelpers + + test 'full_log should correctly apply single-type items filter' do + sign_in users(:moderator) + + model_map = { + 'posts' => Post, + 'comments' => Comment, + 'edits' => SuggestedEdit, + 'flags' => Flag, + 'warnings' => ModWarning + } + + model_map.each do |filter, model| + get :full_log, params: { id: users(:standard_user).id, filter: filter } + assert_response(:success) + items = assigns(:items) + + assert(items.all?(model)) + end + end + + test 'full_log\'s \'interesting\' filter should include deleted comments' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + deleted_comment = comments(:deleted) + + assert(items.any? { |x| x.instance_of?(Comment) && x.id == deleted_comment.id }) + end + + test 'full_log\'s \'interesting\' filter should include declined flags' do + sign_in users(:moderator) + + get :full_log, params: { id: users(:standard_user).id, filter: 'interesting' } + assert_response(:success) + items = assigns(:items) + + declined_flag = flags(:declined) + + assert(items.any? { |x| x.instance_of?(Flag) && x.id == declined_flag.id }) + end + + test 'role toggle should correctly grant & revoke moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, false + + post :role_toggle, params: { id: mod.id, role: 'mod' } + assert_response(:success) + + mod.reload + assert_equal mod.moderator?, true + end + + test 'role toggle should correctly grant & revoke admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, false + + post :role_toggle, params: { id: admin.id, role: 'admin' } + assert_response(:success) + + admin.reload + assert_equal admin.admin?, true + end + + test 'role toggle should correctly grant & revoke global moderator role' do + sign_in users(:global_admin) + + mod = users(:moderator) + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, true + + post :role_toggle, params: { id: mod.id, role: 'mod_global' } + assert_response(:success) + + mod.reload + assert_equal mod.global_moderator?, false + end + + test 'role toggle should correctly grant & revoke global admin role' do + sign_in users(:global_admin) + + admin = users(:admin) + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, true + + post :role_toggle, params: { id: admin.id, role: 'admin_global' } + assert_response(:success) + + admin.reload + assert_equal admin.global_admin?, false + end + + test 'full_log should only be accessible to mods or admins' do + mod = users(:moderator) + std = users(:standard_user) + + sign_in mod + get :full_log, params: { id: std.id } + assert_response(:success) + + sign_in std + get :full_log, params: { id: std.id } + assert_response(:not_found) + end + + test 'should allow moderator access to deleted account' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_account).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should allow moderator access to deleted profile' do + sign_in users(:moderator) + get :show, params: { id: users(:deleted_profile).id } + assert_response(:success) + assert_not_nil assigns(:user) + end + + test 'should get annotations' do + sign_in users(:admin) + get :annotations, params: { id: users(:standard_user).id } + assert_response(:success) + assert_not_nil assigns(:logs) + end + + test 'should annotate user' do + sign_in users(:admin) + post :annotate, params: { id: users(:standard_user).id, comment: 'some words' } + assert_response(:found) + assert_redirected_to user_annotations_path(users(:standard_user)) + end + + test 'should get mod tools page' do + sign_in users(:moderator) + get :mod, params: { id: users(:standard_user).id } + assert_not_nil assigns(:user) + assert_response(:success) + end + + test 'should require authentication to access mod tools' do + sign_out :user + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'should require moderator status to access mod tools' do + sign_in users(:standard_user) + get :mod, params: { id: users(:standard_user).id } + assert_nil assigns(:user) + assert_response(:not_found) + end + + test 'moderators and higher should be able to delete user profiles' do + std_usr = users(:standard_user) + + users.select(&:at_least_moderator?).each do |user| + sign_in(user) + + try_soft_delete_user('profile', std_usr) + @user = assigns(:user) + + assert_response(:success) + assert_not_nil @user + assert @user.community_user.deleted + end + end + + test 'should soft-delete user' do + sign_in users(:global_admin) + + try_soft_delete_user('user', users(:standard_user)) + + assert_response(:success) + assert_not_nil assigns(:user) + assert assigns(:user).reload.deleted + end + + test 'only global moderators or admins should be able to soft-delete users' do + std_usr = users(:standard_user) + + ([nil] + users).each do |user| + if user.present? + sign_in(user) + end + + try_soft_delete_user('user', std_usr) + + if user&.at_least_global_moderator? + assert_json_success + elsif user&.at_least_moderator? + assert_json_failure(:forbidden) + else + assert_json_failure(:not_found) + end + end + end + + test 'should spam-block spammer on deletion' do + sign_in users(:global_admin) + spammer = users(:spammer) + + try_soft_delete_user('user', spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: spammer.email) + assert blocked_item.any?, + "Expected a BlockedItem for #{spammer.email} but none was found." + end + + test 'should not spam-block high-rep user on deletion' do + sign_in users(:global_admin) + high_rep_spammer = users(:high_rep_spammer) + + try_soft_delete_user('user', high_rep_spammer) + + blocked_item = BlockedItem.where(item_type: 'email', value: high_rep_spammer.email) + assert_not blocked_item.any?, + "Expected no BlockedItem for #{high_rep_spammer.email} but one was found." + end +end diff --git a/test/controllers/users/users_abilities_test.rb b/test/controllers/users/users_abilities_test.rb new file mode 100644 index 000000000..854329db6 --- /dev/null +++ b/test/controllers/users/users_abilities_test.rb @@ -0,0 +1,56 @@ +require 'test_helper' +require_relative '../concerns/users/user_test_helpers' + +class UsersControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + include UserTestHelpers + + test 'mod_privilege_action: grant as new' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:flag_curate).internal_id, do: 'grant', + id: users(:standard_user).id } + assert_response(:success) + assert users(:standard_user).community_user.ability?(abilities(:flag_curate).internal_id), + "User was not granted expected ability #{abilities(:flag_curate).internal_id}" + end + + test 'mod_privilege_action: grant as unsuspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:edit_posts).internal_id, do: 'grant', + id: users(:enabled_2fa).id } + assert_response(:success) + assert users(:enabled_2fa).community_user.ability?(abilities(:edit_posts).internal_id), + "User was not granted expected ability #{abilities(:edit_posts).internal_id}" + end + + test 'mod_privilege_action: suspend' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'suspend', + id: users(:standard_user).id, duration: -1 } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been suspended" + end + + test 'mod_privilege_action: delete' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'delete', + id: users(:standard_user).id } + assert_response(:success) + assert_not users(:standard_user).community_user.ability?(abilities(:unrestricted).internal_id), + "User still has ability #{abilities(:unrestricted).internal_id} that should have been deleted" + end + + test 'mod_privilege_action: unrecognized action' do + sign_in users(:moderator) + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end + + test 'mod_privilege_action: require moderator' do + post :mod_privilege_action, params: { ability: abilities(:unrestricted).internal_id, do: 'unrecognized', + id: users(:standard_user).id } + assert_response(:not_found) + end +end diff --git a/test/controllers/users_controller_test.rb b/test/controllers/users_controller_test.rb index 51243fe36..d462664d0 100644 --- a/test/controllers/users_controller_test.rb +++ b/test/controllers/users_controller_test.rb @@ -1,12 +1,10 @@ require 'test_helper' -require_relative 'concerns/users/users_abilities_test' -require_relative 'concerns/users/user_mod_tools_test' +require_relative 'concerns/users/user_test_helpers' class UsersControllerTest < ActionController::TestCase include Devise::Test::ControllerHelpers include ApplicationHelper - include UsersAbilitiesTest - include UserModToolsTest + include UserTestHelpers test 'should get index' do [:html, :json].each do |format| @@ -404,33 +402,4 @@ class UsersControllerTest < ActionController::TestCase assert_not_nil parsed_body['message'] end end - - private - - def create_other_user - other_community = Community.create(host: 'other.qpixel.com', name: 'Other') - RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) - other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') - other_user.community_users.create!(community: other_community) - other_user - end - - # @param type [String] deletion type (user or profile) - # @param user [User] user to soft delete - def try_soft_delete_user(type, user) - perform_enqueued_jobs do - delete :soft_delete, params: { id: user.id, - type: type, - format: :json } - end - end - - def try_save_preference(name, value, community: nil) - post :set_preference, params: { - community: community, - name: name, - value: value, - format: :json - } - end end From 3e0ab42e6f392838a31df432b57c86a5bc1afcbe Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 11:43:25 +0100 Subject: [PATCH 07/21] Move existing creation validations to concern --- app/controllers/posts_controller.rb | 31 +++------------- .../concerns/post_creation_validations.rb | 36 +++++++++++++++++++ app/models/post.rb | 1 + 3 files changed, 41 insertions(+), 27 deletions(-) create mode 100644 app/models/concerns/post_creation_validations.rb diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 53f6561cb..eb7aea3a7 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -30,7 +30,7 @@ def new end if @post_type.system? - check_permissions + check_permissions! # return # uncomment if you add more code after this end end @@ -60,31 +60,8 @@ def create @post = Post.new(post_params.merge(user: current_user, body: helpers.rendered_post(:post, :body_markdown), category: @category, post_type: @post_type, parent: @parent)) - if @post.title? && (@post.title.include? '$$') - flash[:danger] = I18n.t 'posts.no_block_mathjax_title' - render :new, status: :bad_request - return - end - - if @post_type.has_parent? && @parent.nil? - flash[:danger] = helpers.i18ns('posts.type_requires_parent', type: @post_type.name) - redirect_back fallback_location: root_path - return - end - - if @post_type.has_category? && @category.nil? && @parent.nil? - flash[:danger] = helpers.i18ns('posts.type_requires_category', type: @post_type.name) - redirect_back fallback_location: root_path - return - end - - if @category.present? && !current_user.can_post_in?(@category) - @post.errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: @category.name)) - render :new, status: :forbidden - return - end - - if @post_type.system? && !check_permissions + # Not a validation: check_permissions! calls verify_* methods in turn, which render/redirect. + if @post_type.system? && !check_permissions! return end @@ -738,7 +715,7 @@ def set_scoped_post @post = Post.find(params[:id]) end - def check_permissions + def check_permissions! if @post.post_type_id == HelpDoc.post_type_id verify_moderator elsif @post.post_type_id == PolicyDoc.post_type_id diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb new file mode 100644 index 000000000..a7f805923 --- /dev/null +++ b/app/models/concerns/post_creation_validations.rb @@ -0,0 +1,36 @@ +module PostCreationValidations + extend ActiveSupport::Concern + + included do + validate :no_mathjax_in_title, on: :create + validate :post_type_requires_parent, on: :create + validate :post_type_has_category, on: :create + validate :can_post_in_category, on: :create + + private + + def no_mathjax_in_title + if title? && title.include?('$$') + errors.add(:base, I18n.t('posts.no_block_mathjax_title')) + end + end + + def post_type_requires_parent + if post_type.has_parent? && parent.nil? + errors.add(:base, helpers.i18ns('posts.type_requires_parent', type: post_type.name)) + end + end + + def post_type_has_category + if post_type.has_category? && category.nil? && parent.nil? + errors.add(:base, helpers.i18ns('posts.type_requires_category', type: post_type.name)) + end + end + + def can_post_in_category + if category.present? && !current_user.can_post_in?(category) + errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) + end + end + end +end diff --git a/app/models/post.rb b/app/models/post.rb index fa5bb9ba8..fdb571c38 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -3,6 +3,7 @@ class Post < ApplicationRecord include Lockable include PostNormalizations include PostValidations + include PostCreationValidations include SoftDeletable include Timestamped include UserSortable From f32f2a39493274ec505a3f077443977e74a4ce26 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 12:01:57 +0100 Subject: [PATCH 08/21] Block identical post spam --- .../concerns/post_creation_validations.rb | 14 ++++++++++- config/config/spam_protection.yml | 4 ++++ test/controllers/posts/create_test.rb | 23 +++++++++++++++---- test/fixtures/posts.yml | 14 +++++++++++ test/test_helper.rb | 16 ++++++++++--- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index a7f805923..b4641e382 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -6,6 +6,7 @@ module PostCreationValidations validate :post_type_requires_parent, on: :create validate :post_type_has_category, on: :create validate :can_post_in_category, on: :create + validate :identical_post_spam, on: :create private @@ -28,9 +29,20 @@ def post_type_has_category end def can_post_in_category - if category.present? && !current_user.can_post_in?(category) + if category.present? && !user.can_post_in?(category) errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) end end + + def identical_post_spam + threshold = AppConfig.spam_protection['identical_post_spam_threshold'] + prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count + unless prev_non_deleted_count >= threshold + identical_posts = Post.unscoped.where(user: user, body_markdown: body_markdown).where.not(id: id) + if identical_posts.any? + errors.add(:base, ApplicationRecord.useful_err_msg.sample) + end + end + end end end diff --git a/config/config/spam_protection.yml b/config/config/spam_protection.yml index 30f05d8f4..8666bf554 100644 --- a/config/config/spam_protection.yml +++ b/config/config/spam_protection.yml @@ -1,3 +1,7 @@ # When a user is deleted, helpful spam flags against the user's post will additionally cause the user to be fail-banned. # Users with equal to or more than this reputation value in any community are exempt from this check. deletion_block_max_rep: 100 + +# Users are blocked from posting something identical to any of their previous posts, unless they have equal to or +# greater than this number of non-deleted posts anywhere on the network. +identical_post_spam_threshold: 10 diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index ff88b9053..232a25b87 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -142,6 +142,19 @@ class PostsControllerTest < ActionController::TestCase assert_equal 1, NewThreadFollower.where(['post_id = ? AND user_id = ?', assigns(:post), user]).count end + test 'should block identical post spam from low-activity user' do + user = users(:spammer) + previous_post = posts(:non_deleted_spam_question) + sign_in user + sample_post = sample(body_markdown: previous_post.body_markdown) + try_create_post(sample_post: sample_post) + assert_response(:bad_request) + assert_not_nil assigns(:post) + assert_include_any assigns(:post).errors.full_messages, + ApplicationRecord.useful_err_msg, + "Expected post errors to include a 'useful' error message." + end + private # Attempts to create a post @@ -152,16 +165,18 @@ class PostsControllerTest < ActionController::TestCase def try_create_post(post_type: post_types(:question), category: categories(:main), parent: nil, - license: licenses(:cc_by_sa)) + license: licenses(:cc_by_sa), + sample_post: nil) + sample_post ||= sample post :create, params: { post_type: post_type.id, parent: parent&.id, category: category&.id, post: { post_type_id: post_type.id, - title: sample.title, - body_markdown: sample.body_markdown, + title: sample_post.title, + body_markdown: sample_post.body_markdown, category_id: category&.id, parent_id: parent&.id, - tags_cache: sample.tags_cache, + tags_cache: sample_post.tags_cache, license_id: license.id } } end end diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 5a3833e8e..43af35e69 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -560,6 +560,20 @@ imported_question: category: main license: cc_by_sa +non_deleted_spam_question: + post_type: question + title: Best spam in the neighborhood! Call 555-55-55 + body: Find the best spam in your neighborhood on Spam Central + body_markdown:

Find the best spam in your neighborhood on Spam Central

+ tags_cache: + - discussion + tags: + - discussion + user: spammer + community: sample + category: main + license: cc_by_sa + deleted_spam_question: post_type: question title: Best spam in the neighborhood! Call 555-55-55 diff --git a/test/test_helper.rb b/test/test_helper.rb index 7befbb484..c5bd86020 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -211,10 +211,20 @@ def assert_redirected_to_sign_in assert_redirected_to(new_user_session_path) end + ## + # Assert that the given array contains at least one of the array of expected values. + # @param ary [Array] array to check + # @param expected [Array] array of expected values + # @param error_message [String] error message to display if the assertion fails + def assert_include_any(ary, expected, error_message = nil) + default_error = "Expected array to contain any expected value." + assert ary.any? { |x| expected.include? x }, error_message || default_error + end + PostMock = Struct.new(:title, :body_markdown, :body, :tags_cache, :edit, keyword_init: true) - def sample - PostMock.new( + def sample(**options) + PostMock.new(**({ title: 'This is a sample title', body_markdown: 'This is a sample post with some **Markdown** and [a link](/).', body: '

This is a sample post with some Markdown and a link

', @@ -226,7 +236,7 @@ def sample tags_cache: ['discussion', 'posts', 'tags', 'edits'], edit: nil ) - ) + }).merge(options)) end end From c6a4eeb588686dcfc58667de78845cd190a0cfaa Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 12:08:48 +0100 Subject: [PATCH 09/21] Tests --- .../concerns/post_creation_validations.rb | 8 ++++-- app/models/post.rb | 2 ++ test/test_helper.rb | 28 +++++++++---------- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index b4641e382..161982481 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -1,6 +1,7 @@ module PostCreationValidations extend ActiveSupport::Concern + # rubocop:disable Metrics/BlockLength included do validate :no_mathjax_in_title, on: :create validate :post_type_requires_parent, on: :create @@ -18,19 +19,19 @@ def no_mathjax_in_title def post_type_requires_parent if post_type.has_parent? && parent.nil? - errors.add(:base, helpers.i18ns('posts.type_requires_parent', type: post_type.name)) + errors.add(:base, ApplicationRecord.helpers.i18ns('posts.type_requires_parent', type: post_type.name)) end end def post_type_has_category if post_type.has_category? && category.nil? && parent.nil? - errors.add(:base, helpers.i18ns('posts.type_requires_category', type: post_type.name)) + errors.add(:base, ApplicationRecord.helpers.i18ns('posts.type_requires_category', type: post_type.name)) end end def can_post_in_category if category.present? && !user.can_post_in?(category) - errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) + errors.add(:base, ApplicationRecord.helpers.i18ns('posts.category_low_trust_level', name: category.name)) end end @@ -45,4 +46,5 @@ def identical_post_spam end end end + # rubocop:enable Metrics/BlockLength end diff --git a/app/models/post.rb b/app/models/post.rb index fdb571c38..c610cfd01 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -294,6 +294,8 @@ def question? ## # Before-validation callback. Update the tags association from the tags_cache. def update_tag_associations + return if category.nil? + tags_cache.each do |tag_name| tag, name_used = Tag.find_or_create_synonymized name: tag_name, tag_set: category.tag_set unless tags.include? tag diff --git a/test/test_helper.rb b/test/test_helper.rb index c5bd86020..b346098c5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -217,26 +217,24 @@ def assert_redirected_to_sign_in # @param expected [Array] array of expected values # @param error_message [String] error message to display if the assertion fails def assert_include_any(ary, expected, error_message = nil) - default_error = "Expected array to contain any expected value." - assert ary.any? { |x| expected.include? x }, error_message || default_error + default_error = 'Expected array to contain any expected value.' + assert ary.any? { |x| expected.include?(x) }, error_message || default_error end PostMock = Struct.new(:title, :body_markdown, :body, :tags_cache, :edit, keyword_init: true) def sample(**options) - PostMock.new(**({ - title: 'This is a sample title', - body_markdown: 'This is a sample post with some **Markdown** and [a link](/).', - body: '

This is a sample post with some Markdown and a link

', - tags_cache: ['discussion', 'posts', 'tags'], - edit: PostMock.new( - title: 'This is another sample title', - body_markdown: 'This is a sample post with some more **Markdown** and [a link](/).', - body: '

This is a sample post with some more Markdown and a link

', - tags_cache: ['discussion', 'posts', 'tags', 'edits'], - edit: nil - ) - }).merge(options)) + PostMock.new(title: 'This is a sample title', + body_markdown: 'This is a sample post with some **Markdown** and [a link](/).', + body: '

This is a sample post with some Markdown and a link

', + tags_cache: ['discussion', 'posts', 'tags'], + edit: PostMock.new( + title: 'This is another sample title', + body_markdown: 'This is a sample post with some more **Markdown** and [a link](/).', + body: '

This is a sample post with some more Markdown and a link

', + tags_cache: ['discussion', 'posts', 'tags', 'edits'], + edit: nil + ), **options) end end From ab1424abc31b5b0ff0236f8ad84280f709996560 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 12:13:19 +0100 Subject: [PATCH 10/21] Wrong class --- app/models/application_record.rb | 4 ++++ app/models/concerns/post_creation_validations.rb | 6 +++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 24d4ef204..e24f58411 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -26,6 +26,10 @@ def attributes_print(join: ', ') end.join(join) end + def helpers + ApplicationController.helpers + end + def self.sanitize_for_search(term, **cols) cols = cols.map do |k, v| if v.is_a?(Array) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index 161982481..98e963326 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -19,19 +19,19 @@ def no_mathjax_in_title def post_type_requires_parent if post_type.has_parent? && parent.nil? - errors.add(:base, ApplicationRecord.helpers.i18ns('posts.type_requires_parent', type: post_type.name)) + errors.add(:base, helpers.i18ns('posts.type_requires_parent', type: post_type.name)) end end def post_type_has_category if post_type.has_category? && category.nil? && parent.nil? - errors.add(:base, ApplicationRecord.helpers.i18ns('posts.type_requires_category', type: post_type.name)) + errors.add(:base, helpers.i18ns('posts.type_requires_category', type: post_type.name)) end end def can_post_in_category if category.present? && !user.can_post_in?(category) - errors.add(:base, ApplicationRecord.helpers.i18ns('posts.category_low_trust_level', name: category.name)) + errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) end end From 29c9b989196a8a247e161d0d66a1a8522f3a25c9 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 12:23:41 +0100 Subject: [PATCH 11/21] Tests still --- app/controllers/categories_controller.rb | 6 +++--- app/models/concerns/post_validations.rb | 2 ++ test/controllers/posts/create_test.rb | 12 +++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb index dce0a48aa..d9ad73a8b 100644 --- a/app/controllers/categories_controller.rb +++ b/app/controllers/categories_controller.rb @@ -138,12 +138,12 @@ def rss_feed def post_types @post_types = @category.top_level_post_types - if @post_types.one? + if !user_signed_in? + redirect_to_sign_in + elsif @post_types.one? redirect_to new_category_post_path(post_type: @post_types.first, category: @category) elsif @post_types.empty? && current_user&.admin? redirect_to edit_category_post_types_path(@category, no_return: '1') - elsif !user_signed_in? - redirect_to_sign_in end end diff --git a/app/models/concerns/post_validations.rb b/app/models/concerns/post_validations.rb index 1deaa0e51..de0cfa281 100644 --- a/app/models/concerns/post_validations.rb +++ b/app/models/concerns/post_validations.rb @@ -71,6 +71,8 @@ def maximum_title_length end def tags_in_tag_set + return if category.nil? + tag_set = category.tag_set unless tags.all? { |t| t.tag_set_id == tag_set.id } errors.add(:base, "Not all of this question's tags are in the correct tag set.") diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index 232a25b87..cac35957a 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -82,10 +82,9 @@ class PostsControllerTest < ActionController::TestCase try_create_post(category: nil) - assert_response(:found) - assert_redirected_to root_path - assert_not_nil flash[:danger] + assert_response(:bad_request) assert_nil assigns(:post).id + assert_not_empty assigns(:post).errors.full_messages end test 'category post type checks required trust level' do @@ -93,7 +92,7 @@ class PostsControllerTest < ActionController::TestCase try_create_post(category: categories(:high_trust)) - assert_response(:forbidden) + assert_response(:bad_request) assert_nil assigns(:post).id assert_not_empty assigns(:post).errors.full_messages end @@ -103,10 +102,9 @@ class PostsControllerTest < ActionController::TestCase try_create_post(post_type: post_types(:answer)) - assert_response(:found) - assert_redirected_to root_path - assert_not_nil flash[:danger] + assert_response(:bad_request) assert_nil assigns(:post).id + assert_not_empty assigns(:post).errors.full_messages end test 'create ensures community user is created' do From bfe3033154bf4ecf96362430abd3d5e8291f6328 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 12:34:48 +0100 Subject: [PATCH 12/21] Change post fixture user --- test/fixtures/posts.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index 43af35e69..b9adf829f 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -569,7 +569,7 @@ non_deleted_spam_question: - discussion tags: - discussion - user: spammer + user: high_rep_spammer community: sample category: main license: cc_by_sa From fdb91875db76d9ad36a0741f039c39f8e99c8746 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 15:13:49 +0100 Subject: [PATCH 13/21] Block posting when spam flags are active --- .../concerns/post_creation_validations.rb | 25 +++++++++++++++++++ config/config/spam_protection.yml | 7 ++++++ test/controllers/posts/create_test.rb | 15 +++++++++++ test/fixtures/flags.yml | 7 ++++++ 4 files changed, 54 insertions(+) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index 98e963326..8b24a1d30 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -8,6 +8,7 @@ module PostCreationValidations validate :post_type_has_category, on: :create validate :can_post_in_category, on: :create validate :identical_post_spam, on: :create + validate :no_active_spam_flags, on: :create private @@ -45,6 +46,30 @@ def identical_post_spam end end end + + def no_active_spam_flags + posts_threshold = AppConfig.spam_protection['spam_flag_posts_threshold'] + time_threshold = AppConfig.spam_protection['spam_flag_time_threshold'] + prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count + unless prev_non_deleted_count >= posts_threshold + active = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: nil }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + helpful = Post.unscoped + .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) + .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) + .where(flags: { status: 'helpful' }, + post_flag_types: { name: "it's spam" }, + posts: { user_id: user.id }) + .where('flags.created_at <= ?', time_threshold.days.ago) + if active.any? || helpful.any? + errors.add(:base, ApplicationRecord.useful_err_msg.sample) + end + end + end end # rubocop:enable Metrics/BlockLength end diff --git a/config/config/spam_protection.yml b/config/config/spam_protection.yml index 8666bf554..804794429 100644 --- a/config/config/spam_protection.yml +++ b/config/config/spam_protection.yml @@ -5,3 +5,10 @@ deletion_block_max_rep: 100 # Users are blocked from posting something identical to any of their previous posts, unless they have equal to or # greater than this number of non-deleted posts anywhere on the network. identical_post_spam_threshold: 10 + +# Users are blocked from posting if there is an active (i.e. not reviewed) spam flag against any of their posts. Users +# with equal to or greater than this number of non-deleted posts in any community are exempt from this check. +spam_flag_posts_threshold: 10 + +# Helpful spam flags will also block posting as above for up to this number of days after the flag was cast. +spam_flag_time_threshold: 7 diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index cac35957a..e76d6cde3 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -144,8 +144,23 @@ class PostsControllerTest < ActionController::TestCase user = users(:spammer) previous_post = posts(:non_deleted_spam_question) sign_in user + sample_post = sample(body_markdown: previous_post.body_markdown) try_create_post(sample_post: sample_post) + + assert_response(:bad_request) + assert_not_nil assigns(:post) + assert_include_any assigns(:post).errors.full_messages, + ApplicationRecord.useful_err_msg, + "Expected post errors to include a 'useful' error message." + end + + test 'should block posting from user with active spam flag' do + user = users(:high_rep_spammer) + sign_in user + + try_create_post + assert_response(:bad_request) assert_not_nil assigns(:post) assert_include_any assigns(:post).errors.full_messages, diff --git a/test/fixtures/flags.yml b/test/fixtures/flags.yml index a9240110b..e1ad7b07e 100644 --- a/test/fixtures/flags.yml +++ b/test/fixtures/flags.yml @@ -5,6 +5,13 @@ one: user: standard_user community: sample +active_spam: + reason: It's obviously spam + post: deleted_high_rep_spam (Post) + post_flag_type: spam + user: standard_user + community: sample + declined: reason: Please decline it post: question_one (Post) From 867029840fe3385a1153a8c2cfede8a5ea09d4ac Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 16:19:17 +0100 Subject: [PATCH 14/21] Add CIDR flagging --- app/jobs/check_cidr_job.rb | 43 +++++++++++++++++++ .../concerns/post_creation_validations.rb | 6 +++ config/initializers/inflections.rb | 1 + test/controllers/posts/create_test.rb | 24 +++++++++++ test/fixtures/blocked_items.yml | 16 +++++++ test/fixtures/community_users.yml | 14 ++++++ test/fixtures/user_abilities.yml | 16 +++++++ test/fixtures/users.yml | 34 +++++++++++++++ test/jobs/check_cidr_job_test.rb | 7 +++ 9 files changed, 161 insertions(+) create mode 100644 app/jobs/check_cidr_job.rb create mode 100644 test/jobs/check_cidr_job_test.rb diff --git a/app/jobs/check_cidr_job.rb b/app/jobs/check_cidr_job.rb new file mode 100644 index 000000000..ad140f049 --- /dev/null +++ b/app/jobs/check_cidr_job.rb @@ -0,0 +1,43 @@ +class CheckCIDRJob < ApplicationJob + queue_as :default + + def perform(post) + @post = post + relevant_ips = [post.user.current_sign_in_ip, post.user.last_sign_in_ip] + prefixes = BlockedItem.where(item_type: 'ip_prefix') + .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) + .where("? LIKE CONCAT(`value`, '%') OR ? LIKE CONCAT(`value`, '%')", *relevant_ips) + + if prefixes.any? + create_flag prefixes[0] + return # because prefixes are more performant than CIDR checks, so if we can match there then that'll do + end + + cidrs = BlockedItem.where(item_type: 'ip_cidr') + .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) + cidrs.each do |cidr| + network = IPAddress.parse(cidr.value) + relevant_ips.each do |ip| + ip = IPAddress.parse(ip) + if network.include?(ip) + create_flag cidr + return + end + end + end + end + + def create_flag(match) + reason = 'Automatically escalated spam flag - please leave this for the community team to handle.' + spam_flag_type = PostFlagType.unscoped.where(community: @post.community, name: "it's spam").first + flag = @post.flags.create(user: helpers.system_user, community: @post.community, post_flag_type: spam_flag_type, + reason: reason, escalated: true, escalated_at: DateTime.now, + escalated_by: helpers.system_user, + escalation_comment: "Suspicious IP address: user IP matches #{match.value}") + FlagMailer.with(flag: flag).flag_escalated.deliver_now + end + + def helpers + ApplicationController.helpers + end +end diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index 8b24a1d30..d7c576849 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -10,6 +10,8 @@ module PostCreationValidations validate :identical_post_spam, on: :create validate :no_active_spam_flags, on: :create + after_create :escalate_suspicious_cidr + private def no_mathjax_in_title @@ -70,6 +72,10 @@ def no_active_spam_flags end end end + + def escalate_suspicious_cidr + CheckCIDRJob.perform_later(self) + end end # rubocop:enable Metrics/BlockLength end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index e20959af4..95518552c 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -17,4 +17,5 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'SE' + inflect.acronym 'CIDR' end diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index e76d6cde3..0e14ee927 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -168,6 +168,30 @@ class PostsControllerTest < ActionController::TestCase "Expected post errors to include a 'useful' error message." end + test 'should allow posting but flag from blocked IP prefix' do + user = users(:bad_ip_prefix) + sign_in user + + before_flags = Flag.count + assert_performed_jobs 1 do + try_create_post + end + after_flags = Flag.count + assert_equal before_flags + 1, after_flags, 'Expected a flag to be created' + end + + test 'should allow posting but flag from blocked IP CIDR' do + user = users(:bad_ip_cidr) + sign_in user + + before_flags = Flag.count + assert_performed_jobs 1 do + try_create_post + end + after_flags = Flag.count + assert_equal before_flags + 1, after_flags, 'Expected a flag to be created' + end + private # Attempts to create a post diff --git a/test/fixtures/blocked_items.yml b/test/fixtures/blocked_items.yml index 6070be6b2..045c58539 100644 --- a/test/fixtures/blocked_items.yml +++ b/test/fixtures/blocked_items.yml @@ -5,9 +5,25 @@ email: value: blocked@mail.com automatic: true reason: got fed up with them + expires: 2100-01-01T00:00:00 ip: item_type: ip value: 8.8.8.8 automatic: false reason: Why are we accessed by a DNS server? + expires: 2100-01-01T00:00:00 + +ip_prefix: + item_type: ip_prefix + value: '2407:f8:200' + automatic: false + reason: Because I said so + expires: 2100-01-01T00:00:00 + +ip_cidr: + item_type: ip_cidr + value: '2407:f8:300::/48' + automatic: false + reason: Because I said so + expires: 2100-01-01T00:00:00 diff --git a/test/fixtures/community_users.yml b/test/fixtures/community_users.yml index 2e9817bbd..2b2157bab 100644 --- a/test/fixtures/community_users.yml +++ b/test/fixtures/community_users.yml @@ -161,3 +161,17 @@ high_rep_spammer: is_admin: false is_moderator: false reputation: 1000 + +bad_ip_prefix: + user: bad_ip_prefix + community: sample + is_admin: false + is_moderator: false + reputation: 1 + +bad_ip_cidr: + user: bad_ip_cidr + community: sample + is_admin: false + is_moderator: false + reputation: 1 diff --git a/test/fixtures/user_abilities.yml b/test/fixtures/user_abilities.yml index c82618829..7d891f276 100644 --- a/test/fixtures/user_abilities.yml +++ b/test/fixtures/user_abilities.yml @@ -148,3 +148,19 @@ hrs_eo: hrs_ur: community_user: high_rep_spammer ability: unrestricted + +bip_eo: + community_user: bad_ip_prefix + ability: everyone + +bip_ur: + community_user: bad_ip_prefix + ability: unrestricted + +bic_eo: + community_user: bad_ip_cidr + ability: everyone + +bic_ur: + community_user: bad_ip_cidr + ability: unrestricted diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index 4d919baf1..9ea975b5b 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -189,6 +189,40 @@ high_rep_spammer: is_global_moderator: false confirmed_at: 2020-01-01T00:00:00.000000Z +bad_ip_prefix: + email: bad-ip-prefix@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: IPSpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + current_sign_in_ip: '2407:f8:200:abcd::1' + last_sign_in_ip: '2407:f8:200:abcd::1' + +bad_ip_cidr: + email: bad-ip-cidr@example.com + encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' + profile: > + Find the best spam on the network here! + The yummiest spam you've ever tasted. + profile_markdown: > + Find the best spam on the network [here](https://example.com)! + The yummiest spam you've ever tasted. + sign_in_count: 1 + username: CIDRSpam + is_global_admin: false + is_global_moderator: false + confirmed_at: 2020-01-01T00:00:00.000000Z + current_sign_in_ip: '2407:f8:300:abcd::1' + last_sign_in_ip: '2407:f8:300:abcd::1' + profile_spammer: email: profile-spammer@example.com encrypted_password: '$2a$11$roUHXKxecjyQ72Qn7DWs3.9eRCCoRn176kX/UNb/xiue3aGqf7xEW' diff --git a/test/jobs/check_cidr_job_test.rb b/test/jobs/check_cidr_job_test.rb new file mode 100644 index 000000000..b912b1ef0 --- /dev/null +++ b/test/jobs/check_cidr_job_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class CheckCidrJobTest < ActiveJob::TestCase + # test "the truth" do + # assert true + # end +end From 21b5d337b0e9715bd3f8bb417bdaf1bfb8a9a1d1 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 16:20:40 +0100 Subject: [PATCH 15/21] Rubocop --- test/jobs/check_cidr_job_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/jobs/check_cidr_job_test.rb b/test/jobs/check_cidr_job_test.rb index b912b1ef0..6e4280825 100644 --- a/test/jobs/check_cidr_job_test.rb +++ b/test/jobs/check_cidr_job_test.rb @@ -1,4 +1,4 @@ -require "test_helper" +require 'test_helper' class CheckCidrJobTest < ActiveJob::TestCase # test "the truth" do From 49ef46cc67d872297e02714c985894873e09f005 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 16:24:04 +0100 Subject: [PATCH 16/21] Rubocop --- app/jobs/check_cidr_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/check_cidr_job.rb b/app/jobs/check_cidr_job.rb index ad140f049..08bfd993c 100644 --- a/app/jobs/check_cidr_job.rb +++ b/app/jobs/check_cidr_job.rb @@ -21,7 +21,9 @@ def perform(post) ip = IPAddress.parse(ip) if network.include?(ip) create_flag cidr + # rubocop:disable Lint/NonLocalExitFromIterator return + # rubocop:enable Lint/NonLocalExitFromIterator end end end From d8649500d22d5f25b95e4195282fdaf941ae4ee7 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Sun, 16 Aug 2026 16:32:22 +0100 Subject: [PATCH 17/21] Really? --- app/jobs/check_cidr_job.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/jobs/check_cidr_job.rb b/app/jobs/check_cidr_job.rb index 08bfd993c..6319c1e39 100644 --- a/app/jobs/check_cidr_job.rb +++ b/app/jobs/check_cidr_job.rb @@ -19,12 +19,14 @@ def perform(post) network = IPAddress.parse(cidr.value) relevant_ips.each do |ip| ip = IPAddress.parse(ip) + # rubocop:disable Style/Next if network.include?(ip) create_flag cidr # rubocop:disable Lint/NonLocalExitFromIterator return # rubocop:enable Lint/NonLocalExitFromIterator end + # rubocop:enable Style/Next end end end From fce21a71cdc83844c971a8213192f2a39448404a Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 17 Aug 2026 10:54:45 +0100 Subject: [PATCH 18/21] Exempt dev env from registration rate limit --- app/controllers/users/registrations_controller.rb | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index 205336318..43c391709 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -8,7 +8,11 @@ class Users::RegistrationsController < Devise::RegistrationsController def create super do |user| unless user.errors.any? - rate_limit = AppConfig.server_settings['registration_rate_limit'] + rate_limit = if Rails.env.development? + 0 + else + AppConfig.server_settings['registration_rate_limit'] + end ip_list = [user.current_sign_in_ip, request.remote_ip].compact previous_ip_users = User.where(current_sign_in_ip: ip_list).or(User.where(last_sign_in_ip: ip_list)) .where(created_at: rate_limit.seconds.ago..DateTime.now) From 710a3bae3aed1c50ef6c71aa2109eb4f5d391229 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 17 Aug 2026 11:03:30 +0100 Subject: [PATCH 19/21] Use generic error messages instead --- app/models/concerns/post_creation_validations.rb | 4 ++-- config/locales/strings/en.posts.yml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/post_creation_validations.rb b/app/models/concerns/post_creation_validations.rb index d7c576849..3e4b7a802 100644 --- a/app/models/concerns/post_creation_validations.rb +++ b/app/models/concerns/post_creation_validations.rb @@ -44,7 +44,7 @@ def identical_post_spam unless prev_non_deleted_count >= threshold identical_posts = Post.unscoped.where(user: user, body_markdown: body_markdown).where.not(id: id) if identical_posts.any? - errors.add(:base, ApplicationRecord.useful_err_msg.sample) + errors.add(:base, I18n.t('posts.spam_blocked')) end end end @@ -68,7 +68,7 @@ def no_active_spam_flags posts: { user_id: user.id }) .where('flags.created_at <= ?', time_threshold.days.ago) if active.any? || helpful.any? - errors.add(:base, ApplicationRecord.useful_err_msg.sample) + errors.add(:base, I18n.t('posts.spam_blocked')) end end end diff --git a/config/locales/strings/en.posts.yml b/config/locales/strings/en.posts.yml index e4a9c6889..1a367d654 100644 --- a/config/locales/strings/en.posts.yml +++ b/config/locales/strings/en.posts.yml @@ -9,6 +9,8 @@ en: You can't create a :type without a category. type_requires_parent: > You can't create a :type without a parent post. + spam_blocked: > + Couldn't create your post. Try again later. not_public_editable: > This type of post can only be edited by its author and the site moderators. cant_close_post: > From 97b822ddc8c215d5c797ebc5a96616040f333b50 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Mon, 17 Aug 2026 17:55:48 +0100 Subject: [PATCH 20/21] Update expected error --- test/controllers/posts/create_test.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/controllers/posts/create_test.rb b/test/controllers/posts/create_test.rb index 0e14ee927..45e58c227 100644 --- a/test/controllers/posts/create_test.rb +++ b/test/controllers/posts/create_test.rb @@ -150,9 +150,7 @@ class PostsControllerTest < ActionController::TestCase assert_response(:bad_request) assert_not_nil assigns(:post) - assert_include_any assigns(:post).errors.full_messages, - ApplicationRecord.useful_err_msg, - "Expected post errors to include a 'useful' error message." + assert assigns(:post).errors.full_messages.include?(I18n.t('posts.spam_blocked')) end test 'should block posting from user with active spam flag' do @@ -163,9 +161,7 @@ class PostsControllerTest < ActionController::TestCase assert_response(:bad_request) assert_not_nil assigns(:post) - assert_include_any assigns(:post).errors.full_messages, - ApplicationRecord.useful_err_msg, - "Expected post errors to include a 'useful' error message." + assert assigns(:post).errors.full_messages.include?(I18n.t('posts.spam_blocked')) end test 'should allow posting but flag from blocked IP prefix' do From 5fff1262565d4da1ea2b71abd9ee8cd7c1fe5737 Mon Sep 17 00:00:00 2001 From: ArtOfCode- Date: Tue, 18 Aug 2026 00:10:39 +0100 Subject: [PATCH 21/21] Missed a helper in the merge --- test/controllers/concerns/users/user_test_helpers.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/test/controllers/concerns/users/user_test_helpers.rb b/test/controllers/concerns/users/user_test_helpers.rb index 26df3ba69..8d803dae3 100644 --- a/test/controllers/concerns/users/user_test_helpers.rb +++ b/test/controllers/concerns/users/user_test_helpers.rb @@ -30,5 +30,11 @@ def try_save_preference(name, value, community: nil) format: :json } end + + # @param user [User] user to undelete + def try_undelete_user(user) + post :undelete, params: { id: user.id, + format: :json } + end end end