Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+223 -10
View File
@@ -6,7 +6,8 @@ Usage:
Hero relations, rankings, streamers, mechanics, items, patches, players —
read-only browser UI. Edit data/*.json directly, then refresh the page.
Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/.
Player pages: GET /api/players/{account_id}[/{match_id}] from pc/player_pages/;
POST /api/players/enrich and /api/players/ensure-match (local OpenDota backfill).
"""
from __future__ import annotations
@@ -25,6 +26,7 @@ import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import urllib.parse
import urllib.error
@@ -39,6 +41,7 @@ from shared.paths import (
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
PC_DIR,
PC_PLAYER_PAGES,
RANK_ICONS,
ROLE_ICONS,
@@ -53,6 +56,25 @@ from shared.relations import DEFAULT_RELATIONS, load_relations
from fetch_streamer_live import probe_streamers
from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload
from steam_auth import (
cookie_header as _steam_cookie_header,
create_session_token as _steam_create_token,
env_secrets as _steam_env_secrets,
fetch_steam_persona as _steam_fetch_persona,
read_cookie as _steam_read_cookie,
steam_id64_to_account_id as _steam_id64_to_account,
steam_login_redirect_url as _steam_login_url,
verify_session_token as _steam_verify_token,
verify_steam_openid as _steam_verify_openid,
)
sys.path.insert(0, str(PC_DIR))
from common import load_config as _load_pc_config # noqa: E402
from player_pages import ( # noqa: E402
enrich_profile_recent as _enrich_profile_recent,
get_profile_for_web as _get_profile_for_web,
ensure_match_detail as _ensure_match_detail,
)
WEB_DIR = WEB_FRONTEND
MOBILE_DEMAND_PATH = ROOT / "web" / ".refresh" / "mobile_demand.json"
@@ -853,7 +875,14 @@ class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt: str, *args) -> None:
print(f"[relations] {self.address_string()} {fmt % args}")
def _send(self, code: int, body: bytes, content_type: str) -> None:
def _send(
self,
code: int,
body: bytes,
content_type: str,
*,
extra_headers: dict[str, str] | None = None,
) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
@@ -861,13 +890,92 @@ class Handler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
)
if extra_headers:
for k, v in extra_headers.items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(body)
if body:
self.wfile.write(body)
def _redirect(self, location: str, *, set_cookie: str | None = None) -> None:
headers = {"Location": location}
if set_cookie:
headers["Set-Cookie"] = set_cookie
self._send(302, b"", "text/plain; charset=utf-8", extra_headers=headers)
def _request_origin(self) -> str:
host = self.headers.get("Host") or "127.0.0.1:8765"
# Local serve is always http.
return f"http://{host}"
def _serve_steam_auth_get(self, path: str) -> bool:
"""Handle Steam auth GETs. Returns True if handled."""
api_key, session_secret = _steam_env_secrets()
origin = self._request_origin()
if path == "/api/auth/steam":
if not api_key or not session_secret:
self._send(503, b"Steam login not configured", "text/plain; charset=utf-8")
return True
self._redirect(_steam_login_url(origin))
return True
if path == "/api/auth/steam/callback":
if not api_key or not session_secret:
self._redirect(f"{origin}/?auth=unconfigured")
return True
qs = urllib.parse.parse_qs(urlparse(self.path).query)
ok, steamid = _steam_verify_openid(qs)
if not ok or not steamid:
self._redirect(f"{origin}/?auth=denied")
return True
account_id = _steam_id64_to_account(steamid)
if not account_id:
self._redirect(f"{origin}/?auth=error")
return True
persona = _steam_fetch_persona(api_key, steamid)
token = _steam_create_token(
session_secret,
{
"steamid": steamid,
"account_id": account_id,
"personaname": persona.get("personaname"),
"avatar": persona.get("avatar"),
},
)
self._redirect(
f"{origin}/home",
set_cookie=_steam_cookie_header(token, secure=False),
)
return True
if path == "/api/auth/me":
token = _steam_read_cookie(self.headers.get("Cookie"))
session = _steam_verify_token(session_secret, token) if session_secret else None
if not session:
self._json(200, {"authenticated": False})
return True
self._json(
200,
{
"authenticated": True,
"steamid": session.get("steamid"),
"account_id": session.get("account_id"),
"personaname": session.get("personaname"),
"avatar": session.get("avatar"),
},
)
return True
if path == "/api/auth/logout":
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return True
return False
def _send_file(self, fpath: Path, content_type: str) -> None:
"""Stream a file with optional HTTP Range (needed for HTML5 video seek)."""
@@ -907,7 +1015,8 @@ class Handler(BaseHTTPRequestHandler):
self.send_header(
"Content-Security-Policy",
"default-src 'self'; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; "
"img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com "
"https://avatars.steamstatic.com; "
"style-src 'self' 'unsafe-inline'; "
"script-src 'self'; "
"media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com",
@@ -929,6 +1038,9 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path.startswith("/api/auth/"):
if self._serve_steam_auth_get(path):
return
if path in ("/", "/index.html"):
index = WEB_DIR / "index.html"
if not index.is_file():
@@ -962,6 +1074,7 @@ class Handler(BaseHTTPRequestHandler):
if path.startswith("/rank/"):
key = path[len("/rank/") :]
allowed = {f"rank_icon_{i}.png" for i in range(1, 9)}
allowed |= {f"rank_star_{i}.png" for i in range(1, 6)}
if key not in allowed:
self._json(400, {"error": "bad rank icon"})
return
@@ -1169,6 +1282,7 @@ class Handler(BaseHTTPRequestHandler):
return
# History SPA fallback: /heroes/axe → index.html (client router).
spa_pages = {
"home",
"heroes",
"rankings",
"matches",
@@ -1195,6 +1309,30 @@ class Handler(BaseHTTPRequestHandler):
self._json(400, {"error": "bad players path"})
return
account_id = parts[2]
if account_id == "me" and len(parts) == 3:
api_key, session_secret = _steam_env_secrets()
token = _steam_read_cookie(self.headers.get("Cookie"))
session = (
_steam_verify_token(session_secret, token) if session_secret else None
)
if not session or not session.get("account_id"):
self._json(401, {"authenticated": False})
return
aid = int(session["account_id"])
try:
cfg = _load_pc_config()
# Cache-first (TTL); cold miss syncs once, stale refresh is async.
profile = _get_profile_for_web(cfg, aid, force=False, include_gsi=True)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if not profile.get("personaname") and session.get("personaname"):
profile["personaname"] = session.get("personaname")
if not profile.get("avatar") and session.get("avatar"):
profile["avatar"] = session.get("avatar")
profile["authenticated"] = True
self._json(200, profile)
return
if not account_id.isdigit():
self._json(400, {"error": "bad account_id"})
return
@@ -1231,18 +1369,31 @@ class Handler(BaseHTTPRequestHandler):
return
self._json(400, {"error": "bad players path"})
def _read_json_body(self) -> dict | None:
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
return None
return body if isinstance(body, dict) else {}
def do_POST(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path == "/api/mobile-demand":
self._json(200, {"count": _inc_mobile_demand_count(), "voted": True})
return
if path == "/api/auth/logout":
origin = self._request_origin()
self._redirect(
f"{origin}/",
set_cookie=_steam_cookie_header("", clear=True, secure=False),
)
return
if path == "/api/players/publish":
# Local dev: no OSS write; PC already wrote pc/player_pages/.
length = int(self.headers.get("Content-Length", 0) or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
body = json.loads(raw.decode("utf-8"))
except (ValueError, UnicodeDecodeError):
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
@@ -1258,6 +1409,68 @@ class Handler(BaseHTTPRequestHandler):
},
)
return
if path == "/api/players/enrich":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
account_id = body.get("account_id")
try:
aid = int(account_id)
except (TypeError, ValueError):
aid = 0
if aid <= 0:
self._json(400, {"error": "account_id required"})
return
include_gsi = body.get("include_gsi")
if include_gsi is None:
include_gsi = True
force = bool(body.get("force", True))
try:
cfg = _load_pc_config()
if force:
profile = _enrich_profile_recent(
cfg, aid, include_gsi=bool(include_gsi)
)
else:
profile = _get_profile_for_web(
cfg, aid, force=False, include_gsi=bool(include_gsi)
)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "enrich failed", "detail": str(e)})
return
if profile.get("error"):
self._json(400, profile)
return
self._json(200, {"ok": True, "profile": profile})
return
if path == "/api/players/ensure-match":
body = self._read_json_body()
if body is None:
self._json(400, {"error": "invalid json"})
return
try:
aid = int(body.get("account_id"))
mid = int(body.get("match_id"))
except (TypeError, ValueError):
aid, mid = 0, 0
if aid <= 0 or mid <= 0:
self._json(400, {"error": "account_id and match_id required"})
return
try:
cfg = _load_pc_config()
detail, err = _ensure_match_detail(cfg, aid, mid)
except Exception as e: # noqa: BLE001
self._json(500, {"error": "ensure-match failed", "detail": str(e)})
return
if err or not detail:
self._json(
404 if err else 500,
{"error": err or "unknown", "account_id": aid, "match_id": mid},
)
return
self._json(200, {"ok": True, "match": detail})
return
self.send_error(404)