Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
440e39a
switched UsersController#soft-delete to at_least_global_moderator?
Oaphi Oct 26, 2025
a4740ad
renamed User#do_soft_delete to just soft_delete (naming convention)
Oaphi Oct 26, 2025
3b5d5f2
added a proper CommunityUser#soft_delete method
Oaphi Oct 26, 2025
5b77efd
fixed deletion confirmation layout (missing </li> tag)
Oaphi Oct 26, 2025
22cee25
reworked network_name to be a global site setting
Oaphi Oct 26, 2025
f0ec2e9
switched a bunch more views to the NetworkName site setting
Oaphi Oct 26, 2025
546e22a
removed Codidact mention from keyboard shortcuts
Oaphi Oct 26, 2025
ae0dfa3
a bunch more instances of hardcoded network name removed
Oaphi Oct 26, 2025
879accb
added test for CleanUpSpammyUsers job + improved system user handling
Oaphi Oct 26, 2025
76517e8
removed no longer needed system user fixture (it's seeded) & adjusted…
Oaphi Oct 26, 2025
f265135
added webmock to help with preventing & tracking external requests in…
Oaphi Oct 27, 2025
3724130
prevented CDN requests when testing mailers
Oaphi Oct 27, 2025
4fa820a
fixed backup_code's 2FA URI pointing to the default host
Oaphi Oct 27, 2025
16c0122
added tests for TwoFactorMailer
Oaphi Oct 27, 2025
51d4c18
added network name assertions for TwoFactorMailer tests
Oaphi Oct 27, 2025
4d6ba34
external requests are disabled by default when using WebMock, no need…
Oaphi Oct 27, 2025
adb57ea
ensured all external requests in test cases are stubbed out and clean…
Oaphi Oct 27, 2025
2009d94
fixed PotentialSpamProfilesJob test by added a dedicated profile spam…
Oaphi Oct 27, 2025
397d0c5
allowed network connections in system tests
Oaphi Oct 27, 2025
0a8480d
removed stray non-existent action name from verify_moderator action c…
Oaphi Oct 27, 2025
febeafc
Merge branch 'develop' into ovalt/user-restore
Oaphi Oct 27, 2025
2bbe33c
fixed Filter model test since we don't have a system user fixture any…
Oaphi Oct 27, 2025
1788af1
fixed TwoFactorMailer tests not respecting default url options
Oaphi Oct 27, 2025
acc97b6
fixed missing category for the NetworkName site setting
Oaphi Oct 28, 2025
02d8a45
added User#anonymize method (and tests) to make the intent clear
Oaphi Oct 28, 2025
1c34cd4
added NetworkURL global site setting
Oaphi Oct 29, 2025
757fe46
fixed image without alt text regular expression
Oaphi Nov 2, 2025
7994836
expanded user profile soft delete test to cover all users who are at …
Oaphi Nov 3, 2025
737e382
moved user ping regular expression to a USER_PING_REG_EXP constant to…
Oaphi Nov 5, 2025
1f292b9
allowed user pings to be made to users with negative ids
Oaphi Nov 5, 2025
6d2ad87
fixed Comment#pings method (it's unused but still)
Oaphi Nov 5, 2025
6e6d1a3
added tests for the Comment#pings method
Oaphi Nov 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CODE-STANDARDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,8 @@ onto the next line, and every continuation line must be indented _at least_ one
Wrapped lines may be indented further to align certain elements with one another.

```js
codidact.createDangerConfirmationAudit(document.querySelectorAll('.modal.is-danger > .modal--body'),
'POST', 'https://codidact.org/audits/danger-confirmation');
QPixel.createDangerConfirmationAudit(document.querySelectorAll('.modal.is-danger > .modal--body'),
'POST', 'https://example.com/audits/danger-confirmation');
```

#### Bracing
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ group :test do
gem 'capybara', '~> 3.38'
gem 'selenium-webdriver', '~> 4.7'
gem 'webdrivers', '~> 5.2'
gem 'webmock', '~> 3.26'
end

group :development, :test do
Expand Down
9 changes: 9 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ GEM
counter_culture (3.11.2)
activerecord (>= 4.2)
activesupport (>= 4.2)
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
css_parser (1.21.1)
addressable
Expand Down Expand Up @@ -166,6 +169,7 @@ GEM
activesupport (>= 6.1)
groupdate (6.5.1)
activesupport (>= 7)
hashdiff (1.2.1)
hashie (5.0.0)
htmlentities (4.3.4)
i18n (1.14.7)
Expand Down Expand Up @@ -448,6 +452,10 @@ GEM
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
webmock (3.26.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.8.0)
Expand Down Expand Up @@ -530,6 +538,7 @@ DEPENDENCIES
tzinfo-data (~> 1.2022.3)
web-console (~> 4.2)
webdrivers (~> 5.2)
webmock (~> 3.26)
whenever (~> 1.0)
will_paginate (~> 3.3)
will_paginate-bootstrap (~> 1.0)
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/keyboard_tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ $(() => {

if (isHelp) {
_CodidactKeyboard.dialog(
'Codidact Keyboard Shortcuts\n' +
'Keyboard Shortcuts\n' +
'===========================\n' +
'? Open this help\n' +
'esc Close this help\n' +
Expand Down
17 changes: 9 additions & 8 deletions app/assets/javascripts/markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ $(() => {
const $tgt = $(ev.target);
const $button = $tgt.is('a') ? $tgt : $tgt.parents('a');
const action = $button.attr('data-action');

/** @type {JQuery<HTMLTextAreaElement | HTMLInputElement>} */
const $field = $('.js-post-field');

Expand All @@ -33,7 +33,7 @@ $(() => {
heading: ['\n# ', null],
hr: ['\n\n-----\n\n', null],
table: ['\n\n| Title1 | Title2 |\n|- | - |\n| row1_1 | row1_2 |\n\n', null],
mathjax: ['$', '$']
mathjax: ['$', '$'],
};

if (Object.keys(actions).indexOf(action) !== -1) {
Expand All @@ -58,7 +58,7 @@ $(() => {
case 66:
$('[data-action="bold"]').click();
break;

case 73:
$('[data-action="italic"]').click();
break;
Expand Down Expand Up @@ -123,12 +123,13 @@ $(() => {
});

QPixel.addPrePostValidation((text) => {
// This regex catches Markdown images with no or default alt text.
const altRegex = /!\[(?:Image_alt_text)?\](?:\(.+(?!\\\))\)|\[.+(?!\\\])\])/gi;
// catch Markdown images with no or default alt text: https://regex101.com/r/ubcVn4/2
const altRegex = /!\[(?:Image_alt_text)?\](?:\([^\)]+?\)|\[.+(?!\\\])\])/gi;
if (text.match(altRegex)) {
const message = `It looks like you're posting an image with no alt text. Alt text is important for ` +
`accessibility. Consider adding alt text to the images in your post - ` +
`<a href="/help/alt-text">read this help article</a> for details and help writing alt text.`;
const message =
`It looks like you're posting an image with no alt text. Alt text is important for ` +
`accessibility. Consider adding alt text to the images in your post - ` +
`<a href="/help/alt-text">read this help article</a> for details and help writing alt text.`;
return [false, [{ type: 'warning', message }]];
}
else {
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/comments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ def check_if_target_post_locked
# @return [Array<Integer>] list of pinged user ids
def check_for_pings(thread, content)
pingable = thread.pingable
matches = content.scan(/@#(\d+)/)
matches = content.scan(Comment::USER_PING_REG_EXP)
matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i)
end

Expand Down
2 changes: 1 addition & 1 deletion app/controllers/moderator_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def handle_spammy_users
spam = User.where(id: params[:spam_ids])
spam.each do |user|
user.block('Profile spam', length: 10.years, automatic: false)
user.do_soft_delete(current_user)
user.soft_delete(current_user)
end
flash[:success] = "#{spam.size} users blocked and deleted."
redirect_to mod_spammers_path
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/users/registrations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ def do_delete
else
UserMailer.with(user: @user, host: RequestContext.community.host, community: RequestContext.community)
.deletion_confirmation.deliver_later
@user.do_soft_delete(@user)
@user.soft_delete(@user)
flash[:info] = 'Sorry to see you go!'
redirect_to root_path
end
Expand Down
10 changes: 4 additions & 6 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -326,17 +326,15 @@ def soft_delete

case params[:type]
when 'profile'
AuditLog.moderator_audit(event_type: 'profile_delete', related: @user.community_user, user: current_user,
comment: @user.community_user.attributes_print(join: "\n"))
@user.community_user.update(deleted: true, deleted_by: current_user, deleted_at: DateTime.now)
@user.community_user.soft_delete(current_user)
when 'user'
unless current_user.is_global_moderator || current_user.is_global_admin
unless current_user.at_least_global_moderator?
render json: { status: 'failed', message: 'Non-global moderator cannot perform global deletion.' },
status: 403
status: :forbidden
return
end

@user.do_soft_delete(current_user)
@user.soft_delete(current_user)
else
render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400
return
Expand Down
2 changes: 1 addition & 1 deletion app/helpers/application_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ def current_user
# Gets the special System user
# @return [User, nil]
def system_user
User.find(-1)
User.system
end

##
Expand Down
6 changes: 3 additions & 3 deletions app/helpers/comments_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def comment_user_link(comment)
# @param content [String] content to get pinged users from
# @return [Hash{String => User}] list of pinged users
def pinged_users(content)
user_ids = content.scan(/@#(\d+)/).map { |g| g[0].to_i }
user_ids = content.scan(Comment::USER_PING_REG_EXP).map { |g| g[0].to_i }
User.where(id: user_ids).to_a.to_h { |u| [u.id, u] }
end

Expand All @@ -49,7 +49,7 @@ def pinged_users(content)
def render_pings(content, pingable: nil, host: nil)
users = pinged_users(content)

content.gsub(/@#(\d+)/) do |ping|
content.gsub(Comment::USER_PING_REG_EXP) do |ping|
user = users[Regexp.last_match(1).to_i]
if user.nil?
ping
Expand All @@ -70,7 +70,7 @@ def render_pings(content, pingable: nil, host: nil)
def render_pings_text(content)
users = pinged_users(content)

content.gsub(/@#(\d+)/) do |ping|
content.gsub(Comment::USER_PING_REG_EXP) do |ping|
user = users[Regexp.last_match(1).to_i]
user.nil? ? ping : "@#{rtl_safe_username(user)}"
end
Expand Down
7 changes: 5 additions & 2 deletions app/jobs/clean_up_spammy_users_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,17 @@ def perform(created_after: 1.month.ago)
.where('users.created_at >= ?', created_after)
.where(users: { deleted: false }).group('users.id').having('count(posts.id) > 0')
.having('count(distinct if(posts.deleted = true, null, posts.id)) = 0')

possible_spammers.each do |spammer|
all_posts_spam = spammer.posts.all? do |post|
# A post is considered spam if there are any helpful spam flags on it.
post.flags.any? { |flag| flag.post_flag_type.name == "it's spam" && flag.status == 'helpful' }
post.flags.any? do |flag|
flag.post_flag_type.name == "it's spam" && flag.status == 'helpful'
end
end
if all_posts_spam
spammer.block('automatic block from spam cleanup job', length: 2.years)
spammer.do_soft_delete(User.find(-1))
spammer.soft_delete(User.system)
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/mailers/admin_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ def to_moderators
from = "#{SiteSetting['ModeratorDistributionListSenderName', community: @community]} " \
"<#{SiteSetting['ModeratorDistributionListSenderEmail', community: @community]}>"
to = SiteSetting['ModeratorDistributionListSenderEmail', community: @community]
mail subject: "Codidact Moderators: #{@subject}", to: to, from: from, bcc: emails
mail subject: "#{SiteSetting['NetworkName']} Moderators: #{@subject}", to: to, from: from, bcc: emails
end

def to_all_users
Expand Down
2 changes: 1 addition & 1 deletion app/mailers/summary_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def content_summary
@users = params[:users]

mail(from: "#{SiteSetting['NoReplySenderName']} <#{SiteSetting['NoReplySenderEmail']}>",
subject: 'Codidact Content Summary',
subject: "#{SiteSetting['NetworkName']} Content Summary",
to: params[:to])
end
end
6 changes: 3 additions & 3 deletions app/mailers/two_factor_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ def disable_email
@host = params[:host]
@token = SecureRandom.urlsafe_base64(64)
user.update(login_token: @token, login_token_expires_at: 5.minutes.from_now)
mail to: user.email, subject: 'Disable two-factor authentication on Codidact'
mail to: user.email, subject: "Disable two-factor authentication on #{SiteSetting['NetworkName']}"
end

def login_email
user = params[:user]
@host = params[:host]
@token = SecureRandom.urlsafe_base64(64)
user.update(login_token: @token, login_token_expires_at: 5.minutes.from_now)
mail to: user.email, subject: 'Your sign in link for Codidact'
mail to: user.email, subject: "Your sign in link for #{SiteSetting['NetworkName']}"
end

def backup_code
@user = params[:user]
@host = params[:host]
mail to: @user.email, subject: 'Your 2FA backup code for Codidact'
mail to: @user.email, subject: "Your 2FA backup code for #{SiteSetting['NetworkName']}"
end
end
2 changes: 1 addition & 1 deletion app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ def deletion_confirmation
@user = params[:user]
@host = params[:host]
@community = params[:community]
mail to: @user.email, subject: 'Your Codidact account has been deleted as you requested',
mail to: @user.email, subject: "Your #{SiteSetting['NetworkName']} account has been deleted as you requested",
from: "#{SiteSetting['NoReplySenderName', community: @community]} " \
"<#{SiteSetting['NoReplySenderEmail', community: @community]}>"
end
Expand Down
6 changes: 4 additions & 2 deletions app/models/comment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ class Comment < ApplicationRecord
include SoftDeletable
include Timestamped

USER_PING_REG_EXP = /@#(-?\d+)/

scope :by, ->(user) { where(user: user) }

belongs_to :user
Expand Down Expand Up @@ -40,8 +42,8 @@ def content_length
end

def pings
pingable = thread.pingable
matches = content.scan(/@#(\d+)/)
pingable = comment_thread.pingable
matches = content.scan(USER_PING_REG_EXP)
matches.flatten.select { |m| pingable.include?(m.to_i) }.map(&:to_i)
end

Expand Down
9 changes: 9 additions & 0 deletions app/models/community_user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,13 @@ def recalc_trust_level
update(trust_level: trust)
trust
end

# Soft-deletes the community user
# @param attribute_to [User] user to attribute the action to
def soft_delete(attribute_to)
AuditLog.moderator_audit(event_type: 'profile_delete', related: self, user: attribute_to,
comment: attributes_print(join: "\n"))

update(deleted: true, deleted_by: attribute_to, deleted_at: DateTime.now)
end
end
33 changes: 29 additions & 4 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def self.search(term)
where('username LIKE ?', "%#{sanitize_sql_like(term)}%")
end

# Gets the system user
# @return [User, nil]
def self.system
find_by(id: -1)
end

# Safely gets the user's reputation even if they don't have a community user
# @return [Integer] user's reputation
def reputation
Expand Down Expand Up @@ -476,12 +482,31 @@ def active_flags(post)
post.flags.where(user: self, status: nil)
end

def do_soft_delete(attribute_to)
# Anonymizes the user (f.e., for the purpose of soft deletion)
# @param dirty [Boolean] if set to +false+, will persist the changes
def anonymize(dirty: false)
assign_attributes(username: "user#{id}",
email: "#{id}@deleted.localhost",
password: SecureRandom.hex(32))

unless dirty
skip_reconfirmation!
save
end
end

# Soft-deletes the user (username, password, and email are irrevocably reset!)
# @param attribute_to [User] user to attribute the action to
def soft_delete(attribute_to)
AuditLog.moderator_audit(event_type: 'user_delete', related: self, user: attribute_to,
comment: attributes_print(join: "\n"))
assign_attributes(deleted: true, deleted_by_id: attribute_to.id, deleted_at: DateTime.now,
username: "user#{id}", email: "#{id}@deleted.localhost",
password: SecureRandom.hex(32))

assign_attributes(deleted: true,
deleted_by_id: attribute_to.id,
deleted_at: DateTime.now)

anonymize(dirty: true)

skip_reconfirmation!
save
end
Expand Down
Loading