Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 5 additions & 7 deletions app/controllers/api/v8/users/request_deletion_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ class RequestDeletionController < Api::V8::BaseController

sig { void }
def create
user = current_user
user = User.find_by!(id: params[:user_id]) unless params[:user_id] == 'current'
authorize! :destroy, user
UserMailer.destroy_confirmation(user).deliver_now
render json: {
message: 'Verification email sent'
}
unauthorize_guest!
authorize! :destroy, current_user
deletion_url = User.courses_mooc_fi_account_deletion_url
message = deletion_url ? 'Accounts are deleted on courses.mooc.fi.' : 'Please contact support to delete your account.'
render json: errors_json(message).merge(account_deletion_url: deletion_url), status: :gone
end
end
end
Expand Down
43 changes: 42 additions & 1 deletion app/controllers/api/v8/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,40 @@ class UsersController < Api::V8::BaseController
end
end

skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy]
swagger_path '/api/v8/users/{user_id}/course_progress' do
operation :get do
key :description, 'Admin only. Lists every course where the user has submissions or awarded points, hidden and disabled courses included, latest submission first.'
key :operationId, 'findUsersCourseProgress'
key :produces, ['application/json']
key :tags, ['user']
parameter '$ref': '#/parameters/path_user_id'
response 200 do
key :description, "The user's progress per course"
schema do
key :title, :courses
key :required, [:courses]
property :courses do
key :type, :array
items do
key :required, %i[id name title moocfi_id awarded_points submissions last_submission_at]
property :id, type: :integer, example: 1
property :name, type: :string, example: 'mooc-java-programming-i'
property :title, type: :string, 'x-nullable': true, example: 'Java Programming I'
property :moocfi_id, type: :string, 'x-nullable': true, example: '5b4a46e2-0ec4-4c4e-8a6c-3f8d9a3c1f2e'
property :awarded_points, type: :integer, example: 12
property :submissions, type: :integer, example: 34
property :last_submission_at, type: :string, format: 'date-time', 'x-nullable': true, example: '2026-10-01T12:00:00Z'
end
end
end
end
response 401, '$ref': '#/responses/error'
response 403, '$ref': '#/responses/error'
response 404, '$ref': '#/responses/error'
end
end

skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy course_progress]

sig { void }
def show
Expand Down Expand Up @@ -248,6 +281,14 @@ def destroy
render_destroy_failure('internal_error', 'User deletion failed.', :internal_server_error)
end

# courses.mooc.fi lists this to the user before deleting their account; keep the shape stable.
sig { void }
def course_progress
unauthorize_guest!
only_admins!
render json: { courses: User.find_by!(id: params[:id]).course_progress }
end

sig { void }
def set_password_managed_by_courses_mooc_fi
only_admins!
Expand Down
61 changes: 8 additions & 53 deletions app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
class UsersController < ApplicationController
extend T::Sig
skip_authorization_check
# Stale legacy deletion forms must still redirect, whatever the forgery protection strategy.
skip_forgery_protection only: :redirect_to_account_deletion

after_action :remove_x_frame_options_header_when_bare_layout, only: %i[new create show]

Expand Down Expand Up @@ -128,63 +130,16 @@ def send_verification_email
end

sig { void }
def verify_destroying_user
@user = authenticate_current_user_destroy
VerificationToken.delete_user.find_by!(user: @user, token: params[:token])
end

sig { void }
def destroy_user
im_sure = params[:im_sure]
if im_sure != '1'
redirect_to verify_destroying_user_url, notice: 'Please check the checkbox after you have read the instructions.'
return
end
user = authenticate_current_user_destroy
_authenticated_user, status = User.authenticate_with_status(user.login, params[:user][:password])
unless status == :accepted
redirect_to verify_destroying_user_url, alert: failed_authentication_alert(status)
return
def redirect_to_account_deletion
deletion_url = User.courses_mooc_fi_account_deletion_url
if deletion_url
redirect_to deletion_url, allow_other_host: true
else
redirect_to user_path
end
VerificationToken.delete_user.find_by!(user: user, token: params[:token])
sign_out if current_user == user
email = user.email
username = user.login
id = user.id
user.destroy
RecentlyChangedUserDetail.where(username: username).delete_all
RecentlyChangedUserDetail.deleted.create!(new_value: true, email: email, username: username, user_id: id)
Doorkeeper::AccessToken.where(resource_owner_id: id).delete_all
redirect_to root_url, notice: 'Your account has been permanently destroyed.'
end

