diff --git a/app/controllers/__init__.py b/app/controllers/__init__.py index b2a40f3..690bfbd 100644 --- a/app/controllers/__init__.py +++ b/app/controllers/__init__.py @@ -20,6 +20,7 @@ def register_blueprints(app): from app.controllers.rules import rules_bp from app.controllers.password import password_bp from app.controllers.password_reset import password_reset_bp + from app.controllers.account_deletion import account_deletion_bp from app.controllers.static import static_bp from app.controllers.error import error_bp @@ -38,5 +39,6 @@ def register_blueprints(app): app.register_blueprint(rules_bp) app.register_blueprint(password_bp) app.register_blueprint(password_reset_bp) + app.register_blueprint(account_deletion_bp) app.register_blueprint(static_bp) app.register_blueprint(error_bp) diff --git a/app/controllers/account_deletion.py b/app/controllers/account_deletion.py new file mode 100644 index 0000000..f74b7c7 --- /dev/null +++ b/app/controllers/account_deletion.py @@ -0,0 +1,91 @@ +""" +Account deletion controller - let a logged-in user request deletion of their +own account. The request lands in a reviewer queue; an admin approves it, which +runs the actual deletion and emails the user. +""" + +from flask import Blueprint, render_template, request, session +from app.models.user import user_by_name +from app.models.account_deletion_request import ( + account_deletion_request_create, + pending_account_deletion_request_by_user, +) +from app.services.passhash import match_string +from app.services.limiter import limit +from app.controllers.decorators import login_required + +account_deletion_bp = Blueprint('account_deletion', __name__) + + +@account_deletion_bp.route('/delete-account', methods=['GET']) +@login_required +def delete_account_get(): + """Display the account deletion request form.""" + username = session.get('name') + + already_pending = False + try: + already_pending = bool( + pending_account_deletion_request_by_user(username) + ) + except Exception as e: + print(f"Error checking pending deletion request: {e}") + + return render_template('user/delete-account.html', + already_pending=already_pending, + message=None, + submitted=False) + + +@account_deletion_bp.route('/delete-account', methods=['POST']) +@login_required +@limit("5 per hour", key_func=lambda: session.get('name')) +def delete_account_post(): + """Create a pending account deletion request for the current user.""" + username = session.get('name') + if not username: + return 'User not logged in or session invalid', 401 + + password = request.form.get('password', '') + note = request.form.get('note', '').strip() + + try: + user = user_by_name(username) + except Exception as e: + print(f"Error: User not found: {e}") + return render_template('user/delete-account.html', + already_pending=False, + message="User not found.", + submitted=False) + + # Require the current password to confirm the request. This prevents an + # unattended/hijacked session from deleting the account. + if not password or not match_string(user['password'], password): + return render_template('user/delete-account.html', + already_pending=False, + message="Password is incorrect.", + submitted=False) + + # Don't stack duplicate pending requests for the same user. + try: + if pending_account_deletion_request_by_user(username): + return render_template('user/delete-account.html', + already_pending=True, + message=None, + submitted=False) + except Exception as e: + print(f"Error checking pending deletion request: {e}") + + try: + account_deletion_request_create(username, user.get('email', ''), note) + except Exception as e: + print(f"Error creating deletion request: {e}") + return render_template('user/delete-account.html', + already_pending=False, + message="Could not submit your request. Please try again later.", + submitted=False) + + return render_template('user/delete-account.html', + already_pending=True, + message=None, + submitted=True) diff --git a/app/models/account_deletion_request.py b/app/models/account_deletion_request.py new file mode 100644 index 0000000..a4b27d0 --- /dev/null +++ b/app/models/account_deletion_request.py @@ -0,0 +1,121 @@ +""" +Account deletion request model. + +A logged-in user can request that their own account be deleted. Requests land +in a pending queue that reviewers approve or reject; approving a request runs the +full account deletion and emails the (now former) user. The requester's email is +denormalized onto the request so the confirmation email can still be sent after +the user document is gone. +""" + +from datetime import datetime +from bson import ObjectId +from pymongo import DESCENDING, ReturnDocument +from app.services.database import get_collection, check_connection +from app.models.errors import ErrNoResult, ErrUnavailable + +# Request lifecycle states. +STATUS_PENDING = 'pending' +STATUS_APPROVED = 'approved' +STATUS_REJECTED = 'rejected' + + +def account_deletion_request_create(username, email, note=''): + """Create a pending account deletion request. + + Args: + username: Username of the requesting user + email: The user's email (denormalized for the confirmation email) + note: Optional free-text reason from the requester + + Returns: + The inserted request document. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + obj_id = ObjectId() + + request = { + '_id': obj_id, + 'hexid': str(obj_id), + 'requester': username, + 'email': email, + 'note': note or '', + 'status': STATUS_PENDING, + 'created_at': datetime.utcnow(), + 'reviewed_by': None, + 'reviewed_at': None, + } + + collection.insert_one(request) + return request + + +def account_deletion_requests_pending(): + """Return all pending account deletion requests, newest first.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return list(collection.find({'status': STATUS_PENDING}) + .sort('created_at', DESCENDING)) + + +def count_pending_account_deletion_requests(): + """Count pending account deletion requests.""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.count_documents({'status': STATUS_PENDING}) + + +def account_deletion_request_by_hexid(hexid): + """Get an account deletion request by its hex ID. + + Raises: + ErrNoResult: If no request matches. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + request = collection.find_one({'hexid': hexid}) + if not request: + raise ErrNoResult("Account deletion request not found") + return request + + +def account_deletion_request_set_status(hexid, status, reviewer): + """Mark a request approved/rejected and stamp the reviewer. + + Returns: + The updated request document, or None if not found. + """ + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.find_one_and_update( + {'hexid': hexid}, + {'$set': { + 'status': status, + 'reviewed_by': reviewer, + 'reviewed_at': datetime.utcnow(), + }}, + return_document=ReturnDocument.AFTER + ) + + +def pending_account_deletion_request_by_user(username): + """Return a user's existing pending deletion request, if any (anti-dup).""" + if not check_connection(): + raise ErrUnavailable("Database is unavailable") + + collection = get_collection('account_deletion_request') + return collection.find_one({ + 'requester': username, + 'status': STATUS_PENDING, + }) diff --git a/review/routes.py b/review/routes.py index 126f4c1..7c2d778 100644 --- a/review/routes.py +++ b/review/routes.py @@ -49,6 +49,11 @@ label_requests_pending, count_pending_label_requests, label_request_by_hexid, label_request_set_status, STATUS_APPROVED, STATUS_REJECTED, ) +from app.models.account_deletion_request import ( + account_deletion_requests_pending, count_pending_account_deletion_requests, + account_deletion_request_by_hexid, account_deletion_request_set_status, +) +from app.services.email import send_email from app.models.errors import ErrNoResult @@ -1377,13 +1382,20 @@ def dashboard(current_user): print(f"Error counting label requests: {e}") labelreq_cnt = 0 + try: + acctdel_cnt = count_pending_account_deletion_requests() + except Exception as e: + print(f"Error counting account deletion requests: {e}") + acctdel_cnt = 0 + return render_template( 'reviewer/dashboard.html', user=current_user['username'], is_admin=current_user['is_admin'], solution_cnt=count_pending_solutions(), crackme_cnt=count_pending_items('crackme'), - labelreq_cnt=labelreq_cnt + labelreq_cnt=labelreq_cnt, + acctdel_cnt=acctdel_cnt ) @@ -1830,6 +1842,145 @@ def rejectlabelrequest(current_user): return redirect(url_for('reviewer.labelrequests', message="Request rejected")) +# ============================================================================= +# Route Handlers - Account Deletion Requests +# ============================================================================= + +def send_account_deleted_email(email, username): + """Email a user confirming their account has been deleted. + + Best-effort: returns True on success, False otherwise. Never raises. + """ + if not email: + return False + + subject = "Your crackmes.one account has been deleted" + body = ( + f"Hello {username},\n\n" + "As you requested, your crackmes.one account and all of its associated " + "data (crackmes, writeups, comments, and ratings) have been permanently " + "deleted.\n\n" + "If you did not request this, or you believe this was a mistake, please " + "reply to this email or contact us at crackmesone@gmail.com.\n\n" + "Thanks for having been part of the community.\n" + "- The crackmes.one team" + ) + try: + return send_email(email, subject, body) + except Exception as e: + print(f"Error sending account deletion email: {e}") + return False + + +@reviewer_bp.route('/accountdeletionrequests') +@token_required +def accountdeletionrequests(current_user): + """List pending account deletion requests for review.""" + try: + requests_list = account_deletion_requests_pending() + except Exception as e: + print(f"Error loading account deletion requests: {e}") + requests_list = [] + + return render_template( + 'reviewer/accountdeletionrequests.html', + user=current_user['username'], + is_admin=current_user['is_admin'], + requests=requests_list, + message=request.args.get('message') + ) + + +@reviewer_bp.route('/approveaccountdeletion', methods=['POST']) +@admin_required +def approveaccountdeletion(current_user): + """Approve an account deletion request and delete the account (admin only).""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + + try: + del_request = account_deletion_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.accountdeletionrequests', message="Request not found")) + + if del_request.get('status') != 'pending': + return redirect(url_for('reviewer.accountdeletionrequests', message="Request already handled")) + + username = del_request.get('requester') + email = del_request.get('email', '') + + # The stored email is what the account had when the request was made; fall + # back to the current account email so deletion still targets the right user. + delete_email = email + if username: + user = g_crackmesone_db.user.find_one({'name': username}) + if user and user.get('email'): + delete_email = user['email'] + email = email or user['email'] + + if not delete_email: + return redirect(url_for( + 'reviewer.accountdeletionrequests', + message="Request has no email on file; cannot delete" + )) + + result = delete_user_account(delete_email, admin_username=current_user['username']) + success = "successful" in result.lower() + + log_reviewer_operation( + "approve_account_deletion", current_user['username'], + {"request": req_hexid, "user": username, "email": delete_email, "result": result}, + success + ) + + if not success: + return redirect(url_for('reviewer.accountdeletionrequests', message=result)) + + account_deletion_request_set_status(req_hexid, STATUS_APPROVED, current_user['username']) + + email_sent = send_account_deleted_email(email, username) + + msg = f"Account '{username}' deleted." + msg += " Confirmation email sent." if email_sent else " (Confirmation email not sent.)" + return redirect(url_for('reviewer.accountdeletionrequests', message=msg)) + + +@reviewer_bp.route('/rejectaccountdeletion', methods=['POST']) +@token_required +def rejectaccountdeletion(current_user): + """Reject an account deletion request.""" + validate_csrf_token() + req_hexid = request.form.get('uuid') + reject_reason = request.form.get('reject_reason') + + try: + del_request = account_deletion_request_by_hexid(req_hexid) + except ErrNoResult: + return redirect(url_for('reviewer.accountdeletionrequests', message="Request not found")) + + if del_request.get('status') != 'pending': + return redirect(url_for('reviewer.accountdeletionrequests', message="Request already handled")) + + account_deletion_request_set_status(req_hexid, STATUS_REJECTED, current_user['username']) + + log_reviewer_operation( + "reject_account_deletion", current_user['username'], + {"request": req_hexid, "user": del_request.get('requester'), "reason": reject_reason}, + True + ) + + try: + notif = "Your account deletion request has been declined." + if reject_reason: + notif += f" Reason: {html_escape(reject_reason)}" + notif += " If you still wish to delete your account, please contact crackmesone@gmail.com." + send_user_notification(del_request['requester'], notif) + except Exception as e: + print(f"Notification error: {e}") + + return redirect(url_for('reviewer.accountdeletionrequests', message="Request rejected")) + + # ============================================================================= # Route Handlers - Admin: Delete Approved Content # ============================================================================= diff --git a/review/templates/reviewer/accountdeletionrequests.html b/review/templates/reviewer/accountdeletionrequests.html new file mode 100644 index 0000000..c6a3469 --- /dev/null +++ b/review/templates/reviewer/accountdeletionrequests.html @@ -0,0 +1,78 @@ + + + +
+No pending account deletion requests.
+ {% else %} + {% for req in requests %} +
+ {{ req.requester }}
+ — {{ req.email }}
+
+ Requested on {{ req.created_at }}
+
Reason: {{ req.note }}
+ {% endif %} + + {% if is_admin %} + + {% endif %} + + +The number of account deletion requests to review:
+Please email us at crackmesone@gmail.com to request account deletion.
+You can request account deletion yourself: log in, click "Profile", scroll to the + bottom of the page, and click "Delete Account". You'll be asked to confirm your + password. Your request is then reviewed by an admin and, once approved, your account + and all associated data (crackmes, writeups, comments, and ratings) are permanently + deleted. We'll email you at your registered address once the deletion is complete.
+Account deletion requests are processed within 30 days, though in practice they + are usually handled much faster.
+If you cannot log in but still have access to the email address you registered with, + first use the Forgot Password page to reset your + password, then log in and request account deletion as described above.
+If you cannot log in and no longer have access to your registered email address, please + email us at crackmesone@gmail.com so we can help verify your identity and process the + deletion manually.
Your request will be reviewed by an admin and processed within 30 days, though + usually much sooner. Once your account has been deleted, we'll send a + confirmation to your registered email address.
+ + + {% endif %} +