|
# 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
|