sig { void }
def send_destroy_email
user = authenticate_current_user_destroy
UserMailer.destroy_confirmation(user).deliver_now
redirect_to root_path, notice: "Verification email sent to #{user.email}."
end

private
sig { returns(User) }
def authenticate_current_user_destroy
user = User.find(params[:user_id])
authorize! :destroy, user
user
end

sig { params(status: T.anything).returns(String) }
def failed_authentication_alert(status)
case status
when :unavailable
'We could not reach the system that holds your password. Please try again in a few minutes, or contact support if this keeps happening.'
when :misconfigured
'Your account needs manual attention before it can be deleted. Please contact support.'
else
'The password was incorrect.'
end
end

sig { void }
def set_email
user = T.must(@user)
Expand Down
8 changes: 0 additions & 8 deletions app/mailers/user_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,6 @@ def email_confirmation(user, origin = nil, language = nil)
mail(from: SiteSetting.value('emails')['from'], to: user.email, subject: subject)
end

sig { params(user: User).void }
def destroy_confirmation(user)
@user = T.let(user, T.nilable(User))
token = user.verification_tokens.delete_user.create!
@url = T.let(verify_destroying_user_url(user.id, token.token), T.nilable(String))
mail(from: SiteSetting.value('emails')['from'], to: user.email, subject: 'Confirm deleting your mooc.fi account')
end

private
sig { returns(String) }
def base_url
Expand Down
45 changes: 42 additions & 3 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ def force_migrate_to_courses_mooc_fi
# nil means genuinely unknown (unconfigured, network error, unexpected response), not "not migrated".
sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
def courses_mooc_fi_migration_status
return nil if SiteSetting.value('courses_mooc_fi_base_url').blank?
return nil unless User.courses_mooc_fi_base_url

conn = courses_mooc_fi_connection

Expand Down Expand Up @@ -496,16 +496,55 @@ def courses_mooc_fi_migration_status
sig { returns(T.nilable(String)) }
def courses_mooc_fi_profile_url
return nil if courses_mooc_fi_user_id.blank?
return nil if SiteSetting.value('courses_mooc_fi_base_url').blank?
return nil unless User.courses_mooc_fi_base_url

courses_mooc_fi_url("/manage/users/#{courses_mooc_fi_user_id}")
end

# courses.mooc.fi's root URL without a trailing slash. Nil when it isn't configured.
sig { returns(T.nilable(String)) }
def self.courses_mooc_fi_base_url
SiteSetting.value('courses_mooc_fi_base_url').to_s.chomp('/').presence
end

# Where users delete their account, which also deletes it here. Nil when courses.mooc.fi isn't configured.
sig { returns(T.nilable(String)) }
def self.courses_mooc_fi_account_deletion_url
base_url = courses_mooc_fi_base_url
"#{base_url}/user-settings/permissions#delete-account" if base_url
end

sig { returns(T.nilable(ActionToken)) }
def password_reset_key
action_tokens.find { |t| t.action == 'reset_password' }
end

# Submission and awarded point totals for every course the user has any of either in, hidden
# and disabled courses included. Sorted by latest submission (courses with none last), then name.
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def course_progress
submission_totals = submissions.group(:course_id)
.pluck(:course_id, Arel.sql('COUNT(*)'), Arel.sql('MAX(submissions.created_at)'))
.to_h { |course_id, count, latest| [course_id, { count: count, latest: latest }] }
point_counts = awarded_points.group(:course_id).count
courses = Course.where(id: submission_totals.keys | point_counts.keys).pluck(:id, :name, :title, :moocfi_id)

courses.sort_by do |course_id, name|
latest_submission_at = submission_totals.dig(course_id, :latest)
[latest_submission_at ? -latest_submission_at.to_r : Float::INFINITY, name.to_s]
end.map do |course_id, name, title, moocfi_id|
{
id: course_id,
name: name,
title: title,
moocfi_id: moocfi_id,
awarded_points: point_counts.fetch(course_id, 0),
submissions: submission_totals.dig(course_id, :count) || 0,
last_submission_at: submission_totals.dig(course_id, :latest)&.utc&.iso8601
}
end
end

sig { params(course: Course, point_name: String).returns(T::Boolean) }
def has_point?(course, point_name)
awarded_points.where(course_id: course.id, name: point_name).any?
Expand Down Expand Up @@ -679,7 +718,7 @@ def courses_mooc_fi_connection

