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:
+223
-10
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user