# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from devplacepy.dependencies import json_or_form
from devplacepy.models import AccountDeleteForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.routers.profile._shared import resolve_customization_target
from devplacepy.schemas import AccountDeletionOut
from devplacepy.seo import base_seo_context
from devplacepy.services.audit import record as audit
from devplacepy.services.moderation import deletion
from devplacepy.utils import get_current_user, verify_password_async
logger = logging.getLogger(__name__)
router = APIRouter()
REMOVED = [
"Your account record, username, email address and password",
"Your profile: bio, location, links and avatar",
"Your posts, comments, gists, projects, project files and quizzes",
"Your uploads and media gallery",
"Your direct-message history, votes, reactions, bookmarks and polls",
"Your API key, access tokens and every signed-in session",
"Your assistant conversations, tasks, lessons and custom tools",
]
RETAINED = [
"Append-only audit and moderation records, which hold identifiers rather than "
"your profile, so the platform can show it enforced its own rules",
"Backup archives, until they rotate out on their normal schedule",
]
def _owner_only(
request: Request,
username: str,
message: str = "Only the account holder can delete this account",
):
target, denied = resolve_customization_target(request, username)
if denied is not None:
return None, denied
viewer = get_current_user(request)
if not viewer or viewer["uid"] != target["uid"]:
audit.record(
request,
"security.authz.denied",
user=viewer,
result="denied",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
metadata={"reason": message},
summary=f"non-owner denied {request.method} {request.url.path}",
links=[audit.target("user", target["uid"], target["username"])],
)
return None, json_error(403, message)
return target, None
async def _password_matches(password: str, hashed: str) -> bool:
if not hashed:
return False
try:
return await verify_password_async(password, hashed)
except ValueError:
return False
@router.get("/{username}/delete", response_class=HTMLResponse)
async def delete_account_page(request: Request, username: str):
target, denied = _owner_only(request, username)
if denied is not None:
return denied
seo_ctx = base_seo_context(
request,
title="Delete your account",
description="Permanently remove your DevPlace account and personal data.",
robots="noindex,nofollow",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": target["username"], "url": f"/profile/{target['username']}"},
{
"name": "Delete account",
"url": f"/profile/{target['username']}/delete",
},
],
)
return respond(
request,
"account_delete.html",
{
**seo_ctx,
"request": request,
"user": target,
"username": target["username"],
"grace_hours": deletion.grace_hours(),
"removed": REMOVED,
"retained": RETAINED,
},
model=AccountDeletionOut,
)
@router.post("/{username}/delete")
async def delete_account(
request: Request,
username: str,
data: Annotated[AccountDeleteForm, Depends(json_or_form(AccountDeleteForm))],
):
target, denied = _owner_only(request, username)
if denied is not None:
return denied
if not await _password_matches(data.password, target.get("password_hash", "")):
audit.record(
request,
"account.delete.request",
result="denied",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
summary=f"account deletion for {target['username']} refused: wrong password",
links=[audit.target("user", target["uid"], target["username"])],
)
return json_error(403, "That password is not correct")
result = deletion.delete_account(target)
if result is None:
return json_error(409, "This account is already being deleted")
logger.info(f"Account {target['username']} deleted by request")
audit.record(
request,
"account.delete.request",
target_type="user",
target_uid=target["uid"],
target_label=target["username"],
metadata={
"stamp": result["stamp"],
"rows": result["rows"],
"grace_hours": result["grace_hours"],
},
summary=f"account {target['username']} deleted",
links=[audit.target("user", target["uid"], target["username"])],
)
response = action_result(request, "/", data=result)
response.delete_cookie("session")
return response