sig { params(path: String).returns(String) }
def courses_mooc_fi_url(path)
"#{SiteSetting.value('courses_mooc_fi_base_url')}#{path}"
"#{User.courses_mooc_fi_base_url}#{path}"
end

sig { returns(Arel::SelectManager) }
Expand Down
4 changes: 1 addition & 3 deletions app/views/settings/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@

<%= render :partial => 'settings/form', :user => @user, locals: { hide_login: true } %>

<% if can? :destroy, @user %>
<%= button_to 'Request deleting account', send_destroy_email_path(@user.id), method: :post, class: 'btn btn-danger' %>
<% end %>
<%= render 'users/account_deletion', user: @user %>

<br>

Expand Down
2 changes: 1 addition & 1 deletion app/views/settings/user_has_submissions.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div class="jumbotron hero-unit-alert alert-danger">
You can't delete this user, because they have submissions. Advice them to delete their own account by pressing "Request deleting account" -button themselves from their User settings.
You can't delete this user, because they have submissions. Advise them to delete their own account themselves with the "Delete account on courses.mooc.fi" button in their User settings.
<br>
<br>
<strong>Make sure that they realize they will lose their progress in all of their courses, and can't ever get any certificates etc. for anything they've done after account deletion.</strong>
Expand Down
17 changes: 0 additions & 17 deletions app/views/user_mailer/destroy_confirmation.html.erb

This file was deleted.

9 changes: 0 additions & 9 deletions app/views/user_mailer/destroy_confirmation.text.erb

This file was deleted.

7 changes: 7 additions & 0 deletions app/views/users/_account_deletion.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<% if user == current_user %>
<% if (deletion_url = User.courses_mooc_fi_account_deletion_url) %>
<%= link_to 'Delete account on courses.mooc.fi', deletion_url, class: 'btn btn-danger', target: '_top' %>
<% else %>
<p>To delete your account, please contact support.</p>
<% end %>
<% end %>
4 changes: 1 addition & 3 deletions app/views/users/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,4 @@

<%= render :partial => 'users/form', :user => @user, locals: { hide_login: true } %>

<% if can? :destroy, @user %>
<%= button_to 'Request deleting account', send_destroy_email_path(@user.id), method: :post, class: 'btn btn-danger' %>
<% end %>
<%= render 'users/account_deletion', user: @user %>
32 changes: 0 additions & 32 deletions app/views/users/verify_destroying_user.html.erb

This file was deleted.

23 changes: 23 additions & 0 deletions config/brakeman.ignore
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,29 @@
77
],
"note": "sh! Shellwords-escapes every word unless escape: false, which no caller passes."
},
{
"warning_type": "Redirect",
"warning_code": 18,
"fingerprint": "6359af9828749190072deea63d61f76cd4ebf9c9987ef93c154d7da9e902aeaa",
"check_name": "Redirect",
"message": "Possible unprotected redirect",
"file": "app/controllers/users_controller.rb",
"line": 136,
"link": "https://brakemanscanner.org/docs/warning_types/redirect/",
"code": "redirect_to(User.courses_mooc_fi_account_deletion_url, :allow_other_host => true)",
"render_path": null,
"location": {
"type": "method",
"class": "UsersController",
"method": "redirect_to_account_deletion"
},
"user_input": "User.courses_mooc_fi_account_deletion_url",
"confidence": "Weak",
"cwe_id": [
601
],
"note": "The URL comes from the courses_mooc_fi_base_url site setting, not from request input."
}
],
"brakeman_version": "8.1.0"
Expand Down
7 changes: 4 additions & 3 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
resources :assistantships, module: :users, only: :index
resources :teacherships, module: :users, only: :index
post :set_password_managed_by_courses_mooc_fi, on: :member
get :course_progress, on: :member
get :get_user_with_email, on: :collection
end

Expand Down Expand Up @@ -290,9 +291,9 @@

resources :users, only: [:index]

post '/users/:user_id/send_destroy_email', to: 'users#send_destroy_email', as: 'send_destroy_email'
get '/users/:user_id/destroy/:token', to: 'users#verify_destroying_user', as: 'verify_destroying_user'
delete '/users/:user_id/destroy/:token', to: 'users#destroy_user', as: 'destroy_user'
post '/users/:user_id/send_destroy_email', to: 'users#redirect_to_account_deletion'
# Deletion confirmation links already in users' inboxes land here too.
match '/users/:user_id/destroy/:id', to: 'users#redirect_to_account_deletion', via: %i[get delete]

resources :certificates, only: %i[show create]

Expand Down
Loading
Loading