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
+278
View File
@@ -0,0 +1,278 @@
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
Writes web/cloudflare/.resources.json with ids (no secrets).
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = Path(__file__).resolve().parent / ".resources.json"
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
PAGES_PROJECT = "climperor-relations"
D1_NAME = "climperor-users"
R2_NAME = "climperor-player-data"
QUEUE_NAME = "climperor-player-sync"
DLQ_NAME = "climperor-player-sync-dlq"
WORKER_NAME = "climperor-player-sync"
API = "https://api.cloudflare.com/client/v4"
def creds() -> tuple[str, str]:
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
"KEYZOO_ASSET_META_USERNAME"
)
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
)
if not email or not key:
raise SystemExit("missing CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY")
return email, key
def api(method: str, path: str, body: dict | None = None) -> dict:
email, key = creds()
data = None if body is None else json.dumps(body).encode("utf-8")
req = urllib.request.Request(
API + path,
data=data,
method=method,
headers={
"X-Auth-Email": email,
"X-Auth-Key": key,
"Content-Type": "application/json",
"User-Agent": "climperor-provision",
},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
raw = e.read().decode("utf-8", errors="replace")
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
if not payload.get("success"):
raise SystemExit(f"CF API failed: {payload.get('errors')}")
return payload
def account_id() -> str:
rows = api("GET", "/accounts")["result"]
if not rows:
raise SystemExit("no Cloudflare accounts")
return rows[0]["id"]
def ensure_d1(acct: str) -> str:
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
for row in listed:
if row.get("name") == D1_NAME:
print(f"d1 exists: {row['uuid']}")
return row["uuid"]
created = api(
"POST",
f"/accounts/{acct}/d1/database",
{"name": D1_NAME},
)["result"]
print(f"d1 created: {created['uuid']}")
return created["uuid"]
def run_migration(acct: str, db_id: str) -> None:
sql = MIGRATION.read_text(encoding="utf-8")
api(
"POST",
f"/accounts/{acct}/d1/database/{db_id}/query",
{"sql": sql},
)
print("d1 migration applied")
def ensure_r2(acct: str) -> bool:
"""Return True if bucket exists/created. False if R2 not enabled on account."""
try:
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
print(f"r2 exists: {R2_NAME}")
return True
except SystemExit:
pass
try:
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
print(f"r2 created: {R2_NAME}")
return True
except SystemExit as e:
msg = str(e)
if "already exists" in msg.lower() or "10004" in msg:
print(f"r2 exists: {R2_NAME}")
return True
if "10042" in msg or "enable R2" in msg:
print(
"r2 skipped: enable R2 in Cloudflare Dashboard "
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
)
return False
raise
def ensure_queue(acct: str, name: str) -> str:
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
# API shape may be {result: [...]} or {result: {queues: [...]}}
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
for row in rows:
if row.get("queue_name") == name or row.get("name") == name:
qid = row.get("queue_id") or row.get("id")
print(f"queue exists: {name} ({qid})")
return qid
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
qid = created.get("queue_id") or created.get("id")
print(f"queue created: {name} ({qid})")
return qid
def patch_wrangler(d1_id: str) -> None:
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
text = path.read_text(encoding="utf-8")
text = text.replace("REPLACE_D1_ID", d1_id)
path.write_text(text, encoding="utf-8")
print(f"updated {path}")
def write_resources(
acct: str,
d1_id: str,
queue_id: str | None,
dlq_id: str | None,
*,
r2_ok: bool,
) -> None:
payload = {
"account_id": acct,
"d1": {"name": D1_NAME, "id": d1_id},
"r2": {"name": R2_NAME, "ready": r2_ok},
"queue": {"name": QUEUE_NAME, "id": queue_id},
"dlq": {"name": DLQ_NAME, "id": dlq_id},
"worker": WORKER_NAME,
"pages_project": PAGES_PROJECT,
}
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
print(f"wrote {OUT}")
def ensure_queue_soft(acct: str, name: str) -> str | None:
try:
return ensure_queue(acct, name)
except SystemExit as e:
print(f"queue skipped ({name}): {e}")
return None
def _env_bindings(
base: dict,
*,
d1_id: str,
queue_id: str | None,
r2_ok: bool,
fail_open: bool | None,
) -> dict:
"""Build one environment's deployment_config with required bindings."""
out = {
k: v
for k, v in base.items()
if k
not in (
"d1_databases",
"queue_producers",
"r2_buckets",
"fail_open",
)
}
d1_bindings = dict(base.get("d1_databases") or {})
d1_bindings["DB"] = {"id": d1_id}
out["d1_databases"] = d1_bindings
if queue_id:
producers = dict(base.get("queue_producers") or {})
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
out["queue_producers"] = producers
if r2_ok:
buckets = dict(base.get("r2_buckets") or {})
buckets["MATCHES"] = {"name": R2_NAME}
out["r2_buckets"] = buckets
# Cloudflare requires fail_open equal on production and preview.
if fail_open is not None:
out["fail_open"] = bool(fail_open)
return out
def bind_pages(
acct: str,
d1_id: str,
queue_id: str | None,
*,
r2_ok: bool = False,
) -> None:
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
try:
project = api("GET", path)["result"]
except SystemExit as e:
print(f"pages bind skipped (project missing?): {e}")
return
dc = project.get("deployment_configs") or {}
prod = dict(dc.get("production") or {})
preview = dict(dc.get("preview") or {})
fail_open = prod.get("fail_open")
if fail_open is None:
fail_open = preview.get("fail_open")
if fail_open is None:
fail_open = False
body = {
"deployment_configs": {
"production": _env_bindings(
prod,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
"preview": _env_bindings(
preview,
d1_id=d1_id,
queue_id=queue_id,
r2_ok=r2_ok,
fail_open=fail_open,
),
}
}
try:
api("PATCH", path, body)
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
except SystemExit as e:
print(f"pages bind soft-fail (set manually): {e}")
def main() -> int:
acct = account_id()
print(f"account_id: {acct}")
d1_id = ensure_d1(acct)
run_migration(acct, d1_id)
r2_ok = ensure_r2(acct)
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
patch_wrangler(d1_id)
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
print(
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
)
return 0 if d1_id else 1
if __name__ == "__main__":
raise SystemExit(main())