deerflow-code/offline-backend-20260512/backend/app/gateway/routers/public_leaderboard.py
2026-09-07 18:24:55 +08:00

91 lines
3.4 KiB
Python

"""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)