"""Hunter profiles: the identity an account buys you. The open door means a séance needs no account at all, so registering has to be worth something on its own. This is that something — a name other hunters see, a rank that grows from what you have actually contacted, and a public page. Device pairing and messages are the other two. Privacy rules that the tests pin, because they are easy to regress: - `email` is returned by GET /api/profile/me and by NOTHING else. It exists for account recovery; it is never part of a public payload. - `profile_public = False` makes the public page 404 and drops the hunter from the roster. It deliberately does NOT stop messages arriving — privacy here is about being browsed, not about being unreachable. - Guests (`wanderer-` accounts) have profiles like anyone else. They are real rows; nothing here special-cases them. """ import re from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app import rank from app.db import get_db from app.deps import get_current_user from app.models.entity import Entity from app.models.entity_sighting import EntitySighting from app.models.user import User from app.rate_limit import RateLimiter router = APIRouter(prefix="/api", tags=["profile"]) # Editing a profile is cheap, but it is still a write — enough headroom for # someone fiddling with a hue slider, tight enough to be uninteresting to # abuse. profile_limiter = RateLimiter(max_requests=30, window_seconds=60) GENDERS = {"male", "female", "unspecified"} AVATAR_FORMS = {"wisp", "banshee", "fairy", "shade"} BIO_MAX = 280 DISPLAY_NAME_MAX = 48 HUE_MAX = 359 ROSTER_LIMIT = 24 RECENT_ENTITIES = 12 # Same shape as routes/shop.py's — deliberately permissive, since this is a # recovery hint rather than a verified identity. _EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$") async def _encounter_count(db: AsyncSession, user_id) -> int: """Distinct spirits this hunter has contacted.""" return int( await db.scalar( select(func.count(func.distinct(EntitySighting.entity_id))).where( EntitySighting.user_id == user_id ) ) or 0 ) def _rank_block(encounters: int, user: User) -> dict: return rank.progress_for(encounters, user.essence or 0, user.favor or 0.0) def _avatar(user: User) -> dict: return { "avatar_form": user.avatar_form, "avatar_hue": user.avatar_hue, } def _public_fields(user: User) -> dict: """Everything safe to show a stranger. Note the absence of `email` — that omission is the point of this helper existing.""" return { "username": user.username, "display_name": user.display_name, "bio": user.bio, "gender": user.gender or "unspecified", **_avatar(user), } @router.get("/profile/me") async def my_profile( user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) ): encounters = await _encounter_count(db, user.id) return { **_public_fields(user), "profile_public": bool(user.profile_public), # Own profile only. "email": user.email, "rank": _rank_block(encounters, user), } @router.patch("/profile") async def update_profile( payload: dict, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Partial update: only keys actually present are touched, so a client can send one field without having to echo the rest back.""" if not profile_limiter.allow(str(user.id)): raise HTTPException( status.HTTP_429_TOO_MANY_REQUESTS, "too many changes — slow down" ) if not isinstance(payload, dict): raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "expected an object") def _optional_text(key: str, limit: int) -> str | None: """Trim, cap, and treat empty string as an explicit clear.""" raw = payload[key] if raw is None: return None if not isinstance(raw, str): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be text" ) text_value = raw.strip() if len(text_value) > limit: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be {limit} characters or fewer", ) return text_value or None if "display_name" in payload: user.display_name = _optional_text("display_name", DISPLAY_NAME_MAX) if "bio" in payload: user.bio = _optional_text("bio", BIO_MAX) if "gender" in payload: gender = payload["gender"] if gender not in GENDERS: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "no such gender option" ) user.gender = gender if "avatar_form" in payload: form = payload["avatar_form"] if form not in AVATAR_FORMS: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "no such sigil form" ) user.avatar_form = form if "avatar_hue" in payload: hue = payload["avatar_hue"] # bool is an int subclass in Python — reject it explicitly so # `avatar_hue: true` can't silently become hue 1. if not isinstance(hue, int) or isinstance(hue, bool) or not (0 <= hue <= HUE_MAX): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, f"hue must be 0-{HUE_MAX}" ) user.avatar_hue = hue if "profile_public" in payload: visible = payload["profile_public"] if not isinstance(visible, bool): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "profile_public must be true/false" ) user.profile_public = visible if "email" in payload: email = payload["email"] if email is None or (isinstance(email, str) and not email.strip()): user.email = None elif isinstance(email, str) and _EMAIL_RE.match(email.strip().lower()): user.email = email.strip().lower() else: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us" ) await db.commit() await db.refresh(user) encounters = await _encounter_count(db, user.id) return { **_public_fields(user), "profile_public": bool(user.profile_public), "email": user.email, "rank": _rank_block(encounters, user), } @router.get("/hunters") async def hunter_roster(db: AsyncSession = Depends(get_db)): """Public hunters, most-travelled first. Encounter counts come from ONE grouped query rather than a lookup per hunter — the roster is the most-hit public endpoint here and an N+1 would show immediately. """ counts_q = ( select( EntitySighting.user_id, func.count(func.distinct(EntitySighting.entity_id)).label("n"), ) .group_by(EntitySighting.user_id) .subquery() ) rows = ( await db.execute( select(User, func.coalesce(counts_q.c.n, 0).label("encounters")) .outerjoin(counts_q, counts_q.c.user_id == User.id) .where(User.profile_public.is_(True)) .order_by(func.coalesce(counts_q.c.n, 0).desc(), User.created_at) .limit(ROSTER_LIMIT) ) ).all() return { "hunters": [ { "username": user.username, "display_name": user.display_name, **_avatar(user), "rank": _rank_block(int(encounters or 0), user), } for user, encounters in rows ] } @router.get("/hunters/{username}") async def hunter_profile(username: str, db: AsyncSession = Depends(get_db)): user = await db.scalar(select(User).where(User.username == username)) # A hidden profile is indistinguishable from one that never existed — # confirming the account exists would leak exactly what hiding it was # meant to prevent. if user is None or not user.profile_public: raise HTTPException(status.HTTP_404_NOT_FOUND, "no such hunter walks here") encounters = await _encounter_count(db, user.id) # Most recently contacted spirits, distinct, newest first. latest = ( select( EntitySighting.entity_id, func.max(EntitySighting.seen_at).label("last_seen"), ) .where(EntitySighting.user_id == user.id) .group_by(EntitySighting.entity_id) .subquery() ) recent = ( await db.execute( select(Entity, latest.c.last_seen) .join(latest, latest.c.entity_id == Entity.id) .order_by(latest.c.last_seen.desc()) .limit(RECENT_ENTITIES) ) ).all() return { **_public_fields(user), "joined_at": user.created_at.isoformat() if user.created_at else None, "rank": _rank_block(encounters, user), "recent_entities": [ { "id": str(entity.id), "name": entity.name, "epithet": entity.epithet, "rarity": entity.rarity_tier, "visual": entity.visual_profile or {}, } for entity, _seen in recent ], }