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 @@ +
| goober | +date | +trust | +exec | +|
|---|---|---|---|---|
|
+
+
+ |
+ <%= 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 " } %>
+
+ |
+
nuthing here
+ <% end %> +| goober | +approver | +approved | +exploded | +eta | +
|---|---|---|---|---|
|
+
+
+ |
+ <%= 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 + + | +
nuthing here
+ <% end %> +| goober | +approver | +kerblamed | +
|---|---|---|
| #<%= request.user_id %> | +<%= request.admin_approved_by&.display_name || "N/A" %> | +<%= request.completed_at&.strftime("%b %d, %Y at %I:%M %p") %> | +
nuthing here
+ <% end %> +Your account is scheduled to self-destruct soon.
++ Your deletion request is pending approval. During this time, we will review your request and get back to you as soon as possible. +
++ 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. +
+