diff --git a/app/assets/stylesheets/nav.css b/app/assets/stylesheets/nav.css index 09588b65..5b358976 100644 --- a/app/assets/stylesheets/nav.css +++ b/app/assets/stylesheets/nav.css @@ -5,8 +5,6 @@ body { main { flex: 1; - margin-left: 250px; - max-width: calc(100% - 250px); padding: 20px; margin-bottom: 100px; transition: margin-left 0.3s ease, max-width 0.3s ease; diff --git a/app/controllers/admin/deletion_requests_controller.rb b/app/controllers/admin/deletion_requests_controller.rb new file mode 100644 index 00000000..8c91b3af --- /dev/null +++ b/app/controllers/admin/deletion_requests_controller.rb @@ -0,0 +1,35 @@ +class Admin::DeletionRequestsController < Admin::BaseController + before_action :set_deletion_request, only: [ :show, :approve, :reject ] + before_action :require_admin, only: [ :index, :show, :approve, :reject ] + + def index + @pending = DeletionRequest.pending.includes(:user).order(requested_at: :asc) + @approved = DeletionRequest.approved.includes(:user, :admin_approved_by).order(scheduled_deletion_at: :asc) + @done = DeletionRequest.completed.includes(:user, :admin_approved_by).order(completed_at: :desc).limit(25) + end + + def show + end + + def approve + @deletion_request.approve!(current_user) + redirect_to admin_deletion_requests_path, notice: "they gonna go kerblam on #{@deletion_request.scheduled_deletion_at.strftime('%B %d, %Y')}." + end + + def reject + @deletion_request.cancel! + redirect_to admin_deletion_requests_path, notice: "ratioed + stay mad" + end + + private + + def set_deletion_request + @deletion_request = DeletionRequest.find(params[:id]) + end + + def require_admin + unless current_user && current_user.admin_level.in?([ "superadmin" ]) + redirect_to root_path, alert: "no perms lmaooo" + end + end +end diff --git a/app/controllers/api/hackatime/v1/hackatime_controller.rb b/app/controllers/api/hackatime/v1/hackatime_controller.rb index bc6368f4..3aab523c 100644 --- a/app/controllers/api/hackatime/v1/hackatime_controller.rb +++ b/app/controllers/api/hackatime/v1/hackatime_controller.rb @@ -1,6 +1,8 @@ class Api::Hackatime::V1::HackatimeController < ApplicationController before_action :set_user skip_before_action :verify_authenticity_token + skip_before_action :enforce_lockout + before_action :check_lockout, only: [ :push_heartbeats ] before_action :set_raw_heartbeat_upload, only: [ :push_heartbeats ], if: :is_blank? def push_heartbeats @@ -309,6 +311,11 @@ def queue_heartbeat_public_activity(user_id, project_name) Rails.logger.error("Error queuing heartbeat public activity: #{e.class.name} #{e.message}") end + def check_lockout + return unless @user&.pending_deletion? + render json: { error: "Account pending deletion" }, status: :forbidden + end + def set_user api_header = request.headers["Authorization"] raw_token = api_header&.split(" ")&.last diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 08dfc4a5..27ad0ed5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,6 +5,7 @@ class ApplicationController < ActionController::Base before_action :try_rack_mini_profiler_enable before_action :track_request before_action :set_public_activity + before_action :enforce_lockout after_action :track_action around_action :switch_time_zone, if: :current_user @@ -64,6 +65,12 @@ def authenticate_user! end end + def enforce_lockout + return unless current_user&.pending_deletion? + return if %w[deletion_requests sessions].include?(controller_name) + redirect_to deletion_path + end + def initialize_cache_counters Thread.current[:cache_hits] = 0 Thread.current[:cache_misses] = 0 diff --git a/app/controllers/deletion_requests_controller.rb b/app/controllers/deletion_requests_controller.rb new file mode 100644 index 00000000..7cd33863 --- /dev/null +++ b/app/controllers/deletion_requests_controller.rb @@ -0,0 +1,39 @@ +class DeletionRequestsController < ApplicationController + before_action :require_login + before_action :check_can_request, only: [ :create ] + + def show + @deletion_request = current_user.active_deletion_request + redirect_to root_path, alert: "no request" unless @deletion_request + end + + def create + @deletion_request = DeletionRequest.create_for_user!(current_user) + redirect_to deletion_path + rescue ActiveRecord::RecordInvalid => e + Sentry.capture_exception(e) + redirect_to my_settings_path + end + + def cancel + @deletion_request = current_user.active_deletion_request + if @deletion_request&.can_be_cancelled? + @deletion_request.cancel! + redirect_to my_settings_path, notice: "Your deletion request has been cancelled!" + else + redirect_to deletion_path + end + end + + private + + def require_login + redirect_to root_path, alert: "who?" unless current_user + end + + def check_can_request + unless current_user.can_request_deletion? + redirect_to my_settings_path, alert: "You can't request deletion right now." + end + end +end diff --git a/app/javascript/controllers/account_deletion_controller.js b/app/javascript/controllers/account_deletion_controller.js new file mode 100644 index 00000000..6eec7728 --- /dev/null +++ b/app/javascript/controllers/account_deletion_controller.js @@ -0,0 +1,18 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + connect() { + console.log("AccountDeletionController connected"); + } + + confirm(event) { + event.preventDefault(); + console.log("AccountDeletionController#confirm called"); + const modal = document.getElementById("account-deletion-confirm-modal"); + if (modal) { + modal.dispatchEvent(new CustomEvent("modal:open", { bubbles: true })); + } else { + console.error("Modal not found: account-deletion-confirm-modal"); + } + } +} diff --git a/app/jobs/process_account_deletions_job.rb b/app/jobs/process_account_deletions_job.rb new file mode 100644 index 00000000..1f6f4a6d --- /dev/null +++ b/app/jobs/process_account_deletions_job.rb @@ -0,0 +1,20 @@ +class ProcessAccountDeletionsJob < ApplicationJob + queue_as :default + + def perform + DeletionRequest.ready_for_deletion.find_each do |deletion_request| + Rails.logger.info "kerblamming ##{deletion_request.user_id}" + + begin + AnonymizeUserService.call(deletion_request.user) + deletion_request.complete! + + Rails.logger.info "kerblamed account ##{deletion_request.user_id}" + rescue StandardError => e + Sentry.capture_exception(e, extra: { user_id: deletion_request.user_id }) + Rails.logger.error "failed to kerblam ##{deletion_request.user_id}: #{e.message}" + Rails.logger.error e.backtrace.join("\n") + end + end + end +end diff --git a/app/models/deletion_request.rb b/app/models/deletion_request.rb new file mode 100644 index 00000000..ffabf7a3 --- /dev/null +++ b/app/models/deletion_request.rb @@ -0,0 +1,70 @@ +class DeletionRequest < ApplicationRecord + belongs_to :user + belongs_to :admin_approved_by, class_name: "User", optional: true + + enum :status, { + pending: 0, + approved: 1, + cancelled: 2, + completed: 3 + } + + validates :requested_at, presence: true + validate :user_not_banned_from_deletion, on: :create + + scope :active, -> { where(status: [ :pending, :approved ]) } + scope :ready_for_deletion, -> { approved.where("scheduled_deletion_at <= ?", Time.current) } + + def self.create_for_user!(user) + create!( + user: user, + requested_at: Time.current, + status: :pending + ) + end + + def approve!(admin) + update!( + status: :approved, + admin_approved_by: admin, + admin_approved_at: Time.current, + scheduled_deletion_at: Time.current + 30.days # grace period, if shit changes, change this + ) + end + + def cancel! + update!( + status: :cancelled, + cancelled_at: Time.current + ) + end + + def complete! + update!( + status: :completed, + completed_at: Time.current + ) + end + + def days_until_deletion + return nil unless scheduled_deletion_at.present? + [ (scheduled_deletion_at.to_date - Date.current).to_i, 0 ].max + end + + def can_be_cancelled? + pending? || approved? + end + + private + + def user_not_banned_from_deletion + return unless user.present? + + if user.red? + last_audit = user.trust_level_audit_logs.order(created_at: :desc).first + if last_audit && last_audit.created_at > 365.days.ago + errors.add(:base, "You can not request data deletion due to a recent ban") + end + end + end +end diff --git a/app/models/email_address.rb b/app/models/email_address.rb index 67d1e2e7..ea82c15d 100644 --- a/app/models/email_address.rb +++ b/app/models/email_address.rb @@ -9,7 +9,8 @@ class EmailAddress < ApplicationRecord enum :source, { signing_in: 0, github: 1, - slack: 2 + slack: 2, + preserved_for_deletion: 3 }, prefix: true before_validation :downcase_email diff --git a/app/models/user.rb b/app/models/user.rb index dde6184b..7215e517 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -118,6 +118,8 @@ def set_trust(level, changed_by_user: nil, reason: nil, notes: nil) has_many :trust_level_audit_logs, dependent: :destroy has_many :trust_level_changes_made, class_name: "TrustLevelAuditLog", foreign_key: "changed_by_id", dependent: :destroy + has_many :deletion_requests, dependent: :destroy + has_many :deletion_approvals, class_name: "DeletionRequest", foreign_key: "admin_approved_by_id" has_many :access_grants, class_name: "Doorkeeper::AccessGrant", @@ -133,6 +135,24 @@ def streak_days @streak_days ||= heartbeats.daily_streaks_for_users([ id ]).values.first end + def active_deletion_request + deletion_requests.active.order(created_at: :desc).first + end + + def pending_deletion? + active_deletion_request.present? + end + + def can_request_deletion? + return false if pending_deletion? + return true unless red? + + last_audit = trust_level_audit_logs.order(created_at: :desc).first + return true unless last_audit + + last_audit.created_at <= 365.days.ago + end + if Rails.env.development? def self.slow_find_by_email(email) # This is an n+1 query, but provided for developer convenience diff --git a/app/services/anonymize_user_service.rb b/app/services/anonymize_user_service.rb new file mode 100644 index 00000000..11ae8427 --- /dev/null +++ b/app/services/anonymize_user_service.rb @@ -0,0 +1,70 @@ +class AnonymizeUserService + def self.call(user) + new(user).call + end + + def initialize(user) + @user = user + end + + def call + ActiveRecord::Base.transaction do + preserve_emails_for_ban_tracking + anonymize_user_data + destroy_associated_records + invalidate_sessions + end + end + + private + + attr_reader :user + + def preserve_emails_for_ban_tracking + user.email_addresses.update_all( + user_id: user.id, + source: EmailAddress.sources[:preserved_for_deletion] + ) + end + + def anonymize_user_data + user.update!( + slack_uid: nil, + slack_username: nil, + slack_avatar_url: nil, + slack_access_token: nil, + slack_scopes: [], + slack_neighborhood_channel: nil, + github_uid: nil, + github_username: nil, + github_avatar_url: nil, + github_access_token: nil, + hca_id: nil, + hca_access_token: nil, + hca_scopes: [], + username: "deleted_user_#{user.id}", + uses_slack_status: false, + country_code: nil, + mailing_address_otc: nil, + deprecated_name: nil + ) + end + + def destroy_associated_records + user.api_keys.destroy_all + user.admin_api_keys.destroy_all + user.sign_in_tokens.destroy_all + user.email_verification_requests.destroy_all + user.wakatime_mirrors.destroy_all + user.project_repo_mappings.destroy_all + user.mailing_address&.destroy + user.heartbeats.destroy_all + + user.access_grants.destroy_all + user.access_tokens.destroy_all + end + + def invalidate_sessions + user.sign_in_tokens.destroy_all + end +end diff --git a/app/views/admin/deletion_requests/index.html.erb b/app/views/admin/deletion_requests/index.html.erb new file mode 100644 index 00000000..70b11d58 --- /dev/null +++ b/app/views/admin/deletion_requests/index.html.erb @@ -0,0 +1,131 @@ +
+
+

gdpr nerds

+
+ +
+

approval queue (<%= @pending.count %>)

+ <% if @pending.any? %> +
+ + + + + + + + + + + + <% @pending.each do |request| %> + + + + + + + + <% end %> + +
gooberemaildatetrustexec
+
+ Avatar + <%= request.user.display_name %> +
+
<%= request.user.email_addresses.first&.email || "N/A" %><%= time_ago_in_words(request.requested_at) %> ago + + <%= request.user.trust_level %> + + +
+ <%= button_to "yuh", approve_admin_deletion_request_path(request), + method: :post, + class: "px-3 py-1 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded transition-colors cursor-pointer" %> + <%= button_to "nah", reject_admin_deletion_request_path(request), + method: :post, + class: "px-3 py-1 bg-red-600 hover:bg-red-500 text-white text-sm font-medium rounded transition-colors cursor-pointer", + data: { confirm: "yo " } %> +
+
+
+ <% else %> +

nuthing here

+ <% end %> +
+ +
+

accounts waiting to go kerblam (<%= @approved.count %>)

+ <% if @approved.any? %> +
+ + + + + + + + + + + + <% @approved.each do |request| %> + + + + + + + + <% end %> + +
gooberapproverapprovedexplodedeta
+
+ Avatar + <%= request.user.display_name %> +
+
<%= request.admin_approved_by&.display_name || "N/A" %><%= request.admin_approved_at&.strftime("%b %d, %Y") %><%= request.scheduled_deletion_at&.strftime("%b %d, %Y") %> + + <%= request.days_until_deletion %> days + +
+
+ <% else %> +

nuthing here

+ <% end %> +
+ +
+

recently kerblamed

+ <% if @done.any? %> +
+ + + + + + + + + + <% @done.each do |request| %> + + + + + + <% end %> + +
gooberapproverkerblamed
#<%= request.user_id %><%= request.admin_approved_by&.display_name || "N/A" %><%= request.completed_at&.strftime("%b %d, %Y at %I:%M %p") %>
+
+ <% else %> +

nuthing here

+ <% end %> +
+
diff --git a/app/views/deletion_requests/show.html.erb b/app/views/deletion_requests/show.html.erb new file mode 100644 index 00000000..25be7d04 --- /dev/null +++ b/app/views/deletion_requests/show.html.erb @@ -0,0 +1,77 @@ +<% content_for :title do %> + Account Deletion Pending +<% end %> + +<% content_for :hide_nav, true %> + +
+
+
+

Account Scheduled for Deletion

+

Your account is scheduled to self-destruct soon.

+
+ +
+
+
+ Status + + <%= @deletion_request.status.humanize %> + +
+ +
+ Requested + <%= @deletion_request.requested_at.strftime("%B %d, %Y at %I:%M %p") %> +
+ + <% if @deletion_request.pending? %> +
+

+ Your deletion request is pending approval. During this time, we will review your request and get back to you as soon as possible. +

+
+ <% elsif @deletion_request.approved? %> +
+ Deletion Date + + <%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %> + (<%= @deletion_request.days_until_deletion %> days remaining) + +
+ +
+

+ Your account will be permanently deleted on <%= @deletion_request.scheduled_deletion_at.strftime("%B %d, %Y") %>. + After deletion, your email address will be retained on file, but all other personal information will be removed or anonymized. +

+
+ <% end %> + +

Important Information

+
    +
  • • During the 30-day waiting period, you cannot upload data, download data, or use your account for Hack Club programs.
  • +
  • • You can cancel this request at any time before the deletion date.
  • +
  • • After deletion, your email address will be retained to prevent ban evasion.
  • +
+
+ +
+ <%= link_to signout_path, method: :delete, class: "w-full inline-flex justify-center px-4 py-3 border border-gray-600 text-gray-300 hover:bg-darkless font-medium rounded text-center transition-colors duration-200" do %> + Return to Login + <% end %> + + <% if @deletion_request.can_be_cancelled? %> + <%= button_to "I changed my mind", + cancel_deletion_path, + method: :delete, + form: { class: "w-full" }, + class: "w-full px-4 py-3 bg-primary hover:bg-red-600 text-white font-medium rounded text-center transition-colors duration-200 cursor-pointer" %> + <% else %> +
+ <% end %> +
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 304268b7..7b0c1782 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -177,22 +177,22 @@ - - - - - <%= render "shared/nav" %> + <% unless content_for?(:hide_nav) %> + + + <%= render "shared/nav" %> + <% end %> -
+
<%= yield %>