# retoor import logging import re from datetime import datetime, timezone from fastapi import APIRouter, Request from fastapi.responses import Response from devplacepy.cache import TTLCache from devplacepy.config import SECONDS_PER_DAY from devplacepy.database import get_table, get_setting from devplacepy.utils import verify_password_async, register_account_async from devplacepy.services.audit import record as audit from devplacepy.services.devrant.params import merge_params from devplacepy.services.devrant.tokens import issue_token, resolve_user, revoke_all from devplacepy.services.devrant.profile import build_profile from devplacepy.services.devrant.ids import user_by_id from devplacepy.services.devrant.avatar import render_png from devplacepy.routers.devrant._shared import dr_ok, dr_error, unauthorized logger = logging.getLogger(__name__) router = APIRouter() USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_-]{3,32}$") PASSWORD_MIN = 6 _avatar_cache = TTLCache(ttl=SECONDS_PER_DAY, max_size=4096) @router.post("/users/auth-token") async def auth_token(request: Request): params = await merge_params(request) identifier = (params.get("username") or "").strip() password = params.get("password") or "" invalid = "Invalid login credentials entered. Please try again." if not identifier or not password: return dr_error(invalid, 400) users = get_table("users") user = users.find_one(username=identifier) or users.find_one( email=identifier.lower() ) if ( not user or not user.get("is_active", True) or not await verify_password_async(password, user["password_hash"]) ): audit.record( request, "auth.login.failure", user=None, actor_kind="guest", result="failure", origin="devrant", metadata={"identifier": identifier}, summary=f"failed devrant login for {identifier}", ) return dr_error(invalid, 400) token = issue_token(user) logger.info("devrant login for %s", user["username"]) audit.record( request, "auth.login.success", user=user, target_type="user", target_uid=user["uid"], target_label=user["username"], origin="devrant", summary=f"user {user['username']} logged in via devrant", links=[audit.target("user", user["uid"], user["username"])], ) return dr_ok(auth_token=token) @router.post("/users") async def register(request: Request): params = await merge_params(request) username = (params.get("username") or "").strip() email = (params.get("email") or "").strip().lower() password = params.get("password") or "" if get_setting("registration_open", "1") != "1": return dr_error("Registration is closed.", error_field="username") if not USERNAME_PATTERN.match(username): return dr_error( "Username must be 3-32 letters, numbers, hyphens or underscores.", error_field="username", ) if "@" not in email: return dr_error("Please enter a valid email address.", error_field="email") if len(password) < PASSWORD_MIN: return dr_error( "Password must be at least 6 characters.", error_field="password" ) users = get_table("users") if users.find_one(username=username): return dr_error("That username is already taken.", error_field="username") if users.find_one(email=email): return dr_error("That email is already registered.", error_field="email") uid, role, is_first = await register_account_async(username, email, password) logger.info("devrant registration for %s", username) audit.record( request, "auth.signup", user={"uid": uid, "username": username, "role": role}, target_type="user", target_uid=uid, target_label=username, new_value=role, origin="devrant", metadata={"first_user": is_first}, summary=f"user {username} registered via devrant", links=[audit.target("user", uid, username)], ) user = users.find_one(uid=uid) return dr_ok(auth_token=issue_token(user)) @router.get("/get-user-id") async def get_user_id(request: Request): params = await merge_params(request) username = (params.get("username") or "").strip() user = get_table("users").find_one(username=username) if username else None if not user: return dr_error("User not found.") return dr_ok(user_id=int(user["id"])) @router.get("/users/{user_id}") async def profile(request: Request, user_id: str): params = await merge_params(request) user = user_by_id(user_id) if not user: return dr_error("User not found.") viewer = resolve_user(params) return dr_ok(profile=build_profile(user, viewer)) @router.post("/users/me/edit-profile") async def edit_profile(request: Request): params = await merge_params(request) user = resolve_user(params) if not user: return unauthorized() updates = {"uid": user["uid"]} field_map = { "profile_about": "bio", "profile_location": "location", "profile_github": "git_link", "profile_website": "website", } for source, column in field_map.items(): if source in params: updates[column] = (params.get(source) or "")[:500] if len(updates) > 1: get_table("users").update(updates, ["uid"]) audit.record( request, "profile.update", user=user, target_type="user", target_uid=user["uid"], target_label=user["username"], origin="devrant", summary=f"{user['username']} edited profile via devrant", links=[audit.target("user", user["uid"], user["username"])], ) return dr_ok() @router.post("/users/forgot-password") async def forgot_password(request: Request): await merge_params(request) return dr_ok() @router.post("/users/me/mark-news-read") async def mark_news_read(request: Request): await merge_params(request) return dr_ok() @router.post("/users/me/resend-confirm") async def resend_confirm(request: Request): await merge_params(request) return dr_ok() @router.delete("/users/me") async def delete_account(request: Request): params = await merge_params(request) user = resolve_user(params) if not user: return unauthorized() get_table("users").update( {"uid": user["uid"], "is_active": False}, ["uid"] ) revoke_all(user["uid"]) logger.info("devrant account deactivated for %s", user["username"]) audit.record( request, "auth.account.disable", user=user, target_type="user", target_uid=user["uid"], target_label=user["username"], origin="devrant", summary=f"{user['username']} deactivated account via devrant", links=[audit.target("user", user["uid"], user["username"])], ) return dr_ok() @router.get("/avatars/u/{seed}.png") async def avatar_image(request: Request, seed: str, size: int = 128): size = max(16, min(512, size)) cache_key = f"{seed}:{size}" png = _avatar_cache.get(cache_key) if png is None: png = render_png(seed, size) _avatar_cache.set(cache_key, png) headers = {"Cache-Control": f"public, max-age={SECONDS_PER_DAY}, immutable"} return Response(content=png, media_type="image/png", headers=headers)