"""Public (unauthenticated) read-only user leaderboard. Mounted under the ``/api/public/`` prefix so the auth middleware lets this route through without a session cookie. Powers the public "publish" page for the user leaderboard — a view-only snapshot with no admin controls and no mutations. The snapshot honors the same system-wide leaderboard settings (excluded users, scheduled/admin/failed inclusion) as the authenticated admin endpoint, so any accounts an admin chose to hide stay hidden on the public page as well. """ from __future__ import annotations import hashlib import logging from fastapi import APIRouter, HTTPException, Query from app.gateway.routers.admin_users import ( AdminLeaderboardResponse, _load_or_schedule_daily_leaderboard, _resolve_range, ) from deerflow.config.system_settings import load_system_settings from deerflow.persistence.engine import get_session_factory logger = logging.getLogger(__name__) # Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``). router = APIRouter(prefix="/api/public/leaderboard", tags=["public-leaderboard"]) def _mask_email(email: str | None) -> str: """Reduce an email to its local part so the public page can show a recognizable handle without leaking the full address (domain). The leaderboard only makes sense if it names its top users, so the local part is kept intentionally; the privacy gain is dropping the domain and the raw account id from the unauthenticated payload. """ if not email: return "" return email.split("@", 1)[0].strip() or "" def _anonymize(resp: AdminLeaderboardResponse) -> AdminLeaderboardResponse: """Strip PII from a leaderboard snapshot before it leaves over the public, unauthenticated endpoint: email → local part, account/thread ids → opaque short hashes (kept stable so they still work as React keys).""" def hashed(value: str | None) -> str: if not value: return "" return hashlib.sha1(value.encode("utf-8")).hexdigest()[:12] seen_users: set[int] = set() for bucket in (resp.users_by_activity, resp.users_by_questions, resp.users_by_tokens): for user in bucket: if id(user) in seen_users: continue seen_users.add(id(user)) user.email = _mask_email(user.email) user.user_id = hashed(user.user_id) return resp @router.get("", response_model=AdminLeaderboardResponse) @router.get("/", response_model=AdminLeaderboardResponse) async def get_public_leaderboard( days: int = Query(default=30, ge=1, le=365), since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"), until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"), limit: int = Query(default=20, ge=5, le=100), ) -> AdminLeaderboardResponse: """Return a read-only leaderboard snapshot for the public publish page.""" session_factory = get_session_factory() if session_factory is None: raise HTTPException(status_code=503, detail="Analytics requires database persistence") settings = load_system_settings().leaderboard since_dt, until_dt, _range_days = _resolve_range(days, since, until) resp = await _load_or_schedule_daily_leaderboard( session_factory, settings, since_dt, until_dt, limit=limit, ) return _anonymize(resp)