commit 47088e40c793ee5db071974fe8b359c2d55971a3 Author: James Osborne Date: Thu Jun 11 01:32:41 2026 -0400 Initial psopeeps site import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcb5b17 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# secrets / local config +.env +.env.* +!.env.example +*.secret +*secret* +*.key +*.pem + +# databases / runtime state +postgres-data/ +pgdata/ +database/ +instance/ +*.sqlite +*.sqlite3 +*.db + +# generated / local runtime files +logs/ +backups/ +uploads/ +tmp/ +.cache/ + +# python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# editor/os +.DS_Store +.idea/ +.vscode/ diff --git a/ASSETS.md b/ASSETS.md new file mode 100644 index 0000000..cb82f10 --- /dev/null +++ b/ASSETS.md @@ -0,0 +1,14 @@ +# PSO Peeps Site Assets + +## Included + +- `hero.jpg` — cropped from the supplied Pioneer 2 screenshot for the homepage hero area. + +## Still expected / placeholders + +- `logo.png` — PSO Peeps logo. +- `icons/discord.png` — Discord icon. +- `icons/mastodon.png` — Mastodon icon. +- `icons/bluesky.png` — Bluesky icon. + +The HTML references these paths directly so the final assets can be dropped in without changing markup. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..2a752b6 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,26 @@ +{ + email {$ACME_EMAIL} +} + +{$SITE_DOMAIN} { + encode zstd gzip + + root * /srv/site + file_server + + header { + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "geolocation=(), microphone=(), camera=()" + } + + log { + output stdout + format console + } + + handle_path /api/* { + reverse_proxy app:{$APP_PORT} + } +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..572be76 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py key_routes.py . + +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:${APP_PORT:-8000} --workers 2 --threads 4 app:app"] diff --git a/backend/app.py b/backend/app.py new file mode 100644 index 0000000..cbb6d37 --- /dev/null +++ b/backend/app.py @@ -0,0 +1,1337 @@ +import hashlib +import json +import os +import re +import secrets +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +import smtplib +from email.message import EmailMessage +from urllib.parse import urlencode + +import psycopg +from flask import Flask, jsonify, make_response, request, redirect +from werkzeug.security import check_password_hash, generate_password_hash + + +USERNAME_RE = re.compile(r"^[a-z0-9_-]{3,16}$") + +COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "psopeeps_session") +COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true" +COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE", "Lax") +SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30")) + + +def utcnow(): + return datetime.now(timezone.utc) + + +def db_dsn(): + return ( + f"postgresql://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}" + f"@{os.environ.get('POSTGRES_HOST', 'postgres')}:{os.environ.get('POSTGRES_PORT', '5432')}" + f"/{os.environ['POSTGRES_DB']}" + ) + + +def connect(): + return psycopg.connect(db_dsn(), autocommit=True) + + +def init_db(): + last_err = None + for _ in range(30): + try: + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS site_users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + + cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email TEXT") + cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ") + cur.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS site_users_email_lower_unique_idx + ON site_users (lower(email)) + WHERE email IS NOT NULL + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx + ON email_verification_tokens(user_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS email_verification_tokens_expires_at_idx + ON email_verification_tokens(expires_at) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS site_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS site_sessions_user_id_idx + ON site_sessions(user_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS site_sessions_expires_at_idx + ON site_sessions(expires_at) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS bb_accounts ( + user_id BIGINT PRIMARY KEY REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + return + except Exception as e: + last_err = e + time.sleep(1) + raise last_err + + +def clean_username(value): + value = (value or "").strip().lower() + if not USERNAME_RE.match(value): + return None + return value + + + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def clean_email(value): + value = (value or "").strip().lower() + if not value or len(value) > 254 or not EMAIL_RE.match(value): + return None + return value + + +def public_base_url(): + return os.environ.get("PUBLIC_BASE_URL", "https://psopeeps.online").rstrip("/") + + +def user_email_payload(user): + email = user.get("email") + verified_at = user.get("email_verified_at") + return { + "email": email, + "verified": bool(verified_at), + "verified_at": verified_at.isoformat() if hasattr(verified_at, "isoformat") else verified_at, + "required": True, + } + + +def create_email_verification_token(user_id, email): + token = secrets.token_urlsafe(48) + expires_at = utcnow() + timedelta(hours=24) + + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + UPDATE email_verification_tokens + SET used_at = now() + WHERE user_id = %s AND used_at IS NULL + """, (user_id,)) + cur.execute(""" + INSERT INTO email_verification_tokens (user_id, email, token_hash, expires_at) + VALUES (%s, %s, %s, %s) + """, (user_id, email, token_hash(token), expires_at)) + + return token, expires_at + + +def verification_link(token): + return f"{public_base_url()}/api/email/verify?{urlencode({'token': token})}" + + +def send_verification_email(to_email, username, link): + smtp_host = os.environ.get("SMTP_HOST") + smtp_port = int(os.environ.get("SMTP_PORT", "587")) + smtp_user = os.environ.get("SMTP_USERNAME") + smtp_password = os.environ.get("SMTP_PASSWORD") + smtp_from = os.environ.get("SMTP_FROM", smtp_user or "no-reply@psopeeps.online") + smtp_tls = os.environ.get("SMTP_TLS", "starttls").lower() + + if not smtp_host: + if os.environ.get("EMAIL_DEBUG_SHOW_LINK", "").lower() in {"1", "true", "yes"}: + return {"sent": False, "debug_link": link} + raise RuntimeError("SMTP is not configured") + + msg = EmailMessage() + msg["Subject"] = "Verify your PSO Peeps email" + msg["From"] = smtp_from + msg["To"] = to_email + msg.set_content( + f"Hi {username},\n\n" + f"Verify your PSO Peeps email here:\n{link}\n\n" + f"This link expires in 24 hours.\n" + ) + + if smtp_tls == "ssl": + with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=20) as smtp: + if smtp_user: + smtp.login(smtp_user, smtp_password or "") + smtp.send_message(msg) + else: + with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as smtp: + if smtp_tls == "starttls": + smtp.starttls() + if smtp_user: + smtp.login(smtp_user, smtp_password or "") + smtp.send_message(msg) + + return {"sent": True} + + +def require_verified_email(user): + if user and user.get("email_verified_at"): + return None + return jsonify({ + "ok": False, + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + +def json_body(): + data = request.get_json(silent=True) + return data if isinstance(data, dict) else {} + + +def token_hash(token): + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def current_user(): + token = request.cookies.get(COOKIE_NAME) + if not token: + return None + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT u.id, u.username, u.email, u.email_verified_at + FROM site_sessions s + JOIN site_users u ON u.id = s.user_id + WHERE s.token_hash = %s + AND s.expires_at > now() + LIMIT 1 + """, (token_hash(token),)) + return cur.fetchone() + + +def issue_session(user_id): + token = secrets.token_urlsafe(48) + expires_at = utcnow() + timedelta(days=SESSION_DAYS) + + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO site_sessions (user_id, token_hash, expires_at) + VALUES (%s, %s, %s) + """, (user_id, token_hash(token), expires_at)) + + return token, expires_at + + +def set_session_cookie(resp, token, expires_at): + resp.set_cookie( + COOKIE_NAME, + token, + expires=expires_at, + httponly=True, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE, + path="/", + ) + + +def clear_session_cookie(resp): + resp.delete_cookie(COOKIE_NAME, path="/") + + +def fnv1a32(text): + h = 0x811C9DC5 + for b in text.encode("utf-8"): + h ^= b + h = (h * 0x01000193) & 0xFFFFFFFF + return h + + +def bb_account_id(username): + return fnv1a32(username) & 0x7FFFFFFF + + +def account_id_str(account_id): + return f"{int(account_id):010d}" + + +def validate_bb_password(password): + if not password: + return "Blue Burst password is required." + if len(password) > 16: + return "Blue Burst password must be 16 characters or fewer." + return None + + +def license_text(account_id, username, password): + u = json.dumps(username) + p = json.dumps(password) + + return f'''{{ + "BBTeamID": 0x0, + "FormatVersion": 0x1, + "AccountID": 0x{account_id:X}, + "LastPlayerName": {u}, + "DCNTELicenses": [], + "BBLicenses": [ + {{"UserName": {u}, "Password": {p}}} + ], + "BanEndTime": 0x0, + "PCLicenses": [], + "AutoReplyMessage": "", + "GCLicenses": [], + "AutoPatchesEnabled": ["MomokaItemExchangeFix", "DisableIdleDisconnect", "FastTekker", "Palette", "DrawDistance", "EnemyHPBars", "HungryMagSound"], + "XBLicenses": [], + "Flags": 0x7FFFFFFF, + "Ep3TotalMesetaEarned": 0x0, + "Ep3CurrentMeseta": 0x0, + "DCLicenses": [], + "UserFlags": 0x0 +}}''' + + +def account_root(account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + return root / "canonical" / "accounts" / account_id_str(account_id) + + +def canonical_license_path(account_id): + aid = account_id_str(account_id) + return account_root(account_id) / "system" / "licenses" / f"{aid}.json" + + +def sha256_file(path): + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def refresh_account_manifest(account_id): + root = account_root(account_id) + manifest_path = root / "manifest.json" + if not root.is_dir(): + return None + + existing = {} + if manifest_path.exists(): + try: + existing = json.loads(manifest_path.read_text()) + except Exception: + existing = {} + + old_files = existing.get("files", {}) if isinstance(existing.get("files"), dict) else {} + new_files = {} + now = int(time.time()) + + for path in sorted(x for x in root.rglob("*") if x.is_file()): + rel = path.relative_to(root).as_posix() + if rel == "manifest.json": + continue + + st = path.stat() + digest = sha256_file(path) + old = old_files.get(rel, {}) + + if ( + old.get("sha256") == digest + and int(old.get("size", -1)) == st.st_size + and int(old.get("mtime_ns", -1)) == st.st_mtime_ns + ): + new_files[rel] = old + else: + new_files[rel] = { + "inbox_path": f"site-generated:{rel}", + "mtime_ns": st.st_mtime_ns, + "promoted_at": now, + "relative_path": rel, + "sha256": digest, + "size": st.st_size, + "source": "site", + } + + manifest = dict(existing) + manifest["files"] = new_files + manifest["updated_at"] = now + + tmp = manifest_path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + tmp.replace(manifest_path) + + return sha256_file(manifest_path) + + +def canonical_manifest_sha256(account_id): + manifest = account_root(account_id) / "manifest.json" + if not manifest.exists(): + return None + return sha256_file(manifest) + + +def read_json_file(path): + try: + return json.loads(path.read_text()) + except Exception: + return None + + +def bb_sync_info(account_id): + aid = account_id_str(account_id) + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + manifest_sha = canonical_manifest_sha256(account_id) + + regions = { + "us": { + "host": "psopeeps_us", + "status": "pending", + "applied_at": None, + }, + "eu": { + "host": "psopeeps_eu", + "status": "pending", + "applied_at": None, + }, + } + + if not manifest_sha: + return { + "status": "pending", + "manifest_sha256": None, + "regions": regions, + } + + for region, info in regions.items(): + state_path = root / "state" / "applied" / f"{info['host']}.site.{aid}.json" + state = read_json_file(state_path) + + if state and state.get("manifest_sha256") == manifest_sha: + info["status"] = "current" + info["applied_at"] = state.get("applied_at") + elif state: + info["status"] = "pending" + info["applied_at"] = state.get("applied_at") + else: + info["status"] = "pending" + + overall = "current" if all(x["status"] == "current" for x in regions.values()) else "pending" + + return { + "status": overall, + "manifest_sha256": manifest_sha, + "regions": regions, + } + + +def bb_payload(account_id, username): + sync = bb_sync_info(account_id) + return { + "created": True, + "ready": sync["status"] == "current", + "sync_status": sync["status"], + "username": username, + "account_id": account_id_str(account_id), + "regions": sync["regions"], + } + + + +def enqueue_account_sync(account_id, reason): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + queue = root / "queue" / "apply" + queue.mkdir(parents=True, exist_ok=True) + + aid = account_id_str(account_id) + payload = { + "account": aid, + "reason": reason, + "queued_at": int(time.time()), + } + + final = queue / f"{int(time.time())}.{time.time_ns()}.{aid}.json" + tmp = queue / f".{final.name}.tmp" + + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + tmp.replace(final) + + return final + +def write_bb_license(account_id, username, password): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(license_text(account_id, username, password) + "\n") + tmp.replace(path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "bb_license_updated") + + return path + + + +app = Flask(__name__) +init_db() + + +@app.after_request +def add_headers(resp): + resp.headers["Cache-Control"] = "no-store" + return resp + + +@app.get("/health") +def health(): + return jsonify({"ok": True}) + + +@app.get("/me") +def me(): + user = current_user() + if not user: + return jsonify({"authenticated": False}) + return jsonify({ + "authenticated": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + }) + + + +def local_syncer_save_summary(account_id): + import json + import os + from pathlib import Path + from datetime import datetime, timezone + + account = account_id_str(account_id) + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + applied_dir = root / "state" / "applied" + + paths = { + "us": applied_dir / f"psopeeps_us.site.{account}.json", + "eu": applied_dir / f"psopeeps_eu.site.{account}.json", + } + + def parse_time(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + + regions = {} + + for region, path in paths.items(): + info = { + "status": "unknown", + "label": "Not seen", + "style": "warn", + "host": None, + "applied_at": None, + "manifest_sha256": None, + } + + if path.exists(): + try: + data = json.loads(path.read_text()) + info.update({ + "status": "seen", + "label": "Seen", + "style": "warn", + "host": data.get("host"), + "applied_at": data.get("applied_at"), + "manifest_sha256": data.get("manifest_sha256"), + }) + except Exception as e: + info["error"] = str(e) + + regions[region] = info + + us_hash = regions["us"].get("manifest_sha256") + eu_hash = regions["eu"].get("manifest_sha256") + + if us_hash and eu_hash and us_hash == eu_hash: + for region in ("us", "eu"): + regions[region]["status"] = "current" + regions[region]["label"] = "Current" + regions[region]["style"] = "good" + + times = [ + t for t in ( + parse_time(regions["us"].get("applied_at")), + parse_time(regions["eu"].get("applied_at")), + ) + if t + ] + latest = max(times) if times else None + + return { + "status": "current", + "safe": True, + "regions": regions, + "last_sync": latest.isoformat() if latest else None, + "message": "", + } + + if not us_hash and not eu_hash: + return { + "status": "unknown", + "safe": False, + "regions": regions, + "last_sync": None, + "message": "No mirrored save data has been seen for this account yet.", + } + + us_time = parse_time(regions["us"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc) + eu_time = parse_time(regions["eu"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc) + + newest = "us" if us_time >= eu_time else "eu" + older = "eu" if newest == "us" else "us" + + regions[newest]["status"] = "newest" + regions[newest]["label"] = "Newest" + regions[newest]["style"] = "good" + + regions[older]["status"] = "pending" + regions[older]["label"] = "Pending" + regions[older]["style"] = "warn" + + latest = max(us_time, eu_time) + + return { + "status": "pending", + "safe": False, + "regions": regions, + "last_sync": latest.isoformat() if latest else None, + "message": "Save sync pending. If this does not clear soon, contact an admin in Discord.", + } + + +@app.get("/account") +def account(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT account_id, username + FROM bb_accounts + WHERE user_id = %s + LIMIT 1 + """, (user["id"],)) + bb = cur.fetchone() + + return jsonify({ + "authenticated": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + "bb": bb_payload(bb["account_id"], bb["username"]) if bb else { + "created": False, + "ready": False, + "sync_status": "missing", + "username": user["username"], + "account_id": None, + "regions": { + "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None}, + "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None}, + }, + }, + "v2_v3_keys": [], + "save_sync": local_syncer_save_summary(bb["account_id"]) if bb else { + "status": "missing", + "message": "Create a Blue Burst account before save sync status is available.", + "last_sync": None, + "regions": { + "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None}, + "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None}, + }, + }, + }) + + + +@app.post("/email/start") +def email_start(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + data = json_body() + email = clean_email(data.get("email")) + if not email: + return jsonify({"ok": False, "error": "Enter a valid email address."}), 400 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + UPDATE site_users + SET email = %s, + email_verified_at = NULL, + updated_at = now() + WHERE id = %s + RETURNING id, username, email, email_verified_at + """, (email, user["id"])) + updated = cur.fetchone() + except psycopg.errors.UniqueViolation: + return jsonify({"ok": False, "error": "That email is already in use."}), 409 + + token, expires_at = create_email_verification_token(updated["id"], updated["email"]) + result = send_verification_email( + updated["email"], + updated["username"], + verification_link(token), + ) + + return jsonify({ + "ok": True, + "email": user_email_payload(updated), + "verification": result, + }) + + +@app.post("/email/resend") +def email_resend(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email = clean_email(user.get("email")) + if not email: + return jsonify({"ok": False, "error": "No email address is set."}), 400 + + if user.get("email_verified_at"): + return jsonify({"ok": True, "email": user_email_payload(user), "already_verified": True}) + + token, expires_at = create_email_verification_token(user["id"], email) + result = send_verification_email( + email, + user["username"], + verification_link(token), + ) + + return jsonify({ + "ok": True, + "email": user_email_payload(user), + "verification": result, + }) + + +@app.get("/email/verify") +def email_verify(): + token = request.args.get("token") or "" + if not token: + return redirect("/account-unverified.html?error=missing-token") + + th = token_hash(token) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, user_id, email + FROM email_verification_tokens + WHERE token_hash = %s + AND used_at IS NULL + AND expires_at > now() + LIMIT 1 + """, (th,)) + row = cur.fetchone() + + if not row: + return redirect("/account-unverified.html?error=invalid-token") + + cur.execute(""" + UPDATE site_users + SET email = %s, + email_verified_at = now(), + updated_at = now() + WHERE id = %s + """, (row["email"], row["user_id"])) + + cur.execute(""" + UPDATE email_verification_tokens + SET used_at = now() + WHERE id = %s + """, (row["id"],)) + + return redirect("/account.html?verified=1") + + +@app.post("/register") +def register(): + data = json_body() + username = clean_username(data.get("username")) + email = clean_email(data.get("email")) + password = data.get("password") or "" + + if not username: + return jsonify({ + "ok": False, + "error": "Username must be 3-16 characters: lowercase letters, numbers, underscore, or hyphen.", + }), 400 + + if not email: + return jsonify({"ok": False, "error": "Enter a valid email address."}), 400 + + if len(password) < 8: + return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO site_users (username, email, password_hash) + VALUES (%s, %s, %s) + RETURNING id, username, email, email_verified_at + """, (username, email, generate_password_hash(password))) + user = cur.fetchone() + except psycopg.errors.UniqueViolation: + return jsonify({"ok": False, "error": "That username or email is already taken."}), 409 + + verify_token, verify_expires_at = create_email_verification_token(user["id"], user["email"]) + verify_result = send_verification_email( + user["email"], + user["username"], + verification_link(verify_token), + ) + + token, expires_at = issue_session(user["id"]) + resp = make_response(jsonify({ + "ok": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + "verification": verify_result, + })) + set_session_cookie(resp, token, expires_at) + return resp + + +@app.post("/login") +def login(): + data = json_body() + username = clean_username(data.get("username")) + password = data.get("password") or "" + + if not username or not password: + return jsonify({"ok": False, "error": "Username and password are required."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, username, password_hash, email, email_verified_at + FROM site_users + WHERE username = %s + LIMIT 1 + """, (username,)) + user = cur.fetchone() + + if not user or not check_password_hash(user["password_hash"], password): + return jsonify({"ok": False, "error": "Invalid username or password."}), 401 + + token, expires_at = issue_session(user["id"]) + resp = make_response(jsonify({ + "ok": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + })) + set_session_cookie(resp, token, expires_at) + return resp + + + +@app.post("/password/change") +def password_change(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + data = json_body() + current_password = data.get("current_password") or data.get("currentPassword") or "" + new_password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + if len(new_password) < 8: + return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400 + + if new_password != confirm: + return jsonify({"ok": False, "error": "Passwords do not match."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, password_hash + FROM site_users + WHERE id = %s + LIMIT 1 + """, (user["id"],)) + row = cur.fetchone() + + if not row or not check_password_hash(row["password_hash"], current_password): + return jsonify({"ok": False, "error": "Current password is incorrect."}), 401 + + cur.execute(""" + UPDATE site_users + SET password_hash = %s, + updated_at = now() + WHERE id = %s + """, (generate_password_hash(new_password), user["id"])) + + return jsonify({"ok": True}) + +@app.post("/logout") +def logout(): + token = request.cookies.get(COOKIE_NAME) + if token: + with connect() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM site_sessions WHERE token_hash = %s", (token_hash(token),)) + + resp = make_response(jsonify({"ok": True})) + clear_session_cookie(resp) + return resp + + +@app.post("/bb/create") +def bb_create(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email_error = require_verified_email(user) + if email_error: + return email_error + + data = json_body() + password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + err = validate_bb_password(password) + if err: + return jsonify({"ok": False, "error": err}), 400 + if password != confirm: + return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400 + + username = user["username"] + account_id = bb_account_id(username) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT user_id FROM bb_accounts WHERE user_id = %s", (user["id"],)) + if cur.fetchone(): + return jsonify({"ok": False, "error": "Blue Burst account already exists."}), 409 + + write_bb_license(account_id, username, password) + + cur.execute(""" + INSERT INTO bb_accounts (user_id, account_id, username) + VALUES (%s, %s, %s) + RETURNING account_id, username + """, (user["id"], account_id, username)) + bb = cur.fetchone() + + return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])}) + + +@app.post("/bb/change-password") +def bb_change_password(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email_error = require_verified_email(user) + if email_error: + return email_error + + data = json_body() + password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + err = validate_bb_password(password) + if err: + return jsonify({"ok": False, "error": err}), 400 + if password != confirm: + return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT account_id, username + FROM bb_accounts + WHERE user_id = %s + LIMIT 1 + """, (user["id"],)) + bb = cur.fetchone() + + if not bb: + return jsonify({"ok": False, "error": "Blue Burst account does not exist yet."}), 404 + + write_bb_license(bb["account_id"], bb["username"], password) + + cur.execute(""" + UPDATE bb_accounts + SET updated_at = now() + WHERE user_id = %s + """, (user["id"],)) + + return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])}) + + +# Register V2 key profile API routes. +from key_routes import register_key_routes + +register_key_routes( + app, + connect=connect, + current_user=current_user, + jsonify=jsonify, + request=request, + account_id_str=account_id_str, + bb_account_id=bb_account_id, + canonical_license_path=canonical_license_path, + refresh_account_manifest=refresh_account_manifest, + enqueue_account_sync=enqueue_account_sync, + bb_sync_info=bb_sync_info, +) + +# --- Hardcore stats aggregator ------------------------------------------------- +# Combines EU + US local Hardcore stats APIs for the website. +# Source rows are kept conceptually separate, then merged by stable character key. + +import urllib.request as _hc_urllib_request +import urllib.error as _hc_urllib_error +from datetime import datetime as _hc_datetime, timezone as _hc_timezone + +def _hc_source_urls(): + sources = [] + eu = (os.environ.get("HARDCORE_STATS_EU_URL") or "").strip().rstrip("/") + us = (os.environ.get("HARDCORE_STATS_US_URL") or "").strip().rstrip("/") + if eu: + sources.append(("eu", eu)) + if us: + sources.append(("us", us)) + return sources + +def _hc_fetch_json(url, timeout=30): + req = _hc_urllib_request.Request( + url, + headers={"User-Agent": "psopeeps-site-hardcore-aggregator/1.0"}, + ) + with _hc_urllib_request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return json.loads(raw) + +def _hc_get_source_characters(): + rows = [] + errors = [] + + for source_name, base_url in _hc_source_urls(): + url = f"{base_url}/api/hardcore/characters" + try: + data = _hc_fetch_json(url) + if isinstance(data, list): + for row in data: + if isinstance(row, dict): + r = dict(row) + r["_source_ship"] = source_name + r["_source_url"] = base_url + rows.append(r) + else: + errors.append({ + "source_ship": source_name, + "url": url, + "error": "response was not a list", + }) + except Exception as e: + errors.append({ + "source_ship": source_name, + "url": url, + "error": str(e), + }) + + return rows, errors + +def _hc_int(value, default=0): + try: + if value is None: + return default + return int(value) + except Exception: + return default + +def _hc_bool(value, default=True): + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "no", "dead"} + return bool(value) + +def _hc_sort_ts(value): + if not value: + return "" + return str(value) + +def _hc_character_key(row): + guild_card = _hc_int(row.get("guild_card")) + slot = _hc_int(row.get("character_slot")) + creation_ts = _hc_int(row.get("character_creation_timestamp")) + + # Real creation timestamps are stable across synced saves and should be used. + if creation_ts > 0: + return (guild_card, slot, creation_ts) + + # Legacy timestamp-0 rows are kept separate by name so an old dead/test row + # does not poison the current real character row. + name = str(row.get("character_name") or "") + return (guild_card, slot, f"legacy:{name}") + +def _hc_pick_display_row(rows): + return sorted( + rows, + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("play_time_seconds")), + _hc_sort_ts(r.get("updated_at")), + ), + reverse=True, + )[0] + +def _hc_merge_character_rows(rows): + by_key = {} + for row in rows: + by_key.setdefault(_hc_character_key(row), []).append(row) + + combined = [] + + for key, group in by_key.items(): + display = _hc_pick_display_row(group) + + source_ships = sorted({ + str(r.get("_source_ship") or "") + for r in group + if r.get("_source_ship") + }) + + # Kills aggregate across source rows in the same character identity group. + total_kills = sum(_hc_int(r.get("total_enemies_killed")) for r in group) + + # Higher numeric values win for progression fields. + level = max(_hc_int(r.get("level"), 1) for r in group) + total_exp = max(_hc_int(r.get("total_exp")) for r in group) + play_time_seconds = max(_hc_int(r.get("play_time_seconds")) for r in group) + + # Death anywhere in the same identity group makes the combined row dead. + alive = all(_hc_bool(r.get("alive"), True) for r in group) + + updated_at = max((str(r.get("updated_at") or "") for r in group), default="") + last_seen_at = max((str(r.get("last_seen_at") or "") for r in group), default="") + dead_at_values = [str(r.get("dead_at")) for r in group if r.get("dead_at")] + dead_at = min(dead_at_values) if dead_at_values else None + + merged = dict(display) + merged.pop("_source_url", None) + + merged["source_ships"] = source_ships + merged["source_count"] = len(group) + merged["guild_card"] = _hc_int(display.get("guild_card")) + merged["character_slot"] = _hc_int(display.get("character_slot")) + merged["character_creation_timestamp"] = _hc_int(display.get("character_creation_timestamp")) + merged["level"] = level + merged["total_exp"] = total_exp + merged["play_time_seconds"] = play_time_seconds + merged["total_enemies_killed"] = total_kills + merged["alive"] = alive + merged["dead_at"] = dead_at + merged["last_seen_at"] = last_seen_at or None + merged["updated_at"] = updated_at or None + + combined.append(merged) + + return combined + +def _hc_combined_payload(): + source_rows, errors = _hc_get_source_characters() + combined = _hc_merge_character_rows(source_rows) + return combined, errors + +@app.get("/hardcore/sources") +def hardcore_sources(): + statuses = [] + + for source_name, base_url in _hc_source_urls(): + status = { + "source_ship": source_name, + "base_url": base_url, + "ok": False, + "health": None, + "error": None, + } + try: + status["health"] = _hc_fetch_json(f"{base_url}/health") + status["ok"] = bool(status["health"].get("ok")) if isinstance(status["health"], dict) else True + except Exception as e: + status["error"] = str(e) + statuses.append(status) + + return jsonify({ + "ok": all(s["ok"] for s in statuses) if statuses else False, + "sources": statuses, + }) + +@app.get("/hardcore/characters") +def hardcore_characters_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify({ + "ok": not errors, + "errors": errors, + "count": len(combined), + "characters": combined, + }) + +@app.get("/hardcore/leaderboard/kills") +def hardcore_leaderboard_kills_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_enemies_killed")), + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +@app.get("/hardcore/leaderboard/level") +def hardcore_leaderboard_level_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("level")), + _hc_int(r.get("total_exp")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +@app.get("/hardcore/leaderboard/exp") +def hardcore_leaderboard_exp_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +def _hc_points_row(row): + level = _hc_int(row.get("level")) + total_exp = _hc_int(row.get("total_exp")) + kills = _hc_int(row.get("total_enemies_killed")) + play_time = _hc_int(row.get("play_time_seconds")) + hours_played = play_time / 3600 if play_time > 0 else 0 + + level_points = (level * 1000) + (level * level * 10) + exp_points = int(total_exp / 1000) + kill_points = int(100 * (kills ** 0.5)) + survival_time_points = min(10000, int(250 * (hours_played ** 0.5))) + challenge_mode_points = 0 + total_points_raw = level_points + exp_points + kill_points + survival_time_points + challenge_mode_points + total_points = int(total_points_raw / 100) + + player_name = row.get("character_name") or row.get("name") or "" + + return { + "PlayerName": player_name, + "CharacterName": player_name, + "Points": total_points, + "TotalPoints": total_points, + "Class": row.get("character_class") or "", + "SecID": row.get("section_id") or "", + "LevelPoints": level_points, + "EXPPoints": exp_points, + "KillPoints": kill_points, + "SurvivalTimePoints": survival_time_points, + "ChallengeModePoints": challenge_mode_points, + "Level": level, + "TotalEXP": total_exp, + "Kills": kills, + "TotalKills": kills, + "PlayTimeSeconds": play_time, + "Alive": _hc_bool(row.get("alive"), True), + } + +@app.get("/hardcore/leaderboard/points") +def hardcore_leaderboard_points_combined(): + combined, errors = _hc_combined_payload() + rows = [_hc_points_row(r) for r in combined] + rows.sort(key=lambda r: r["TotalPoints"], reverse=True) + return jsonify(rows[:100]) + +# --- end Hardcore stats aggregator ------------------------------------------- diff --git a/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z b/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z new file mode 100644 index 0000000..cbb6d37 --- /dev/null +++ b/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z @@ -0,0 +1,1337 @@ +import hashlib +import json +import os +import re +import secrets +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +import smtplib +from email.message import EmailMessage +from urllib.parse import urlencode + +import psycopg +from flask import Flask, jsonify, make_response, request, redirect +from werkzeug.security import check_password_hash, generate_password_hash + + +USERNAME_RE = re.compile(r"^[a-z0-9_-]{3,16}$") + +COOKIE_NAME = os.environ.get("SESSION_COOKIE_NAME", "psopeeps_session") +COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() == "true" +COOKIE_SAMESITE = os.environ.get("SESSION_COOKIE_SAMESITE", "Lax") +SESSION_DAYS = int(os.environ.get("SESSION_DAYS", "30")) + + +def utcnow(): + return datetime.now(timezone.utc) + + +def db_dsn(): + return ( + f"postgresql://{os.environ['POSTGRES_USER']}:{os.environ['POSTGRES_PASSWORD']}" + f"@{os.environ.get('POSTGRES_HOST', 'postgres')}:{os.environ.get('POSTGRES_PORT', '5432')}" + f"/{os.environ['POSTGRES_DB']}" + ) + + +def connect(): + return psycopg.connect(db_dsn(), autocommit=True) + + +def init_db(): + last_err = None + for _ in range(30): + try: + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS site_users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + + cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email TEXT") + cur.execute("ALTER TABLE site_users ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ") + cur.execute(""" + CREATE UNIQUE INDEX IF NOT EXISTS site_users_email_lower_unique_idx + ON site_users (lower(email)) + WHERE email IS NOT NULL + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx + ON email_verification_tokens(user_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS email_verification_tokens_expires_at_idx + ON email_verification_tokens(expires_at) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS site_sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + ) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS site_sessions_user_id_idx + ON site_sessions(user_id) + """) + cur.execute(""" + CREATE INDEX IF NOT EXISTS site_sessions_expires_at_idx + ON site_sessions(expires_at) + """) + cur.execute(""" + CREATE TABLE IF NOT EXISTS bb_accounts ( + user_id BIGINT PRIMARY KEY REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """) + return + except Exception as e: + last_err = e + time.sleep(1) + raise last_err + + +def clean_username(value): + value = (value or "").strip().lower() + if not USERNAME_RE.match(value): + return None + return value + + + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def clean_email(value): + value = (value or "").strip().lower() + if not value or len(value) > 254 or not EMAIL_RE.match(value): + return None + return value + + +def public_base_url(): + return os.environ.get("PUBLIC_BASE_URL", "https://psopeeps.online").rstrip("/") + + +def user_email_payload(user): + email = user.get("email") + verified_at = user.get("email_verified_at") + return { + "email": email, + "verified": bool(verified_at), + "verified_at": verified_at.isoformat() if hasattr(verified_at, "isoformat") else verified_at, + "required": True, + } + + +def create_email_verification_token(user_id, email): + token = secrets.token_urlsafe(48) + expires_at = utcnow() + timedelta(hours=24) + + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + UPDATE email_verification_tokens + SET used_at = now() + WHERE user_id = %s AND used_at IS NULL + """, (user_id,)) + cur.execute(""" + INSERT INTO email_verification_tokens (user_id, email, token_hash, expires_at) + VALUES (%s, %s, %s, %s) + """, (user_id, email, token_hash(token), expires_at)) + + return token, expires_at + + +def verification_link(token): + return f"{public_base_url()}/api/email/verify?{urlencode({'token': token})}" + + +def send_verification_email(to_email, username, link): + smtp_host = os.environ.get("SMTP_HOST") + smtp_port = int(os.environ.get("SMTP_PORT", "587")) + smtp_user = os.environ.get("SMTP_USERNAME") + smtp_password = os.environ.get("SMTP_PASSWORD") + smtp_from = os.environ.get("SMTP_FROM", smtp_user or "no-reply@psopeeps.online") + smtp_tls = os.environ.get("SMTP_TLS", "starttls").lower() + + if not smtp_host: + if os.environ.get("EMAIL_DEBUG_SHOW_LINK", "").lower() in {"1", "true", "yes"}: + return {"sent": False, "debug_link": link} + raise RuntimeError("SMTP is not configured") + + msg = EmailMessage() + msg["Subject"] = "Verify your PSO Peeps email" + msg["From"] = smtp_from + msg["To"] = to_email + msg.set_content( + f"Hi {username},\n\n" + f"Verify your PSO Peeps email here:\n{link}\n\n" + f"This link expires in 24 hours.\n" + ) + + if smtp_tls == "ssl": + with smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=20) as smtp: + if smtp_user: + smtp.login(smtp_user, smtp_password or "") + smtp.send_message(msg) + else: + with smtplib.SMTP(smtp_host, smtp_port, timeout=20) as smtp: + if smtp_tls == "starttls": + smtp.starttls() + if smtp_user: + smtp.login(smtp_user, smtp_password or "") + smtp.send_message(msg) + + return {"sent": True} + + +def require_verified_email(user): + if user and user.get("email_verified_at"): + return None + return jsonify({ + "ok": False, + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + +def json_body(): + data = request.get_json(silent=True) + return data if isinstance(data, dict) else {} + + +def token_hash(token): + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def current_user(): + token = request.cookies.get(COOKIE_NAME) + if not token: + return None + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT u.id, u.username, u.email, u.email_verified_at + FROM site_sessions s + JOIN site_users u ON u.id = s.user_id + WHERE s.token_hash = %s + AND s.expires_at > now() + LIMIT 1 + """, (token_hash(token),)) + return cur.fetchone() + + +def issue_session(user_id): + token = secrets.token_urlsafe(48) + expires_at = utcnow() + timedelta(days=SESSION_DAYS) + + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + INSERT INTO site_sessions (user_id, token_hash, expires_at) + VALUES (%s, %s, %s) + """, (user_id, token_hash(token), expires_at)) + + return token, expires_at + + +def set_session_cookie(resp, token, expires_at): + resp.set_cookie( + COOKIE_NAME, + token, + expires=expires_at, + httponly=True, + secure=COOKIE_SECURE, + samesite=COOKIE_SAMESITE, + path="/", + ) + + +def clear_session_cookie(resp): + resp.delete_cookie(COOKIE_NAME, path="/") + + +def fnv1a32(text): + h = 0x811C9DC5 + for b in text.encode("utf-8"): + h ^= b + h = (h * 0x01000193) & 0xFFFFFFFF + return h + + +def bb_account_id(username): + return fnv1a32(username) & 0x7FFFFFFF + + +def account_id_str(account_id): + return f"{int(account_id):010d}" + + +def validate_bb_password(password): + if not password: + return "Blue Burst password is required." + if len(password) > 16: + return "Blue Burst password must be 16 characters or fewer." + return None + + +def license_text(account_id, username, password): + u = json.dumps(username) + p = json.dumps(password) + + return f'''{{ + "BBTeamID": 0x0, + "FormatVersion": 0x1, + "AccountID": 0x{account_id:X}, + "LastPlayerName": {u}, + "DCNTELicenses": [], + "BBLicenses": [ + {{"UserName": {u}, "Password": {p}}} + ], + "BanEndTime": 0x0, + "PCLicenses": [], + "AutoReplyMessage": "", + "GCLicenses": [], + "AutoPatchesEnabled": ["MomokaItemExchangeFix", "DisableIdleDisconnect", "FastTekker", "Palette", "DrawDistance", "EnemyHPBars", "HungryMagSound"], + "XBLicenses": [], + "Flags": 0x7FFFFFFF, + "Ep3TotalMesetaEarned": 0x0, + "Ep3CurrentMeseta": 0x0, + "DCLicenses": [], + "UserFlags": 0x0 +}}''' + + +def account_root(account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + return root / "canonical" / "accounts" / account_id_str(account_id) + + +def canonical_license_path(account_id): + aid = account_id_str(account_id) + return account_root(account_id) / "system" / "licenses" / f"{aid}.json" + + +def sha256_file(path): + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def refresh_account_manifest(account_id): + root = account_root(account_id) + manifest_path = root / "manifest.json" + if not root.is_dir(): + return None + + existing = {} + if manifest_path.exists(): + try: + existing = json.loads(manifest_path.read_text()) + except Exception: + existing = {} + + old_files = existing.get("files", {}) if isinstance(existing.get("files"), dict) else {} + new_files = {} + now = int(time.time()) + + for path in sorted(x for x in root.rglob("*") if x.is_file()): + rel = path.relative_to(root).as_posix() + if rel == "manifest.json": + continue + + st = path.stat() + digest = sha256_file(path) + old = old_files.get(rel, {}) + + if ( + old.get("sha256") == digest + and int(old.get("size", -1)) == st.st_size + and int(old.get("mtime_ns", -1)) == st.st_mtime_ns + ): + new_files[rel] = old + else: + new_files[rel] = { + "inbox_path": f"site-generated:{rel}", + "mtime_ns": st.st_mtime_ns, + "promoted_at": now, + "relative_path": rel, + "sha256": digest, + "size": st.st_size, + "source": "site", + } + + manifest = dict(existing) + manifest["files"] = new_files + manifest["updated_at"] = now + + tmp = manifest_path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") + tmp.replace(manifest_path) + + return sha256_file(manifest_path) + + +def canonical_manifest_sha256(account_id): + manifest = account_root(account_id) / "manifest.json" + if not manifest.exists(): + return None + return sha256_file(manifest) + + +def read_json_file(path): + try: + return json.loads(path.read_text()) + except Exception: + return None + + +def bb_sync_info(account_id): + aid = account_id_str(account_id) + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + manifest_sha = canonical_manifest_sha256(account_id) + + regions = { + "us": { + "host": "psopeeps_us", + "status": "pending", + "applied_at": None, + }, + "eu": { + "host": "psopeeps_eu", + "status": "pending", + "applied_at": None, + }, + } + + if not manifest_sha: + return { + "status": "pending", + "manifest_sha256": None, + "regions": regions, + } + + for region, info in regions.items(): + state_path = root / "state" / "applied" / f"{info['host']}.site.{aid}.json" + state = read_json_file(state_path) + + if state and state.get("manifest_sha256") == manifest_sha: + info["status"] = "current" + info["applied_at"] = state.get("applied_at") + elif state: + info["status"] = "pending" + info["applied_at"] = state.get("applied_at") + else: + info["status"] = "pending" + + overall = "current" if all(x["status"] == "current" for x in regions.values()) else "pending" + + return { + "status": overall, + "manifest_sha256": manifest_sha, + "regions": regions, + } + + +def bb_payload(account_id, username): + sync = bb_sync_info(account_id) + return { + "created": True, + "ready": sync["status"] == "current", + "sync_status": sync["status"], + "username": username, + "account_id": account_id_str(account_id), + "regions": sync["regions"], + } + + + +def enqueue_account_sync(account_id, reason): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + queue = root / "queue" / "apply" + queue.mkdir(parents=True, exist_ok=True) + + aid = account_id_str(account_id) + payload = { + "account": aid, + "reason": reason, + "queued_at": int(time.time()), + } + + final = queue / f"{int(time.time())}.{time.time_ns()}.{aid}.json" + tmp = queue / f".{final.name}.tmp" + + tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n") + tmp.replace(final) + + return final + +def write_bb_license(account_id, username, password): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(license_text(account_id, username, password) + "\n") + tmp.replace(path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "bb_license_updated") + + return path + + + +app = Flask(__name__) +init_db() + + +@app.after_request +def add_headers(resp): + resp.headers["Cache-Control"] = "no-store" + return resp + + +@app.get("/health") +def health(): + return jsonify({"ok": True}) + + +@app.get("/me") +def me(): + user = current_user() + if not user: + return jsonify({"authenticated": False}) + return jsonify({ + "authenticated": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + }) + + + +def local_syncer_save_summary(account_id): + import json + import os + from pathlib import Path + from datetime import datetime, timezone + + account = account_id_str(account_id) + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + applied_dir = root / "state" / "applied" + + paths = { + "us": applied_dir / f"psopeeps_us.site.{account}.json", + "eu": applied_dir / f"psopeeps_eu.site.{account}.json", + } + + def parse_time(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except Exception: + return None + + regions = {} + + for region, path in paths.items(): + info = { + "status": "unknown", + "label": "Not seen", + "style": "warn", + "host": None, + "applied_at": None, + "manifest_sha256": None, + } + + if path.exists(): + try: + data = json.loads(path.read_text()) + info.update({ + "status": "seen", + "label": "Seen", + "style": "warn", + "host": data.get("host"), + "applied_at": data.get("applied_at"), + "manifest_sha256": data.get("manifest_sha256"), + }) + except Exception as e: + info["error"] = str(e) + + regions[region] = info + + us_hash = regions["us"].get("manifest_sha256") + eu_hash = regions["eu"].get("manifest_sha256") + + if us_hash and eu_hash and us_hash == eu_hash: + for region in ("us", "eu"): + regions[region]["status"] = "current" + regions[region]["label"] = "Current" + regions[region]["style"] = "good" + + times = [ + t for t in ( + parse_time(regions["us"].get("applied_at")), + parse_time(regions["eu"].get("applied_at")), + ) + if t + ] + latest = max(times) if times else None + + return { + "status": "current", + "safe": True, + "regions": regions, + "last_sync": latest.isoformat() if latest else None, + "message": "", + } + + if not us_hash and not eu_hash: + return { + "status": "unknown", + "safe": False, + "regions": regions, + "last_sync": None, + "message": "No mirrored save data has been seen for this account yet.", + } + + us_time = parse_time(regions["us"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc) + eu_time = parse_time(regions["eu"].get("applied_at")) or datetime.fromtimestamp(0, tz=timezone.utc) + + newest = "us" if us_time >= eu_time else "eu" + older = "eu" if newest == "us" else "us" + + regions[newest]["status"] = "newest" + regions[newest]["label"] = "Newest" + regions[newest]["style"] = "good" + + regions[older]["status"] = "pending" + regions[older]["label"] = "Pending" + regions[older]["style"] = "warn" + + latest = max(us_time, eu_time) + + return { + "status": "pending", + "safe": False, + "regions": regions, + "last_sync": latest.isoformat() if latest else None, + "message": "Save sync pending. If this does not clear soon, contact an admin in Discord.", + } + + +@app.get("/account") +def account(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT account_id, username + FROM bb_accounts + WHERE user_id = %s + LIMIT 1 + """, (user["id"],)) + bb = cur.fetchone() + + return jsonify({ + "authenticated": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + "bb": bb_payload(bb["account_id"], bb["username"]) if bb else { + "created": False, + "ready": False, + "sync_status": "missing", + "username": user["username"], + "account_id": None, + "regions": { + "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None}, + "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None}, + }, + }, + "v2_v3_keys": [], + "save_sync": local_syncer_save_summary(bb["account_id"]) if bb else { + "status": "missing", + "message": "Create a Blue Burst account before save sync status is available.", + "last_sync": None, + "regions": { + "us": {"host": "psopeeps_us", "status": "missing", "applied_at": None}, + "eu": {"host": "psopeeps_eu", "status": "missing", "applied_at": None}, + }, + }, + }) + + + +@app.post("/email/start") +def email_start(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + data = json_body() + email = clean_email(data.get("email")) + if not email: + return jsonify({"ok": False, "error": "Enter a valid email address."}), 400 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + UPDATE site_users + SET email = %s, + email_verified_at = NULL, + updated_at = now() + WHERE id = %s + RETURNING id, username, email, email_verified_at + """, (email, user["id"])) + updated = cur.fetchone() + except psycopg.errors.UniqueViolation: + return jsonify({"ok": False, "error": "That email is already in use."}), 409 + + token, expires_at = create_email_verification_token(updated["id"], updated["email"]) + result = send_verification_email( + updated["email"], + updated["username"], + verification_link(token), + ) + + return jsonify({ + "ok": True, + "email": user_email_payload(updated), + "verification": result, + }) + + +@app.post("/email/resend") +def email_resend(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email = clean_email(user.get("email")) + if not email: + return jsonify({"ok": False, "error": "No email address is set."}), 400 + + if user.get("email_verified_at"): + return jsonify({"ok": True, "email": user_email_payload(user), "already_verified": True}) + + token, expires_at = create_email_verification_token(user["id"], email) + result = send_verification_email( + email, + user["username"], + verification_link(token), + ) + + return jsonify({ + "ok": True, + "email": user_email_payload(user), + "verification": result, + }) + + +@app.get("/email/verify") +def email_verify(): + token = request.args.get("token") or "" + if not token: + return redirect("/account-unverified.html?error=missing-token") + + th = token_hash(token) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, user_id, email + FROM email_verification_tokens + WHERE token_hash = %s + AND used_at IS NULL + AND expires_at > now() + LIMIT 1 + """, (th,)) + row = cur.fetchone() + + if not row: + return redirect("/account-unverified.html?error=invalid-token") + + cur.execute(""" + UPDATE site_users + SET email = %s, + email_verified_at = now(), + updated_at = now() + WHERE id = %s + """, (row["email"], row["user_id"])) + + cur.execute(""" + UPDATE email_verification_tokens + SET used_at = now() + WHERE id = %s + """, (row["id"],)) + + return redirect("/account.html?verified=1") + + +@app.post("/register") +def register(): + data = json_body() + username = clean_username(data.get("username")) + email = clean_email(data.get("email")) + password = data.get("password") or "" + + if not username: + return jsonify({ + "ok": False, + "error": "Username must be 3-16 characters: lowercase letters, numbers, underscore, or hyphen.", + }), 400 + + if not email: + return jsonify({"ok": False, "error": "Enter a valid email address."}), 400 + + if len(password) < 8: + return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO site_users (username, email, password_hash) + VALUES (%s, %s, %s) + RETURNING id, username, email, email_verified_at + """, (username, email, generate_password_hash(password))) + user = cur.fetchone() + except psycopg.errors.UniqueViolation: + return jsonify({"ok": False, "error": "That username or email is already taken."}), 409 + + verify_token, verify_expires_at = create_email_verification_token(user["id"], user["email"]) + verify_result = send_verification_email( + user["email"], + user["username"], + verification_link(verify_token), + ) + + token, expires_at = issue_session(user["id"]) + resp = make_response(jsonify({ + "ok": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + "email": user_email_payload(user), + "verification": verify_result, + })) + set_session_cookie(resp, token, expires_at) + return resp + + +@app.post("/login") +def login(): + data = json_body() + username = clean_username(data.get("username")) + password = data.get("password") or "" + + if not username or not password: + return jsonify({"ok": False, "error": "Username and password are required."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, username, password_hash, email, email_verified_at + FROM site_users + WHERE username = %s + LIMIT 1 + """, (username,)) + user = cur.fetchone() + + if not user or not check_password_hash(user["password_hash"], password): + return jsonify({"ok": False, "error": "Invalid username or password."}), 401 + + token, expires_at = issue_session(user["id"]) + resp = make_response(jsonify({ + "ok": True, + "user": { + "id": user["id"], + "username": user["username"], + }, + })) + set_session_cookie(resp, token, expires_at) + return resp + + + +@app.post("/password/change") +def password_change(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + data = json_body() + current_password = data.get("current_password") or data.get("currentPassword") or "" + new_password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + if len(new_password) < 8: + return jsonify({"ok": False, "error": "Password must be at least 8 characters."}), 400 + + if new_password != confirm: + return jsonify({"ok": False, "error": "Passwords do not match."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, password_hash + FROM site_users + WHERE id = %s + LIMIT 1 + """, (user["id"],)) + row = cur.fetchone() + + if not row or not check_password_hash(row["password_hash"], current_password): + return jsonify({"ok": False, "error": "Current password is incorrect."}), 401 + + cur.execute(""" + UPDATE site_users + SET password_hash = %s, + updated_at = now() + WHERE id = %s + """, (generate_password_hash(new_password), user["id"])) + + return jsonify({"ok": True}) + +@app.post("/logout") +def logout(): + token = request.cookies.get(COOKIE_NAME) + if token: + with connect() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM site_sessions WHERE token_hash = %s", (token_hash(token),)) + + resp = make_response(jsonify({"ok": True})) + clear_session_cookie(resp) + return resp + + +@app.post("/bb/create") +def bb_create(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email_error = require_verified_email(user) + if email_error: + return email_error + + data = json_body() + password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + err = validate_bb_password(password) + if err: + return jsonify({"ok": False, "error": err}), 400 + if password != confirm: + return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400 + + username = user["username"] + account_id = bb_account_id(username) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute("SELECT user_id FROM bb_accounts WHERE user_id = %s", (user["id"],)) + if cur.fetchone(): + return jsonify({"ok": False, "error": "Blue Burst account already exists."}), 409 + + write_bb_license(account_id, username, password) + + cur.execute(""" + INSERT INTO bb_accounts (user_id, account_id, username) + VALUES (%s, %s, %s) + RETURNING account_id, username + """, (user["id"], account_id, username)) + bb = cur.fetchone() + + return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])}) + + +@app.post("/bb/change-password") +def bb_change_password(): + user = current_user() + if not user: + return jsonify({"ok": False, "error": "Not signed in."}), 401 + + email_error = require_verified_email(user) + if email_error: + return email_error + + data = json_body() + password = data.get("password") or "" + confirm = data.get("confirm_password") or data.get("confirmPassword") or "" + + err = validate_bb_password(password) + if err: + return jsonify({"ok": False, "error": err}), 400 + if password != confirm: + return jsonify({"ok": False, "error": "Blue Burst passwords do not match."}), 400 + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT account_id, username + FROM bb_accounts + WHERE user_id = %s + LIMIT 1 + """, (user["id"],)) + bb = cur.fetchone() + + if not bb: + return jsonify({"ok": False, "error": "Blue Burst account does not exist yet."}), 404 + + write_bb_license(bb["account_id"], bb["username"], password) + + cur.execute(""" + UPDATE bb_accounts + SET updated_at = now() + WHERE user_id = %s + """, (user["id"],)) + + return jsonify({"ok": True, "bb": bb_payload(bb["account_id"], bb["username"])}) + + +# Register V2 key profile API routes. +from key_routes import register_key_routes + +register_key_routes( + app, + connect=connect, + current_user=current_user, + jsonify=jsonify, + request=request, + account_id_str=account_id_str, + bb_account_id=bb_account_id, + canonical_license_path=canonical_license_path, + refresh_account_manifest=refresh_account_manifest, + enqueue_account_sync=enqueue_account_sync, + bb_sync_info=bb_sync_info, +) + +# --- Hardcore stats aggregator ------------------------------------------------- +# Combines EU + US local Hardcore stats APIs for the website. +# Source rows are kept conceptually separate, then merged by stable character key. + +import urllib.request as _hc_urllib_request +import urllib.error as _hc_urllib_error +from datetime import datetime as _hc_datetime, timezone as _hc_timezone + +def _hc_source_urls(): + sources = [] + eu = (os.environ.get("HARDCORE_STATS_EU_URL") or "").strip().rstrip("/") + us = (os.environ.get("HARDCORE_STATS_US_URL") or "").strip().rstrip("/") + if eu: + sources.append(("eu", eu)) + if us: + sources.append(("us", us)) + return sources + +def _hc_fetch_json(url, timeout=30): + req = _hc_urllib_request.Request( + url, + headers={"User-Agent": "psopeeps-site-hardcore-aggregator/1.0"}, + ) + with _hc_urllib_request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8", errors="replace") + return json.loads(raw) + +def _hc_get_source_characters(): + rows = [] + errors = [] + + for source_name, base_url in _hc_source_urls(): + url = f"{base_url}/api/hardcore/characters" + try: + data = _hc_fetch_json(url) + if isinstance(data, list): + for row in data: + if isinstance(row, dict): + r = dict(row) + r["_source_ship"] = source_name + r["_source_url"] = base_url + rows.append(r) + else: + errors.append({ + "source_ship": source_name, + "url": url, + "error": "response was not a list", + }) + except Exception as e: + errors.append({ + "source_ship": source_name, + "url": url, + "error": str(e), + }) + + return rows, errors + +def _hc_int(value, default=0): + try: + if value is None: + return default + return int(value) + except Exception: + return default + +def _hc_bool(value, default=True): + if isinstance(value, bool): + return value + if value is None: + return default + if isinstance(value, str): + return value.strip().lower() not in {"0", "false", "no", "dead"} + return bool(value) + +def _hc_sort_ts(value): + if not value: + return "" + return str(value) + +def _hc_character_key(row): + guild_card = _hc_int(row.get("guild_card")) + slot = _hc_int(row.get("character_slot")) + creation_ts = _hc_int(row.get("character_creation_timestamp")) + + # Real creation timestamps are stable across synced saves and should be used. + if creation_ts > 0: + return (guild_card, slot, creation_ts) + + # Legacy timestamp-0 rows are kept separate by name so an old dead/test row + # does not poison the current real character row. + name = str(row.get("character_name") or "") + return (guild_card, slot, f"legacy:{name}") + +def _hc_pick_display_row(rows): + return sorted( + rows, + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("play_time_seconds")), + _hc_sort_ts(r.get("updated_at")), + ), + reverse=True, + )[0] + +def _hc_merge_character_rows(rows): + by_key = {} + for row in rows: + by_key.setdefault(_hc_character_key(row), []).append(row) + + combined = [] + + for key, group in by_key.items(): + display = _hc_pick_display_row(group) + + source_ships = sorted({ + str(r.get("_source_ship") or "") + for r in group + if r.get("_source_ship") + }) + + # Kills aggregate across source rows in the same character identity group. + total_kills = sum(_hc_int(r.get("total_enemies_killed")) for r in group) + + # Higher numeric values win for progression fields. + level = max(_hc_int(r.get("level"), 1) for r in group) + total_exp = max(_hc_int(r.get("total_exp")) for r in group) + play_time_seconds = max(_hc_int(r.get("play_time_seconds")) for r in group) + + # Death anywhere in the same identity group makes the combined row dead. + alive = all(_hc_bool(r.get("alive"), True) for r in group) + + updated_at = max((str(r.get("updated_at") or "") for r in group), default="") + last_seen_at = max((str(r.get("last_seen_at") or "") for r in group), default="") + dead_at_values = [str(r.get("dead_at")) for r in group if r.get("dead_at")] + dead_at = min(dead_at_values) if dead_at_values else None + + merged = dict(display) + merged.pop("_source_url", None) + + merged["source_ships"] = source_ships + merged["source_count"] = len(group) + merged["guild_card"] = _hc_int(display.get("guild_card")) + merged["character_slot"] = _hc_int(display.get("character_slot")) + merged["character_creation_timestamp"] = _hc_int(display.get("character_creation_timestamp")) + merged["level"] = level + merged["total_exp"] = total_exp + merged["play_time_seconds"] = play_time_seconds + merged["total_enemies_killed"] = total_kills + merged["alive"] = alive + merged["dead_at"] = dead_at + merged["last_seen_at"] = last_seen_at or None + merged["updated_at"] = updated_at or None + + combined.append(merged) + + return combined + +def _hc_combined_payload(): + source_rows, errors = _hc_get_source_characters() + combined = _hc_merge_character_rows(source_rows) + return combined, errors + +@app.get("/hardcore/sources") +def hardcore_sources(): + statuses = [] + + for source_name, base_url in _hc_source_urls(): + status = { + "source_ship": source_name, + "base_url": base_url, + "ok": False, + "health": None, + "error": None, + } + try: + status["health"] = _hc_fetch_json(f"{base_url}/health") + status["ok"] = bool(status["health"].get("ok")) if isinstance(status["health"], dict) else True + except Exception as e: + status["error"] = str(e) + statuses.append(status) + + return jsonify({ + "ok": all(s["ok"] for s in statuses) if statuses else False, + "sources": statuses, + }) + +@app.get("/hardcore/characters") +def hardcore_characters_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify({ + "ok": not errors, + "errors": errors, + "count": len(combined), + "characters": combined, + }) + +@app.get("/hardcore/leaderboard/kills") +def hardcore_leaderboard_kills_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_enemies_killed")), + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +@app.get("/hardcore/leaderboard/level") +def hardcore_leaderboard_level_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("level")), + _hc_int(r.get("total_exp")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +@app.get("/hardcore/leaderboard/exp") +def hardcore_leaderboard_exp_combined(): + combined, errors = _hc_combined_payload() + combined.sort( + key=lambda r: ( + _hc_int(r.get("total_exp")), + _hc_int(r.get("level")), + _hc_int(r.get("total_enemies_killed")), + ), + reverse=True, + ) + return jsonify(combined[:100]) + +def _hc_points_row(row): + level = _hc_int(row.get("level")) + total_exp = _hc_int(row.get("total_exp")) + kills = _hc_int(row.get("total_enemies_killed")) + play_time = _hc_int(row.get("play_time_seconds")) + hours_played = play_time / 3600 if play_time > 0 else 0 + + level_points = (level * 1000) + (level * level * 10) + exp_points = int(total_exp / 1000) + kill_points = int(100 * (kills ** 0.5)) + survival_time_points = min(10000, int(250 * (hours_played ** 0.5))) + challenge_mode_points = 0 + total_points_raw = level_points + exp_points + kill_points + survival_time_points + challenge_mode_points + total_points = int(total_points_raw / 100) + + player_name = row.get("character_name") or row.get("name") or "" + + return { + "PlayerName": player_name, + "CharacterName": player_name, + "Points": total_points, + "TotalPoints": total_points, + "Class": row.get("character_class") or "", + "SecID": row.get("section_id") or "", + "LevelPoints": level_points, + "EXPPoints": exp_points, + "KillPoints": kill_points, + "SurvivalTimePoints": survival_time_points, + "ChallengeModePoints": challenge_mode_points, + "Level": level, + "TotalEXP": total_exp, + "Kills": kills, + "TotalKills": kills, + "PlayTimeSeconds": play_time, + "Alive": _hc_bool(row.get("alive"), True), + } + +@app.get("/hardcore/leaderboard/points") +def hardcore_leaderboard_points_combined(): + combined, errors = _hc_combined_payload() + rows = [_hc_points_row(r) for r in combined] + rows.sort(key=lambda r: r["TotalPoints"], reverse=True) + return jsonify(rows[:100]) + +# --- end Hardcore stats aggregator ------------------------------------------- diff --git a/backend/key_routes.py b/backend/key_routes.py new file mode 100644 index 0000000..9fa277d --- /dev/null +++ b/backend/key_routes.py @@ -0,0 +1,540 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + gc_password TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + cur.execute(""" + ALTER TABLE v2_v3_key_profiles + ADD COLUMN IF NOT EXISTS gc_password TEXT + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format: NN-NNNN-NNNN.") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + + if row["game_version"] == "gc_v3": + gc_password = row.get("gc_password") + if not gc_password: + raise RuntimeError("GC V3 key profile is missing its GC password") + password = phosg_string(gc_password) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}, "Password": {password}}}' + + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key, gc_password + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + def normalize_access_key(value, game_version): + raw = validate_secret(value) + if game_version == "gc_v3": + normalized = raw.replace("-", "").replace(" ", "") + if not normalized.isdigit() or len(normalized) != 12: + raise ValueError("GC V3 access key must be 12 digits.") + return normalized + return raw + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = normalize_access_key(data.get("access_key"), game_version) + gc_password = validate_secret(data.get("password")) if game_version == "gc_v3" else None + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key, + gc_password + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + gc_password, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-final-gc-v3-neutral-20260611T011127Z b/backend/key_routes.py.before-final-gc-v3-neutral-20260611T011127Z new file mode 100644 index 0000000..a4dc688 --- /dev/null +++ b/backend/key_routes.py.before-final-gc-v3-neutral-20260611T011127Z @@ -0,0 +1,514 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format: NN-NNNN-NNNN.") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-gc-v3-accesskey-normalize-20260611T014008Z b/backend/key_routes.py.before-gc-v3-accesskey-normalize-20260611T014008Z new file mode 100644 index 0000000..f37a64d --- /dev/null +++ b/backend/key_routes.py.before-gc-v3-accesskey-normalize-20260611T014008Z @@ -0,0 +1,530 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + gc_password TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + cur.execute(""" + ALTER TABLE v2_v3_key_profiles + ADD COLUMN IF NOT EXISTS gc_password TEXT + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format: NN-NNNN-NNNN.") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + + if row["game_version"] == "gc_v3": + gc_password = row.get("gc_password") + if not gc_password: + raise RuntimeError("GC V3 key profile is missing its GC password") + password = phosg_string(gc_password) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}, "Password": {password}}}' + + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key, gc_password + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + gc_password = validate_secret(data.get("password")) if game_version == "gc_v3" else None + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key, + gc_password + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + gc_password, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-gc-v3-password-column-20260611T012517Z b/backend/key_routes.py.before-gc-v3-password-column-20260611T012517Z new file mode 100644 index 0000000..a4dc688 --- /dev/null +++ b/backend/key_routes.py.before-gc-v3-password-column-20260611T012517Z @@ -0,0 +1,514 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format: NN-NNNN-NNNN.") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-gc-v3-serial-message-fix-20260611T002117Z b/backend/key_routes.py.before-gc-v3-serial-message-fix-20260611T002117Z new file mode 100644 index 0000000..8df5300 --- /dev/null +++ b/backend/key_routes.py.before-gc-v3-serial-message-fix-20260611T002117Z @@ -0,0 +1,514 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format, like 21-3364-4991") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-gc-v3-smtp-fix-20260611T001916Z b/backend/key_routes.py.before-gc-v3-smtp-fix-20260611T001916Z new file mode 100644 index 0000000..8df5300 --- /dev/null +++ b/backend/key_routes.py.before-gc-v3-smtp-fix-20260611T001916Z @@ -0,0 +1,514 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format, like 21-3364-4991") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-insert-normalize-access-key-20260611T014503Z b/backend/key_routes.py.before-insert-normalize-access-key-20260611T014503Z new file mode 100644 index 0000000..dc36e25 --- /dev/null +++ b/backend/key_routes.py.before-insert-normalize-access-key-20260611T014503Z @@ -0,0 +1,530 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + gc_password TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + cur.execute(""" + ALTER TABLE v2_v3_key_profiles + ADD COLUMN IF NOT EXISTS gc_password TEXT + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format: NN-NNNN-NNNN.") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + + if row["game_version"] == "gc_v3": + gc_password = row.get("gc_password") + if not gc_password: + raise RuntimeError("GC V3 key profile is missing its GC password") + password = phosg_string(gc_password) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}, "Password": {password}}}' + + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key, gc_password + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = normalize_access_key(data.get("access_key"), game_version) + gc_password = validate_secret(data.get("password")) if game_version == "gc_v3" else None + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key, + gc_password + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + gc_password, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/key_routes.py.before-neutral-gc-v3-copy-20260611T010909Z b/backend/key_routes.py.before-neutral-gc-v3-copy-20260611T010909Z new file mode 100644 index 0000000..8a2c92f --- /dev/null +++ b/backend/key_routes.py.before-neutral-gc-v3-copy-20260611T010909Z @@ -0,0 +1,514 @@ +import json +import re +import os +from pathlib import Path + +import psycopg + + +SUPPORTED_KEY_VERSIONS = { + "dc_v2": {"label": "DC V2"}, + "pc_v2": {"label": "PC V2"}, + "gc_v3": {"label": "GC V3"}, +} + + +def register_key_routes( + app, + *, + connect, + current_user, + jsonify, + request, + account_id_str, + bb_account_id, + canonical_license_path, + refresh_account_manifest, + enqueue_account_sync, + bb_sync_info, +): + def ensure_key_profile_table(): + with connect() as conn: + with conn.cursor() as cur: + cur.execute(""" + CREATE TABLE IF NOT EXISTS v2_v3_key_profiles ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES site_users(id) ON DELETE CASCADE, + account_id BIGINT NOT NULL, + game_version TEXT NOT NULL, + label TEXT NOT NULL DEFAULT '', + serial_number_hex TEXT NOT NULL, + access_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (game_version, serial_number_hex) + ) + """) + conn.commit() + + def site_account_id(username): + return bb_account_id(username) + + def user_has_bb_account(conn, user_id): + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM bb_accounts WHERE user_id = %s LIMIT 1", + (user_id,), + ) + return cur.fetchone() is not None + + def normalize_serial_hex(value, game_version): + raw = str(value or "").strip() + + if not raw: + raise ValueError("serial number is required") + + if game_version == "dc_v2": + v = raw.upper() + if v.startswith("0X"): + v = v[2:] + if not re.fullmatch(r"[0-9A-F]{1,8}", v): + raise ValueError("DC V2 serial must be hex, like 4E62F237") + return f"{int(v, 16):08X}" + + if game_version == "pc_v2": + if not re.fullmatch(r"[0-9]{1,10}", raw): + raise ValueError("PC V2 serial must be digits only") + n = int(raw, 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("PC V2 serial is out of range") + return f"{n:08X}" + + if game_version == "gc_v3": + if not re.fullmatch(r"[0-9]{2}-[0-9]{4}-[0-9]{4}", raw): + raise ValueError("GC V3 serial must use the dashed format, like 11-3273-6540") + n = int(raw.replace("-", ""), 10) + if not (0 <= n <= 0xFFFFFFFF): + raise ValueError("GC V3 serial is out of range") + return f"{n:08X}" + + raise ValueError("unsupported game version") + + def validate_secret(value): + v = str(value or "").strip() + if not v: + raise ValueError("key is required") + if len(v) > 64: + raise ValueError("key is too long") + if any(ord(ch) < 0x20 for ch in v): + raise ValueError("key contains invalid characters") + return v + + def phosg_string(value): + return json.dumps(str(value)) + + def base_site_license_text(account_id, username): + aid_hex = f"0x{int(account_id):X}" + uname = phosg_string(username) + return ( + "{\n" + " \"BBTeamID\": 0x0,\n" + " \"FormatVersion\": 0x1,\n" + f" \"AccountID\": {aid_hex},\n" + f" \"LastPlayerName\": {uname},\n" + " \"DCNTELicenses\": [],\n" + " \"BBLicenses\": [],\n" + " \"BanEndTime\": 0x0,\n" + " \"PCLicenses\": [],\n" + " \"AutoReplyMessage\": \"\",\n" + " \"GCLicenses\": [],\n" + " \"AutoPatchesEnabled\": [\"PsoPeepsV2EXP_enabled\", \"RareDropNotifications\", \"UltimateMapFix\", \"RaresInQuests\", \"DisableIdleDisconnect\", \"ItemLossPrevention\"],\n" + " \"XBLicenses\": [],\n" + " \"Flags\": 0x0,\n" + " \"Ep3TotalMesetaEarned\": 0x0,\n" + " \"Ep3CurrentMeseta\": 0x0,\n" + " \"DCLicenses\": [],\n" + " \"UserFlags\": 0x0\n" + "}" + ) + + def find_array_span(text, array_name): + marker = f'"{array_name}":' + marker_pos = text.find(marker) + if marker_pos < 0: + return None + + bracket_pos = text.find("[", marker_pos) + if bracket_pos < 0: + return None + + depth = 0 + in_string = False + escape = False + + for i in range(bracket_pos, len(text)): + ch = text[i] + + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + else: + if ch == '"': + in_string = True + elif ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if depth == 0: + return bracket_pos, i + 1 + + return None + + def replace_license_array(text, array_name, entries): + span = find_array_span(text, array_name) + if span is None: + raise RuntimeError(f"could not find {array_name} in license file") + + start, end = span + rendered = "[]" if not entries else "[\n" + ",\n".join(f" {e}" for e in entries) + "\n ]" + return text[:start] + rendered + text[end:] + + def render_v2_entry(row): + serial_hex = row["serial_number_hex"].upper() + secret = phosg_string(row["access_key"]) + return f'{{"SerialNumber": 0x{serial_hex}, "AccessKey": {secret}}}' + + + def rewrite_v2_license_arrays(conn, user, account_id): + path = canonical_license_path(account_id) + path.parent.mkdir(parents=True, exist_ok=True) + + text = path.read_text() if path.exists() else base_site_license_text(account_id, user["username"]) + + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + dc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "dc_v2"] + pc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "pc_v2"] + gc_entries = [render_v2_entry(r) for r in rows if r["game_version"] == "gc_v3"] + + text = replace_license_array(text, "DCLicenses", dc_entries) + text = replace_license_array(text, "PCLicenses", pc_entries) + text = replace_license_array(text, "GCLicenses", gc_entries) + + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text + "\n") + tmp.replace(path) + + sync_root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + flat_path = sync_root / "canonical-system" / "system" / "licenses" / path.name + flat_path.parent.mkdir(parents=True, exist_ok=True) + flat_tmp = flat_path.with_suffix(flat_path.suffix + ".tmp") + flat_tmp.write_text(text + "\n") + flat_tmp.replace(flat_path) + + refresh_account_manifest(account_id) + enqueue_account_sync(account_id, "v2_key_profile_updated") + return path + + + @app.get("/keys") + def list_key_profiles(): + user = current_user() + if not user: + return jsonify({"authenticated": False}), 401 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + sync = bb_sync_info(account_id) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex, created_at, updated_at + FROM v2_v3_key_profiles + WHERE user_id = %s + ORDER BY id + """, (user["id"],)) + rows = list(cur.fetchall()) + + return jsonify({ + "authenticated": True, + "account_id": account_id_str(account_id), + "sync_status": sync["status"], + "regions": sync["regions"], + "keys": [{ + "id": row["id"], + "game_version": row["game_version"], + "game_version_label": SUPPORTED_KEY_VERSIONS.get(row["game_version"], {}).get("label", row["game_version"]), + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + "created_at": row["created_at"].isoformat() if row["created_at"] else None, + "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, + } for row in rows], + }) + + + def license_text_has_serial(text, serial_hex): + want_hex = (serial_hex or "").upper().lstrip("0") or "0" + + for match in re.finditer(r'"SerialNumber"\s*:\s*0x([0-9A-Fa-f]+)', text): + got_hex = match.group(1).upper().lstrip("0") or "0" + if got_hex == want_hex: + return True + + try: + want_dec = str(int(serial_hex, 16)) + except ValueError: + want_dec = None + + if want_dec is not None: + for match in re.finditer(r'"SerialNumber"\s*:\s*([0-9]+)', text): + if match.group(1) == want_dec: + return True + + return False + + + def find_existing_key_owner(serial_hex, current_account_id): + root = Path(os.environ.get("ACCOUNT_SYNC_ROOT", "/account-sync")) + + current_ids = { + account_id_str(current_account_id), + str(int(current_account_id)), + } + + def owner_for_license_path(license_path): + try: + rel = license_path.relative_to(root) + parts = rel.parts + except Exception: + return license_path.stem + + # canonical/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # canonical/REGION/accounts/ACCOUNT/system/licenses/ACCOUNT.json + # inbox/REGION/SOURCE/ACCOUNT/system/licenses/ACCOUNT.json + for i in range(1, len(parts) - 2): + if parts[i:i + 2] == ("system", "licenses"): + possible = parts[i - 1] + if re.fullmatch(r"[0-9]{10}", possible): + return possible + + # canonical-system/system/licenses/ACCOUNT.json + return license_path.stem + + search_paths = [] + seen_paths = set() + + for base in ( + root / "canonical", + root / "canonical-system", + root / "inbox", + ): + if not base.is_dir(): + continue + + for license_path in sorted(base.rglob("system/licenses/*.json")): + if license_path in seen_paths: + continue + seen_paths.add(license_path) + search_paths.append((license_path, owner_for_license_path(license_path))) + + for license_path, owner_account_id in search_paths: + if owner_account_id in current_ids: + continue + + try: + text = license_path.read_text(errors="ignore") + except OSError: + continue + + if license_text_has_serial(text, serial_hex): + return { + "account_id": owner_account_id, + "path": str(license_path), + } + + return None + + + @app.post("/keys/register") + def register_key_profile(): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + if not user_has_bb_account(conn, user["id"]): + return jsonify({ + "error": "Create your Blue Burst account before adding DC V2, PC V2, or GC V3 keys.", + "bb_account_required": True, + }), 409 + + data = request.get_json(silent=True) or {} + game_version = str(data.get("game_version") or "").strip().lower() + label = str(data.get("label") or "").strip()[:80] + + if game_version not in SUPPORTED_KEY_VERSIONS: + return jsonify({"error": "unsupported game version"}), 400 + + try: + serial_hex = normalize_serial_hex(data.get("serial_number"), game_version) + key_secret = validate_secret(data.get("access_key")) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + + account_id = site_account_id(user["username"]) + + duplicate = find_existing_key_owner(serial_hex, account_id) + if duplicate: + return jsonify({ + "error": "that key is already registered to another account" + }), 409 + + try: + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + INSERT INTO v2_v3_key_profiles ( + user_id, + account_id, + game_version, + label, + serial_number_hex, + access_key + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + user["id"], + account_id, + game_version, + label, + serial_hex, + key_secret, + )) + row = cur.fetchone() + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + except psycopg.errors.UniqueViolation: + return jsonify({"error": "that key is already registered to an account"}), 409 + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": game_version, + "game_version_label": SUPPORTED_KEY_VERSIONS[game_version]["label"], + "label": label, + "serial_number_hex": serial_hex, + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + + + @app.get("/keys//access-key") + def get_key_access_key(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before viewing game account keys.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, serial_number_hex, access_key + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + return jsonify({ + "ok": True, + "key": { + "id": row["id"], + "game_version": row["game_version"], + "serial_number_hex": row["serial_number_hex"], + "access_key": row["access_key"], + }, + }) + + + @app.delete("/keys/") + def delete_key_profile(key_id): + user = current_user() + if not user: + return jsonify({"error": "not authenticated"}), 401 + + if not user.get("email_verified_at"): + return jsonify({ + "error": "Please verify your email before changing game account settings.", + "email_required": True, + }), 403 + + ensure_key_profile_table() + account_id = site_account_id(user["username"]) + + with connect() as conn: + with conn.cursor(row_factory=psycopg.rows.dict_row) as cur: + cur.execute(""" + SELECT id, game_version, label, serial_number_hex + FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + LIMIT 1 + """, (key_id, user["id"])) + row = cur.fetchone() + + if not row: + return jsonify({"error": "key profile not found"}), 404 + + cur.execute(""" + DELETE FROM v2_v3_key_profiles + WHERE id = %s AND user_id = %s + """, (key_id, user["id"])) + + rewrite_v2_license_arrays(conn, user, account_id) + conn.commit() + + sync = bb_sync_info(account_id) + + return jsonify({ + "ok": True, + "deleted": { + "id": row["id"], + "game_version": row["game_version"], + "label": row["label"], + "serial_number_hex": row["serial_number_hex"], + }, + "sync_status": sync["status"], + "account_id": account_id_str(account_id), + }) + diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..818ddef --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.3 +gunicorn==22.0.0 +psycopg[binary]==3.2.1 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..12fc85b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +services: + caddy: + image: caddy:2-alpine + container_name: psopeeps-web-caddy + restart: unless-stopped + env_file: + - .env + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ./site:/srv/site:ro + - ./caddy-data:/data + - ./caddy-config:/config + depends_on: + - app + networks: + - psopeeps-web + + app: + build: + context: ./backend + container_name: psopeeps-web-app + user: "${PUID}:${PGID}" + restart: unless-stopped + env_file: + - .env + environment: + APP_PORT: ${APP_PORT:-8000} + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + SESSION_COOKIE_NAME: ${SESSION_COOKIE_NAME:-psopeeps_session} + SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false} + SESSION_COOKIE_SAMESITE: ${SESSION_COOKIE_SAMESITE:-Lax} + SESSION_DAYS: ${SESSION_DAYS:-30} + ACCOUNT_SYNC_ROOT: /account-sync + PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://psopeeps.online} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} + SMTP_TLS: ${SMTP_TLS:-starttls} + EMAIL_DEBUG_SHOW_LINK: ${EMAIL_DEBUG_SHOW_LINK:-false} + HARDCORE_STATS_EU_URL: ${HARDCORE_STATS_EU_URL:-} + HARDCORE_STATS_US_URL: ${HARDCORE_STATS_US_URL:-} + volumes: + - ../psopeeps_account_sync:/account-sync + depends_on: + postgres: + condition: service_healthy + networks: + - psopeeps-web + + postgres: + image: postgres:16-alpine + container_name: psopeeps-web-postgres + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./postgres-data:/var/lib/postgresql/data + networks: + - psopeeps-web + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 10s + timeout: 5s + retries: 5 + +networks: + psopeeps-web: + name: psopeeps-web diff --git a/hero.jpg b/hero.jpg new file mode 100644 index 0000000..2098660 Binary files /dev/null and b/hero.jpg differ diff --git a/hero2.png b/hero2.png new file mode 100644 index 0000000..79c6692 Binary files /dev/null and b/hero2.png differ diff --git a/hero3.png b/hero3.png new file mode 100644 index 0000000..bd94df3 Binary files /dev/null and b/hero3.png differ diff --git a/hero4.png b/hero4.png new file mode 100644 index 0000000..71a7bf4 Binary files /dev/null and b/hero4.png differ diff --git a/icons/.keep b/icons/.keep new file mode 100644 index 0000000..e69de29 diff --git a/icons/bluesky.png b/icons/bluesky.png new file mode 100644 index 0000000..7423cf7 Binary files /dev/null and b/icons/bluesky.png differ diff --git a/icons/discord.png b/icons/discord.png new file mode 100644 index 0000000..a4d8dff Binary files /dev/null and b/icons/discord.png differ diff --git a/icons/mastodon.png b/icons/mastodon.png new file mode 100644 index 0000000..7df2b5b Binary files /dev/null and b/icons/mastodon.png differ diff --git a/scripts/generate-hardcore-leaderboard-json.sh b/scripts/generate-hardcore-leaderboard-json.sh new file mode 100755 index 0000000..b4ef75f --- /dev/null +++ b/scripts/generate-hardcore-leaderboard-json.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /home/rbatty/.local/share/psopeeps_site + +out="site/generated/hardcore-leaderboard-points.json" +mkdir -p "$(dirname "$out")" + +tmp="$(mktemp "${out}.tmp.XXXXXX")" +trap 'rm -f "$tmp"' EXIT + +docker exec -i psopeeps-web-app python - <<'PY' > "$tmp" +from app import _hc_combined_payload, _hc_points_row +from datetime import datetime, timezone +import json + +combined, errors = _hc_combined_payload() + +rows = [] +for source_row in combined: + points_row = _hc_points_row(source_row) + + rows.append({ + "PlayerName": ( + points_row.get("PlayerName") + or points_row.get("CharacterName") + or source_row.get("character_name") + or "" + ), + "Points": int(points_row.get("Points") or points_row.get("TotalPoints") or 0), + "Class": ( + points_row.get("Class") + or source_row.get("character_class") + or "" + ), + "SecID": ( + points_row.get("SecID") + or source_row.get("section_id") + or "" + ), + "Kills": int(points_row.get("Kills") or points_row.get("TotalKills") or source_row.get("total_enemies_killed") or 0), + "PlayTimeSeconds": int(points_row.get("PlayTimeSeconds") or source_row.get("play_time_seconds") or 0), + "Alive": bool(points_row.get("Alive", True)), + "Level": int(points_row.get("Level") or source_row.get("level") or 0), + "TotalEXP": int(points_row.get("TotalEXP") or source_row.get("total_exp") or 0), + }) + +rows.sort(key=lambda r: r["Points"], reverse=True) + +payload = { + "generated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "stale_after_seconds": 600, + "errors": errors, + "rows": rows[:100], +} + +print(json.dumps(payload, separators=(",", ":"))) +PY + +python3 -m json.tool "$tmp" >/dev/null +mv -f "$tmp" "$out" +trap - EXIT diff --git a/site/ASSETS.md b/site/ASSETS.md new file mode 100644 index 0000000..cb82f10 --- /dev/null +++ b/site/ASSETS.md @@ -0,0 +1,14 @@ +# PSO Peeps Site Assets + +## Included + +- `hero.jpg` — cropped from the supplied Pioneer 2 screenshot for the homepage hero area. + +## Still expected / placeholders + +- `logo.png` — PSO Peeps logo. +- `icons/discord.png` — Discord icon. +- `icons/mastodon.png` — Mastodon icon. +- `icons/bluesky.png` — Bluesky icon. + +The HTML references these paths directly so the final assets can be dropped in without changing markup. diff --git a/site/account-keys.js b/site/account-keys.js new file mode 100644 index 0000000..63b44f6 --- /dev/null +++ b/site/account-keys.js @@ -0,0 +1,555 @@ +(() => { + "use strict"; + + const API = { + list: "/api/keys", + register: "/api/keys/register", + delete: (id) => `/api/keys/${encodeURIComponent(id)}`, + reveal: (id) => `/api/keys/${encodeURIComponent(id)}/access-key`, + }; + + let bound = false; + + function qs(sel, root = document) { + return root.querySelector(sel); + } + + function esc(v) { + return String(v ?? "").replace(/[&<>"']/g, ch => ({ + "&": "&", + "<": "<", + ">": ">", + "\"": """, + "'": "'", + }[ch])); + } + + async function readJson(res) { + const text = await res.text(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return { error: text }; + } + } + + function formEl() { + const version = qs("#key-version"); + return version ? version.closest("form") : null; + } + + function listEl() { + return qs(".key-list"); + } + + function setMessage(text, kind = "") { + const form = formEl(); + if (!form) return; + + let box = qs("#key-profile-status"); + if (!box) { + box = document.createElement("p"); + box.id = "key-profile-status"; + box.className = "key-status-message"; + form.insertAdjacentElement("afterend", box); + } + + box.textContent = text || ""; + box.dataset.kind = kind; + } + + function normalizeSerial(raw, gameVersion) { + const value = String(raw || "").trim(); + + if (gameVersion === "dc_v2") { + let v = value.toUpperCase(); + if (v.startsWith("0X")) v = v.slice(2); + if (/^[0-9A-F]{1,8}$/.test(v)) { + return v.padStart(8, "0"); + } + return ""; + } + + if (gameVersion === "pc_v2") { + if (/^[0-9]{1,10}$/.test(value)) { + const n = Number(value); + if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) { + return n.toString(16).toUpperCase().padStart(8, "0"); + } + } + return ""; + } + + if (gameVersion === "gc_v3") { + if (/^[0-9]{2}-[0-9]{4}-[0-9]{4}$/.test(value)) { + const n = Number(value.replaceAll("-", "")); + if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) { + return n.toString(16).toUpperCase().padStart(8, "0"); + } + } + return ""; + } + + return ""; + } + + function setupForm(form) { + const version = qs("#key-version", form); + if (version) { + version.innerHTML = [ + '', + '', + '', + ].join(""); + } + + updateSerialHint(form); + + const versionSelect = qs("#key-version", form); + if (versionSelect && !versionSelect.dataset.hintBound) { + versionSelect.dataset.hintBound = "1"; + versionSelect.addEventListener("change", () => updateSerialHint(form)); + } + + const keyPassword = qs("#key-password", form); + if (keyPassword) { + const label = qs('label[for="key-password"]', form); + if (label) label.remove(); + keyPassword.remove(); + } + + const serial = qs("#key-serial", form); + if (serial && !qs("#key-display-serial", form)) { + const displayLabel = document.createElement("label"); + displayLabel.id = "key-display-serial-label"; + displayLabel.setAttribute("for", "key-display-serial"); + displayLabel.textContent = "Confirm Serial Number"; + + const displayInput = document.createElement("input"); + displayInput.id = "key-display-serial"; + displayInput.name = "display_serial"; + displayInput.type = "text"; + displayInput.autocomplete = "off"; + displayInput.placeholder = "confirm serial number"; + + serial.insertAdjacentElement("afterend", displayInput); + serial.insertAdjacentElement("afterend", displayLabel); + } + + const access = qs("#key-access", form); + if (access && !qs("#key-password", form)) { + const passwordLabel = document.createElement("label"); + passwordLabel.id = "key-password-label"; + passwordLabel.setAttribute("for", "key-password"); + passwordLabel.textContent = "GC Password"; + passwordLabel.style.display = "none"; + + const passwordInput = document.createElement("input"); + passwordInput.id = "key-password"; + passwordInput.name = "password"; + passwordInput.type = "text"; + passwordInput.autocomplete = "off"; + passwordInput.placeholder = "GC password"; + passwordInput.style.display = "none"; + + access.insertAdjacentElement("afterend", passwordInput); + access.insertAdjacentElement("afterend", passwordLabel); + } + + const button = qs('button[type="submit"]', form) || qs("button", form); + if (button) { + button.type = "button"; + } + } + + + function syncDotClass(status) { + const value = String(status || "").toLowerCase(); + return (value === "synced" || value === "current") ? "is-synced" : "is-syncing"; + } + + function renderSyncStatus(label, status) { + const safeStatus = status || "unknown"; + return ` + + + ${esc(label)}: ${esc(safeStatus)} + + `; + } + + function renderKeySyncSummary(data) { + const box = qs("#key-sync-summary"); + if (!box) return; + + const sync = data.sync_status || "unknown"; + const us = data.regions?.us?.status || "unknown"; + const eu = data.regions?.eu?.status || "unknown"; + + box.innerHTML = ` + ${renderSyncStatus("Key sync", sync)} + ${renderSyncStatus("US", us)} + ${renderSyncStatus("EU", eu)} + `; + } + + + async function loadKeys() { + const res = await fetch("/api/keys", { + credentials: "same-origin", + }); + + const data = await readJson(res); + + if (!res.ok || data.ok === false) { + throw new Error(data.error || data.detail || `HTTP ${res.status}`); + } + + renderKeys(data); + return data; + } + + + function renderKeys(data) { + const list = listEl(); + if (!list) return; + + const keys = Array.isArray(data.keys) ? data.keys : []; + const sync = data.sync_status || "unknown"; + const us = data.regions?.us?.status || "unknown"; + const eu = data.regions?.eu?.status || "unknown"; + + renderKeySyncSummary(data); + const summary = ""; + + if (!keys.length) { + list.innerHTML = summary + ` +
+
+

No V2 / V3 keys registered

+

Add a DC V2, PC V2, or GC V3 key profile above.

+
+
+ `; + return; + } + + list.innerHTML = summary + keys.map(key => { + const version = key.game_version_label || key.game_version || "Key"; + const label = key.label || ""; + const serial = key.serial_number_hex || ""; + let displaySerial = key.display_serial || serial; + if (!key.display_serial && serial) { + const n = Number.parseInt(serial, 16); + if (Number.isFinite(n)) { + if (key.game_version === "pc_v2") { + displaySerial = String(n); + } else if (key.game_version === "gc_v3") { + const dec = String(n).padStart(10, "0"); + displaySerial = `${dec.slice(0, 2)}-${dec.slice(2, 6)}-${dec.slice(6, 10)}`; + } + } + } + const isGC = key.game_version === "gc_v3"; + + return ` +
+
+

${esc(version)}

+

Registered profile ${esc(serial)}

+

Serial: ${esc(displaySerial)}

+ ${label ? `

Label: ${esc(label)}

` : ""} +

+ Access key: + •••••••••••• + +

+ ${isGC ? `

+ Password: + •••••••••••• +

` : ""} +
+ +
+ `; + }).join(""); + } + + + function updateSerialHint(form) { + const gameVersion = qs("#key-version", form)?.value || ""; + const serial = qs("#key-serial", form); + const displaySerial = qs("#key-display-serial", form); + const access = qs("#key-access", form); + const password = qs("#key-password", form); + const passwordLabel = qs("#key-password-label", form); + + if (!serial) return; + + if (gameVersion === "dc_v2") { + serial.placeholder = "DC V2 serial number"; + if (displaySerial) displaySerial.placeholder = "confirm DC V2 serial number"; + if (access) access.placeholder = "access key"; + } else if (gameVersion === "pc_v2") { + serial.placeholder = "PC V2 decimal serial number"; + if (displaySerial) displaySerial.placeholder = "confirm PC V2 decimal serial number"; + if (access) access.placeholder = "access key"; + } else if (gameVersion === "gc_v3") { + serial.placeholder = "GC V3 serial number"; + if (displaySerial) displaySerial.placeholder = "confirm GC V3 serial number"; + if (access) access.placeholder = "access key"; + } + + if (passwordLabel) { + passwordLabel.style.display = gameVersion === "gc_v3" ? "" : "none"; + } + if (password) { + password.style.display = gameVersion === "gc_v3" ? "" : "none"; + if (gameVersion !== "gc_v3") { + password.value = ""; + } + } + } + + async function registerKey() { + const form = formEl(); + if (!form) return; + + const button = qs("button", form); + const gameVersion = qs("#key-version", form)?.value || ""; + const label = qs("#key-label", form)?.value.trim() || ""; + const rawSerial = qs("#key-serial", form)?.value.trim() || ""; + const displaySerial = qs("#key-display-serial", form)?.value.trim() || ""; + const accessKey = qs("#key-access", form)?.value.trim() || ""; + const password = qs("#key-password", form)?.value.trim() || ""; + + if (!["dc_v2", "pc_v2", "gc_v3"].includes(gameVersion)) { + setMessage("Choose DC V2, PC V2, or GC V3.", "warn"); + return; + } + + const serialChecks = { + dc_v2: /^(?:0x)?[0-9A-Fa-f]{1,8}$/, + pc_v2: /^[0-9]{1,10}$/, + gc_v3: /^[0-9]{2}-[0-9]{4}-[0-9]{4}$/, + }; + + if (!serialChecks[gameVersion]?.test(rawSerial)) { + const examples = { + dc_v2: "DC V2 serial must be hex, like 4E62F237.", + pc_v2: "PC V2 serial must be digits only.", + gc_v3: "GC V3 serial must use the dashed format: NN-NNNN-NNNN.", + }; + setMessage(examples[gameVersion] || "Enter a valid serial number.", "warn"); + return; + } + + const serial = rawSerial; + + if (!serial) { + setMessage("Enter a valid serial number.", "warn"); + return; + } + + if (!displaySerial) { + setMessage("Confirm serial number is required.", "warn"); + return; + } + + if (displaySerial !== rawSerial) { + setMessage("Serial number confirmation does not match.", "warn"); + return; + } + + if (!accessKey) { + setMessage("Enter the key.", "warn"); + return; + } + + if (gameVersion === "gc_v3" && !password) { + setMessage("Enter the GC password.", "warn"); + return; + } + + if (button) button.disabled = true; + setMessage("Registering key profile...", "pending"); + + try { + const res = await fetch("/api/keys/register", { + method: "POST", + credentials: "same-origin", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + game_version: gameVersion, + label, + serial_number: serial, + display_serial: displaySerial, + access_key: accessKey, + password, + }), + }); + + const data = await readJson(res); + if (!res.ok) { + throw new Error(data.error || `Register failed: ${res.status}`); + } + + form.reset(); + setupForm(form); + setMessage(`Saved. Sync status: ${data.sync_status}. Refresh status in a few seconds.`, "ok"); + await loadKeys(); + } catch (err) { + setMessage(err.message || String(err), "warn"); + } finally { + if (button) button.disabled = false; + } + } + + + async function revealAccessKey(id, button) { + if (!id || !button) return; + + const card = button.closest("article, .key-card, .key-row, .registered-key"); + const values = Array.from(card?.querySelectorAll(`[data-key-secret="${String(id).replace(/"/g, "\\\"")}"]`) || []); + if (!values.length) { + setMessage("Could not find this key on the page. Refresh and try again.", "warn"); + return; + } + + if (button.dataset.visible === "1") { + values.forEach(value => value.textContent = "••••••••••••"); + button.textContent = "show"; + button.dataset.visible = "0"; + return; + } + + button.disabled = true; + + try { + const res = await fetch(API.reveal(id), { + credentials: "same-origin", + headers: { "Accept": "application/json" }, + }); + + const data = await readJson(res); + if (!res.ok) { + throw new Error(data.error || `Reveal failed: ${res.status}`); + } + + values.forEach(value => { + const kind = value.dataset.keySecretKind || "access_key"; + value.textContent = data.key?.[kind] || ""; + }); + + button.textContent = "hide"; + button.dataset.visible = "1"; + } catch (err) { + setMessage(err.message || String(err), "warn"); + } finally { + button.disabled = false; + } + } + + async function deleteKey(id, button) { + if (!id) return; + + const card = button?.closest("article, .key-card, .key-row, .registered-key"); + const gameVersion = card?.dataset?.keyVersion || ""; + + let confirmMessage = "Delete this V2 key profile from this website account?"; + if (gameVersion === "pc_v2") { + confirmMessage += "\n\nPC V2 serial/key is forever tied to your local character saves. Please be sure you have your keys backed up before removing this key from your profile."; + } + + if (!window.confirm(confirmMessage)) return; + + button.disabled = true; + setMessage("Deleting key profile...", "pending"); + + try { + const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { + method: "DELETE", + credentials: "same-origin", + headers: { "Accept": "application/json" }, + }); + + const data = await readJson(res); + if (!res.ok) { + throw new Error(data.error || `Delete failed: ${res.status}`); + } + + setMessage(`Deleted. Sync status: ${data.sync_status}.`, "ok"); + await loadKeys(); + } catch (err) { + setMessage(err.message || String(err), "warn"); + button.disabled = false; + } + } + + function bind() { + if (bound) return true; + + const form = formEl(); + const list = listEl(); + if (!form || !list) return false; + + setupForm(form); + + const button = qs("button", form); + if (button) { + button.addEventListener("click", registerKey); + } + + form.addEventListener("submit", (ev) => { + ev.preventDefault(); + registerKey(); + }); + + list.addEventListener("click", (ev) => { + const revealButton = ev.target.closest(".key-reveal-button"); + if (revealButton) { + revealAccessKey(revealButton.dataset.keyId, revealButton); + return; + } + + const button = ev.target.closest(".key-delete-button"); + if (!button) return; + deleteKey(button.dataset.keyId, button); + }); + + bound = true; + + loadKeys().catch(err => { + list.innerHTML = ` +
+
+

Could not load keys

+

${esc(err.message || err)}

+
+
+ `; + }); + + return true; + } + + function start() { + [0, 500, 1500, 3000].forEach(ms => { + window.setTimeout(bind, ms); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", start); + } else { + start(); + } +})(); diff --git a/site/account-ready.html b/site/account-ready.html new file mode 100644 index 0000000..f4a9548 --- /dev/null +++ b/site/account-ready.html @@ -0,0 +1,151 @@ + + + + + + Account Ready · PSO Peeps + + + + + + + + +
+ + +
+ + + +
+ + +
+

Save Sync

+
+
US saves Current
+
EU saves Current
+
Last syncLess than 1 minute ago
+
+
+ +
+
+

Blue Burst Account

+ +

Blue Burst is limited to one account per website account. Password reset can come later.

+
+ +
+

Key Sync

+
+ Key sync: unknown + US: unknown + EU: unknown +
+
+ +
+

Register V2 / V3 Key

+

+ Add each DC V2, PC V2, or GC V3 key profile you use. You can register more than one key profile. +

+ +
+ +
+

Xbox V3 / Insignia

+

Linking an Xbox V3 profile with an Insignia token will live here.

+ Insignia support pending +
+
+ +
+

Registered V2 / V3 Keys

+

+ These key profiles are attached to this website account and mirrored to both US and EU. Deleting a key removes that game profile and its associated saves/backups after a confirmation step. +

+ +
+
+ +
Delete warning: deleting a key removes that V2 login profile from this website account and syncs the removal to US and EU.
+
+
+ + +
+ + + + diff --git a/site/account-save-sync.js b/site/account-save-sync.js new file mode 100644 index 0000000..b19688c --- /dev/null +++ b/site/account-save-sync.js @@ -0,0 +1,90 @@ +(() => { + const qs = (s, r = document) => r.querySelector; + const qsa = (s, r = document) => Array.from(r.querySelectorAll(s)); + + async function apiAccount() { + const res = await fetch("/api/account", { + credentials: "same-origin", + headers: { "Accept": "application/json" }, + }); + + const text = await res.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + return { save_sync: { status: "unknown", message: "Save sync status unavailable." } }; + } + + if (!res.ok) { + return { save_sync: { status: "unknown", message: data.error || data.message || "Save sync status unavailable." } }; + } + + return data; + } + + function value(obj, paths, fallback = "") { + for (const path of paths) { + let cur = obj; + let ok = true; + for (const part of path.split(".")) { + if (!cur || !(part in cur)) { + ok = false; + break; + } + cur = cur[part]; + } + if (ok && cur !== undefined && cur !== null && cur !== "") return cur; + } + return fallback; + } + + function normalizeStatus(s) { + const v = String(s || "").toLowerCase(); + if (["current", "synced", "ok", "ready"].includes(v)) return "Current"; + if (["pending", "syncing"].includes(v)) return "Pending"; + if (["missing", "unknown", "error"].includes(v)) return "Unknown"; + return s || "Unknown"; + } + + function dot(status) { + return normalizeStatus(status) === "Current" ? ' ' : ""; + } + + function ensureMessage(card) { + let msg = document.getElementById("save-sync-message"); + if (!msg) { + msg = document.createElement("p"); + msg.id = "save-sync-message"; + msg.className = "save-sync-message fine-print"; + card.appendChild(msg); + } + return msg; + } + + function render(saveSync) { + const card = document.querySelector(".save-sync-card"); + if (!card) return; + + const rows = Array.from(card.querySelectorAll(".region-row")); + const us = value(saveSync, ["regions.us.status", "us.status", "us_status"], "Unknown"); + const eu = value(saveSync, ["regions.eu.status", "eu.status", "eu_status"], "Unknown"); + const last = value(saveSync, ["last_sync", "last_synced_at", "updated_at"], "Unknown"); + + if (rows[0]) rows[0].querySelector("strong").innerHTML = `${dot(us)}${normalizeStatus(us)}`; + if (rows[1]) rows[1].querySelector("strong").innerHTML = `${dot(eu)}${normalizeStatus(eu)}`; + if (rows[2]) rows[2].querySelector("strong").textContent = last; + + const status = String(saveSync?.status || "").toLowerCase(); + const fallback = status === "synced" || status === "current" + ? "" + : "Save sync status unavailable."; + + ensureMessage(card).textContent = saveSync?.message || fallback; + } + + document.addEventListener("DOMContentLoaded", async () => { + const data = await apiAccount(); + render(data.save_sync || {}); + }); +})(); diff --git a/site/account-status.js b/site/account-status.js new file mode 100644 index 0000000..00d7569 --- /dev/null +++ b/site/account-status.js @@ -0,0 +1,58 @@ +(() => { + function addDebug(text) { + let box = document.getElementById("account-api-debug"); + if (!box) { + box = document.createElement("pre"); + box.id = "account-api-debug"; + box.style.whiteSpace = "pre-wrap"; + box.style.marginTop = "1rem"; + box.style.color = "#ffd2d2"; + box.style.fontSize = "12px"; + const hero = document.querySelector(".account-hero-card") || document.body; + hero.appendChild(box); + } + box.textContent = text; + } + + function replaceText(root, from, to) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + if (node.nodeValue.includes(from)) node.nodeValue = node.nodeValue.replaceAll(from, to); + } + } + + document.addEventListener("DOMContentLoaded", async () => { + try { + const res = await fetch("/api/account", { + credentials: "same-origin", + headers: { "Accept": "application/json" }, + }); + + const text = await res.text(); + addDebug(`HTTP ${res.status}\n${text}`); + + if (!res.ok) return; + + const data = JSON.parse(text); + const email = data.email || {}; + const addr = email.address || email.email || ""; + const verified = Boolean(email.verified || email.verified_at || email.email_verified_at); + const bbReady = Boolean(data.bb && (data.bb.ready || data.bb.created)); + + const hero = document.querySelector(".account-hero-card") || document.body; + + if (data.user?.username) { + const title = document.querySelector("#account-title"); + if (title) title.textContent = data.user.username; + } + + if (addr) replaceText(hero, "No email address set", addr); + if (verified) replaceText(hero, "VERIFICATION NEEDED", "VERIFIED"); + if (bbReady) replaceText(hero, "BB PASSWORD NEEDED", "BB ACCOUNT READY"); + } catch (err) { + addDebug(String(err && err.stack || err)); + } + }); +})(); diff --git a/site/account-unverified.html b/site/account-unverified.html new file mode 100644 index 0000000..ab1ac0a --- /dev/null +++ b/site/account-unverified.html @@ -0,0 +1,108 @@ + + + + + + Verify Email · PSO Peeps + + + + + + + + +
+ + +
+ +
+ + + +
+ + +
+
+

Email Verification

+

Check your inbox for the PSO Peeps verification link. The game account provisioner stays disabled until verification is complete.

+ +
+ +
+

Next Steps

+
    +
  1. 1Verify emailRequired before game provisioning.
  2. +
  3. 2Set Player PasswordYour Blue Burst / game-side password.
  4. +
  5. 3Link serialsDC V2, PC V2, and GC V3 keys attach to the same mirrored account.
  6. +
+ View verified state mockup +
+
+
+ + +
+ + diff --git a/site/account.html b/site/account.html new file mode 100644 index 0000000..d54d084 --- /dev/null +++ b/site/account.html @@ -0,0 +1,163 @@ + + + + + + Account · PSO Peeps + + + + + + + + +
+ + +
+
+ + + +
+ + +
+

Save Sync

+
+
US saves Current
+
EU saves Current
+
Last syncLess than 1 minute ago
+
+
+ +
+
+

Blue Burst Account

+

+ No Blue Burst account has been created yet. Set one BB password here; your BB username will be chuudoku on both US and EU. +

+ +

Blue Burst is limited to one account per website account.

+
+ +
+

Key Sync

+
+ Key sync: unknown + US: unknown + EU: unknown +
+
+ +
+

Register V2 / V3 Key

+

+ Add each DC V2, PC V2, or GC V3 key profile you use. You can register more than one key profile. +

+ +
+ +
+

Xbox V3 / Insignia

+

Linking an Xbox V3 profile with an Insignia token will live here.

+ Insignia support pending +
+
+ +
+

Registered V2 / V3 Keys

+

+ These key profiles are attached to this website account and mirrored to both US and EU. Deleting a key removes that game profile and its associated saves/backups after a confirmation step. +

+
Delete warning: deleting a key removes that V2 login profile from this website account and syncs the removal to US and EU.
+
+
+ + +
+ + + + diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..2a9de21 --- /dev/null +++ b/site/app.js @@ -0,0 +1,614 @@ +(() => { + const API_BASE = "/api"; + + async function api(path, options = {}) { + const res = await fetch(`${API_BASE}${path}`, { + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + ...(options.headers || {}), + }, + ...options, + }); + + let data = {}; + try { + data = await res.json(); + } catch (_) {} + + if (!res.ok) { + throw new Error(data.error || `Request failed: ${res.status}`); + } + + return data; + } + + function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[ch])); + } + + function replaceText(root, from, to) { + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const nodes = []; + + while (walker.nextNode()) { + if (walker.currentNode.nodeValue.includes(from)) { + nodes.push(walker.currentNode); + } + } + + for (const node of nodes) { + node.nodeValue = node.nodeValue.replaceAll(from, to); + } + } + + function buildLoginForm() { + const form = document.createElement("form"); + form.className = "top-account-form"; + form.action = "#"; + form.method = "post"; + form.setAttribute("aria-label", "Account login or registration"); + + let mode = "login"; + + function render() { + form.classList.toggle("is-register", mode === "register"); + form.classList.toggle("is-login", mode !== "register"); + + if (mode === "register") { + form.innerHTML = ` + + + + + + + + + + + +
+ `; + return; + } + + form.innerHTML = ` + + + + + + + + + +
+ `; + } + + function setMessage(text, kind = "error") { + const message = form.querySelector(".auth-message"); + if (!message) return; + + message.textContent = text || ""; + message.classList.remove("is-error", "is-ok"); + message.classList.add(kind === "ok" ? "is-ok" : "is-error"); + } + + form.addEventListener("click", (event) => { + const button = event.target.closest("button[data-action]"); + if (!button) return; + + const action = button.dataset.action; + + if (action === "show-register") { + event.preventDefault(); + mode = "register"; + render(); + form.querySelector('input[name="username"]')?.focus(); + return; + } + + if (action === "cancel-register") { + event.preventDefault(); + mode = "login"; + render(); + form.querySelector('input[name="username"]')?.focus(); + } + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + + const submitter = event.submitter; + const action = submitter?.dataset.action || "login"; + + const username = form.querySelector('input[name="username"]')?.value.trim() || ""; + const password = form.querySelector('input[name="password"]')?.value || ""; + const email = form.querySelector('input[name="email"]')?.value.trim() || ""; + + setMessage(""); + + try { + if (action === "create-account") { + await api("/register", { + method: "POST", + body: JSON.stringify({ username, email, password }), + }); + + setMessage("Account created. Check your email to verify your account.", "ok"); + window.setTimeout(() => { + window.location.href = "account-ready.html"; + }, 900); + return; + } + + await api("/login", { + method: "POST", + body: JSON.stringify({ username, password }), + }); + + window.location.href = "account-ready.html"; + } catch (err) { + setMessage(err.message); + } + }); + + render(); + return form; + } + + + function buildSignedInStatus(user) { + const wrap = document.createElement("div"); + wrap.className = "top-account-status"; + + wrap.innerHTML = ` + + Signed in as ${escapeHtml(user.username)} + + `; + + wrap.querySelector("[data-logout]").addEventListener("click", async () => { + await api("/logout", { method: "POST" }); + window.location.href = "index.html"; + }); + + return wrap; + } + + function renderHeader(user) { + const header = document.querySelector(".site-header--accountline"); + if (!header) return; + + header.querySelectorAll(".top-account-form, .top-account-status").forEach((el) => el.remove()); + + if (user) { + header.appendChild(buildSignedInStatus(user)); + } else { + header.appendChild(buildLoginForm()); + } + } + + function findCardByHeading(text) { + const needle = text.toLowerCase(); + + for (const heading of document.querySelectorAll("h1, h2, h3, h4")) { + if (!heading.textContent.toLowerCase().includes(needle)) continue; + + const card = heading.closest(".setup-card, .card, section, article, div"); + if (card) return card; + } + + return null; + } + + function findBBCard() { + return document.querySelector("[data-bb-card], .setup-card--bb") || findCardByHeading("Blue Burst"); + } + + function badge(text, kind = "") { + const cls = kind ? `badge ${kind}` : "badge"; + return `${escapeHtml(text)}`; + } + + function updateAccountStatusBadges(accountData) { + if (!accountData?.bb) return; + + const bb = accountData.bb; + let label = "BB PASSWORD NEEDED"; + let warn = true; + + if (bb.created && bb.ready) { + label = "ACCOUNT READY"; + warn = false; + } else if (bb.created || bb.account_id) { + label = "BB SYNC PENDING"; + warn = true; + } + + for (const badge of document.querySelectorAll(".status-badges .badge")) { + const text = badge.textContent.trim().toUpperCase(); + + if (text.includes("BB ")) { + badge.textContent = label; + badge.classList.toggle("badge--warn", warn); + } + } + } + + function renderBBCard(accountData) { + const card = findBBCard(); + if (!card || !accountData?.user) return; + + const user = accountData.user; + const bb = accountData.bb || { + created: false, + ready: false, + sync_status: "missing", + username: user.username, + account_id: null, + }; + + const bbCreated = !!(bb.created || bb.account_id); + const bbReady = !!bb.ready; + const bbBadgeLabel = bbReady ? "ACCOUNT READY" : "BB SYNC PENDING"; + + if (bbCreated) { + card.innerHTML = ` +
+
+

Blue Burst

+

Blue Burst Account

+
+ ${badge(bbBadgeLabel, bbReady ? "" : "badge--warn")} +
+ + + + + `; + } else { + card.innerHTML = ` +
+
+

Blue Burst

+

Create Blue Burst Account

+
+ ${badge("BB PASSWORD NEEDED", "badge--warn")} +
+ + + + + `; + } + + const form = card.querySelector(".bb-account-form"); + if (!form) return; + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + + const message = form.querySelector(".bb-message"); + const password = form.querySelector('input[name="password"]').value; + const confirmPassword = form.querySelector('input[name="confirm_password"]').value; + const action = form.dataset.bbAction; + + message.textContent = ""; + message.classList.remove("is-error", "is-ok"); + + const endpoint = action === "change-password" ? "/bb/change-password" : "/bb/create"; + + try { + const result = await api(endpoint, { + method: "POST", + body: JSON.stringify({ + password, + confirm_password: confirmPassword, + }), + }); + + message.textContent = result?.bb?.ready + ? "Saved and synced." + : "Saved. Sync queued; refresh shortly."; + message.classList.add("is-ok"); + + const fresh = await api("/account"); + renderBBCard(fresh); + } catch (err) { + message.textContent = err.message; + message.classList.add("is-error"); + } + }); + } + + + function renderAccountEmail(accountData) { + const hero = document.querySelector(".account-hero-card"); + const title = document.querySelector("#account-title"); + if (!hero || !title) return; + + for (const p of Array.from(hero.querySelectorAll("p"))) { + if (p.textContent.includes("Manage your Blue Burst login")) { + p.remove(); + } + } + + let box = hero.querySelector("#account-email-summary"); + if (!box) { + box = document.createElement("div"); + box.id = "account-email-summary"; + box.className = "account-email-summary"; + title.insertAdjacentElement("afterend", box); + } + + const email = accountData?.email || {}; + const emailText = email.email || "No email address set"; + const verified = !!email.verified; + + box.innerHTML = ` + + + + + + + + `; + + const emailButton = box.querySelector(".account-email-update"); + const passwordButton = box.querySelector(".account-password-update"); + const emailForm = box.querySelector(".account-email-form"); + const passwordForm = box.querySelector(".account-password-form"); + const emailCancel = box.querySelector("[data-email-cancel]"); + const passwordCancel = box.querySelector("[data-password-cancel]"); + const message = box.querySelector(".account-control-message"); + const emailInput = box.querySelector("#account-email-input"); + + function setMessage(text, kind = "") { + message.textContent = text || ""; + message.className = "account-control-message"; + if (kind) message.classList.add(kind); + } + + function hideForms() { + emailForm.hidden = true; + passwordForm.hidden = true; + } + + emailButton.addEventListener("click", () => { + hideForms(); + emailForm.hidden = false; + emailInput.value = email.email || ""; + setMessage(""); + emailInput.focus(); + }); + + passwordButton.addEventListener("click", () => { + hideForms(); + passwordForm.hidden = false; + setMessage(""); + passwordForm.querySelector('input[name="current_password"]')?.focus(); + }); + + emailCancel.addEventListener("click", () => { + emailForm.hidden = true; + setMessage(""); + }); + + passwordCancel.addEventListener("click", () => { + passwordForm.hidden = true; + passwordForm.reset(); + setMessage(""); + }); + + emailForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const newEmail = emailInput.value.trim(); + setMessage(""); + + try { + const result = await api("/email/start", { + method: "POST", + body: JSON.stringify({ email: newEmail }), + }); + + const sent = result?.verification?.sent; + const debugLink = result?.verification?.debug_link; + + setMessage(sent ? "Verification email sent. Check your inbox." : "Verification link created.", "is-ok"); + + if (debugLink) { + const a = document.createElement("a"); + a.href = debugLink; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + a.textContent = " Open verification link"; + message.appendChild(a); + } + + const updated = await api("/account"); + renderAccountEmail(updated); + } catch (err) { + setMessage(err.message || String(err), "is-error"); + } + }); + + passwordForm.addEventListener("submit", async (event) => { + event.preventDefault(); + + const currentPassword = passwordForm.querySelector('input[name="current_password"]').value; + const newPassword = passwordForm.querySelector('input[name="password"]').value; + const confirmPassword = passwordForm.querySelector('input[name="confirm_password"]').value; + + setMessage(""); + + try { + await api("/password/change", { + method: "POST", + body: JSON.stringify({ + current_password: currentPassword, + password: newPassword, + confirm_password: confirmPassword, + }), + }); + + passwordForm.reset(); + passwordForm.hidden = true; + setMessage("Password changed.", "is-ok"); + } catch (err) { + setMessage(err.message || String(err), "is-error"); + } + }); + } + + + function renderAccountPage(user, accountData) { + const main = document.querySelector(".account-layout"); + if (!main) return; + + if (!user) { + main.innerHTML = ` + + `; + return; + } + + replaceText(document.body, "chuudoku", user.username); + + const title = document.querySelector("#account-title"); + if (title) title.textContent = user.username; + + renderAccountEmail(accountData); + updateAccountStatusBadges(accountData); + renderBBCard(accountData); + } + + async function boot() { + let user = null; + let accountData = null; + + try { + const me = await api("/me"); + if (me.authenticated) { + user = me.user; + } + } catch (_) {} + + if (user) { + try { + accountData = await api("/account"); + } catch (_) { + accountData = { + authenticated: true, + user, + bb: { + ready: false, + username: user.username, + account_id: null, + }, + v2_v3_keys: [], + }; + } + } + + renderHeader(user); + renderAccountPage(user, accountData); + } + + document.addEventListener("DOMContentLoaded", boot); +})(); diff --git a/site/bestiary.html b/site/bestiary.html new file mode 100644 index 0000000..dd5a9bb --- /dev/null +++ b/site/bestiary.html @@ -0,0 +1,94 @@ + + + + + + Bestiary - PSO Peeps + + + + + + + + +
+ + +
+ + + +
+
+

Bestiary

+
+ + + + + + + + +
+
+ +
+
Bestiary data placeholder
+
+
+ + +
+ + + diff --git a/site/drops.html b/site/drops.html new file mode 100644 index 0000000..4316214 --- /dev/null +++ b/site/drops.html @@ -0,0 +1,93 @@ + + + + + + Drops - PSO Peeps + + + + + + + + +
+ + +
+ + + +
+
+

Drops

+
+ + + + + + + + +
+
+ +
+
Drop table placeholder
+
+
+ + +
+ + + diff --git a/site/generated/.gitkeep b/site/generated/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/site/hero-cycle.js b/site/hero-cycle.js new file mode 100644 index 0000000..c9143c2 --- /dev/null +++ b/site/hero-cycle.js @@ -0,0 +1 @@ +// disabled: using static hero image diff --git a/site/hero.jpg b/site/hero.jpg new file mode 100644 index 0000000..2098660 Binary files /dev/null and b/site/hero.jpg differ diff --git a/site/home-leaderboard.js b/site/home-leaderboard.js new file mode 100644 index 0000000..c5c5e3f --- /dev/null +++ b/site/home-leaderboard.js @@ -0,0 +1,95 @@ +(() => { + "use strict"; + + function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[ch])); + } + + function fmtNumber(value) { + const n = Number(value || 0); + return Number.isFinite(n) ? n.toLocaleString() : "0"; + } + + function statusText(row) { + if (row.Alive === false || row.alive === false) return "Dead"; + return "Alive"; + } + + function fitName(value, width) { + const s = String(value || "—").trimEnd(); + if (s.length <= width) return s.padEnd(width, " "); + return s.slice(0, Math.max(0, width - 1)) + "…"; + } + + function fitPoints(value, width) { + return `${fmtNumber(value)} pts`.padStart(width, " "); + } + + async function loadHomeHardcoreLeaderboard() { + const list = document.querySelector("#home-hardcore-leaderboard-body"); + if (!list) return; + + const cacheBucket = Math.floor(Date.now() / 300000); + const urls = [ + `/generated/hardcore-leaderboard-points.json?v=${cacheBucket}`, + "/hardcore/leaderboard/points", + ]; + + let rows = null; + let lastError = null; + + for (const url of urls) { + try { + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) { + lastError = new Error(`${url}: HTTP ${res.status}`); + continue; + } + + const data = await res.json(); + rows = Array.isArray(data) ? data : (data.rows || []); + break; + } catch (err) { + lastError = err; + } + } + + if (!rows) { + list.innerHTML = `
  • !${escapeHtml(lastError?.message || "Unable to load.")}
  • `; + return; + } + + const top = rows.slice(0, 5); + + if (!top.length) { + list.innerHTML = `
  • 1.
  • `; + return; + } + + list.innerHTML = top.map((row, idx) => { + const rank = idx + 1; + const name = row.PlayerName || row.CharacterName || row.character_name || "—"; + const points = row.Points ?? row.TotalPoints ?? 0; + const status = statusText(row); + + const line = `${fitName(name, 12)} ${fitPoints(points, 7)} ${status}`; + + return `
  • + ${rank}. + ${escapeHtml(line)} +
  • `; + }).join(""); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", loadHomeHardcoreLeaderboard); + } else { + loadHomeHardcoreLeaderboard(); + } +})(); diff --git a/site/icons/.keep b/site/icons/.keep new file mode 100644 index 0000000..e69de29 diff --git a/site/icons/bluesky.png b/site/icons/bluesky.png new file mode 100644 index 0000000..7423cf7 Binary files /dev/null and b/site/icons/bluesky.png differ diff --git a/site/icons/discord.png b/site/icons/discord.png new file mode 100644 index 0000000..a4d8dff Binary files /dev/null and b/site/icons/discord.png differ diff --git a/site/icons/mastodon.png b/site/icons/mastodon.png new file mode 100644 index 0000000..7df2b5b Binary files /dev/null and b/site/icons/mastodon.png differ diff --git a/site/images/hero.jpg b/site/images/hero.jpg new file mode 100644 index 0000000..2098660 Binary files /dev/null and b/site/images/hero.jpg differ diff --git a/site/images/hero2.jpg b/site/images/hero2.jpg new file mode 100644 index 0000000..6f65d3a Binary files /dev/null and b/site/images/hero2.jpg differ diff --git a/site/images/hero3.jpg b/site/images/hero3.jpg new file mode 100644 index 0000000..d88d42f Binary files /dev/null and b/site/images/hero3.jpg differ diff --git a/site/images/hero4.jpg b/site/images/hero4.jpg new file mode 100644 index 0000000..ccde54d Binary files /dev/null and b/site/images/hero4.jpg differ diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..935531d --- /dev/null +++ b/site/index.html @@ -0,0 +1,144 @@ + + + + + + PSO Peeps + + + + + + + + +
    + + +
    + +
    + + + +
    +
    +

    + PSO Peeps is a private multi-platform Phantasy Star Online server supporting DC V2, PC V2, GC V3, and Blue Burst. + Our ships feature XP boosts, optional experimental crossplay between all versions, increased difficulty tiers, and a hardcore mode. +

    + +

    Server Status

    + +
    +
    US Server
    + +
    Alis0 Players
    +
    V20 Players
    +
    V30 Players
    +
    BB0 Players
    + +
    Abion
    +
    HC0 Players
    + +
    EU Server
    + +
    Palma0 Players
    +
    V20 Players
    +
    V30 Players
    +
    BB0 Players
    + +
    Aiedo
    +
    HC0 Players
    + +
    PSP Ship
    +
    PSP10 Players
    +
    PSP2i0 Players
    +
    +
    + + +
    + + +
    + + + + diff --git a/site/leaderboards.html b/site/leaderboards.html new file mode 100644 index 0000000..2026c73 --- /dev/null +++ b/site/leaderboards.html @@ -0,0 +1,88 @@ + + + + + + Leaderboards - PSO Peeps + + + + + + + + +
    + + +
    + + + +
    +
    +

    Leaderboards

    +
    + + + +
    + + +
    +
    +
    + +
    +
    Leaderboard data placeholder
    +
    +
    + + +
    + + + diff --git a/site/placeholder-pages.js b/site/placeholder-pages.js new file mode 100644 index 0000000..4d5dee2 --- /dev/null +++ b/site/placeholder-pages.js @@ -0,0 +1,313 @@ +(() => { + "use strict"; + + function qs(sel) { + return document.querySelector(sel); + } + + function setText(id, text) { + const el = qs(id); + if (el) el.textContent = text; + } + + const leaderboardState = { + rows: [], + sortKey: "points", + sortDir: "desc", + page: 1, + pageSize: 10, + loading: false, + loaded: false, + error: null, + }; + + const leaderboardColumns = [ + { key: "rank", label: "Rank", numeric: true }, + { key: "name", label: "Player Name" }, + { key: "points", label: "Points", numeric: true }, + { key: "status", label: "Status" }, + { key: "class", label: "Class" }, + { key: "secid", label: "SecID" }, + { key: "kills", label: "Kills", numeric: true }, + { key: "playtime", label: "Playtime", numeric: true }, + ]; + + function escapeHtml(value) { + return String(value ?? "").replace(/[&<>"']/g, (ch) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[ch])); + } + + function fmtNumber(value) { + const n = Number(value || 0); + return Number.isFinite(n) ? n.toLocaleString() : "0"; + } + + function fmtPlaytime(seconds) { + const total = Number(seconds || 0); + if (!Number.isFinite(total) || total <= 0) return "0h"; + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + if (hours <= 0) return `${minutes}m`; + return minutes ? `${hours}h ${minutes}m` : `${hours}h`; + } + + function normalizeLeaderboardRow(row, index) { + return { + originalRank: index + 1, + name: row.PlayerName || row.CharacterName || row.character_name || "", + points: Number(row.Points ?? row.TotalPoints ?? 0), + class: row.Class || row.character_class || "", + secid: row.SecID || row.section_id || "", + kills: Number(row.Kills ?? row.TotalKills ?? row.total_enemies_killed ?? 0), + status: (row.Alive === false || row.alive === false) ? "Dead" : "Alive", + playtime: Number(row.PlayTimeSeconds ?? row.play_time_seconds ?? 0), + }; + } + + async function fetchHardcoreLeaderboard() { + leaderboardState.loading = true; + leaderboardState.error = null; + renderHardcoreLeaderboard(); + + const cacheBucket = Math.floor(Date.now() / 300000); + const urls = [ + `/generated/hardcore-leaderboard-points.json?v=${cacheBucket}`, + "/api/hardcore/leaderboard/points", + "/hardcore/leaderboard/points", + ]; + + let lastError = null; + + for (const url of urls) { + try { + const res = await fetch(url, { credentials: "same-origin" }); + if (!res.ok) { + lastError = new Error(`${url}: HTTP ${res.status}`); + continue; + } + + const data = await res.json(); + const rows = Array.isArray(data) ? data : (data.rows || data.characters || []); + leaderboardState.rows = rows.map(normalizeLeaderboardRow); + leaderboardState.loaded = true; + leaderboardState.loading = false; + leaderboardState.page = 1; + renderHardcoreLeaderboard(); + return; + } catch (err) { + lastError = err; + } + } + + leaderboardState.loading = false; + leaderboardState.error = lastError ? String(lastError.message || lastError) : "Unable to load leaderboard."; + renderHardcoreLeaderboard(); + } + + function sortedLeaderboardRows() { + const key = leaderboardState.sortKey; + const dir = leaderboardState.sortDir === "asc" ? 1 : -1; + const col = leaderboardColumns.find((c) => c.key === key); + const numeric = !!col?.numeric; + + return [...leaderboardState.rows].sort((a, b) => { + let av = key === "rank" ? a.originalRank : a[key]; + let bv = key === "rank" ? b.originalRank : b[key]; + + if (numeric) { + av = Number(av || 0); + bv = Number(bv || 0); + return (av - bv) * dir; + } + + return String(av || "").localeCompare(String(bv || "")) * dir; + }); + } + + function renderHardcoreLeaderboard() { + const box = qs("#leaderboard-placeholder"); + if (!box) return; + + if (leaderboardState.loading) { + box.innerHTML = `
    Loading Hardcore leaderboard...
    `; + return; + } + + if (leaderboardState.error) { + box.innerHTML = `
    ${escapeHtml(leaderboardState.error)}
    `; + return; + } + + if (!leaderboardState.loaded) { + box.innerHTML = `
    Leaderboard data will load here.
    `; + return; + } + + const rows = sortedLeaderboardRows(); + const pageSize = leaderboardState.pageSize; + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + leaderboardState.page = Math.min(Math.max(1, leaderboardState.page), totalPages); + + const start = (leaderboardState.page - 1) * pageSize; + const pageRows = rows.slice(start, start + pageSize); + + const head = leaderboardColumns.map((col) => { + const active = leaderboardState.sortKey === col.key; + const marker = active ? (leaderboardState.sortDir === "asc" ? " ▲" : " ▼") : ""; + return ``; + }).join(""); + + const body = pageRows.map((row, idx) => { + const rank = start + idx + 1; + return ` + ${rank} + ${escapeHtml(row.name)} + ${fmtNumber(row.points)} + ${escapeHtml(row.status)} + ${escapeHtml(row.class || "—")} + ${escapeHtml(row.secid || "—")} + ${fmtNumber(row.kills)} + ${escapeHtml(fmtPlaytime(row.playtime))} + `; + }).join(""); + + box.innerHTML = ` +
    + + ${head} + ${body || ``} +
    No Hardcore leaderboard rows yet.
    +
    +
    + + Page ${leaderboardState.page} of ${totalPages} + +
    + `; + + box.querySelectorAll("[data-sort]").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.getAttribute("data-sort"); + if (leaderboardState.sortKey === key) { + leaderboardState.sortDir = leaderboardState.sortDir === "asc" ? "desc" : "asc"; + } else { + leaderboardState.sortKey = key; + leaderboardState.sortDir = key === "name" || key === "class" || key === "secid" || key === "status" ? "asc" : "desc"; + } + leaderboardState.page = 1; + renderHardcoreLeaderboard(); + }); + }); + + qs("#leaderboard-prev")?.addEventListener("click", () => { + leaderboardState.page -= 1; + renderHardcoreLeaderboard(); + }); + + qs("#leaderboard-next")?.addEventListener("click", () => { + leaderboardState.page += 1; + renderHardcoreLeaderboard(); + }); + } + + function updateLeaderboards() { + const mode = qs("#leaderboard-mode")?.value || "hardcore"; + const pageSizeWrap = qs("#leaderboard-page-size-wrap"); + + if (pageSizeWrap) pageSizeWrap.hidden = mode !== "hardcore"; + + if (mode === "hardcore") { + if (!leaderboardState.loaded && !leaderboardState.loading) { + fetchHardcoreLeaderboard(); + } else { + renderHardcoreLeaderboard(); + } + return; + } + + const labels = { + cmode: "CMode leaderboard placeholder.", + "hardcore-cmode": "Hardcore CMode leaderboard placeholder.", + }; + setText("#leaderboard-placeholder", labels[mode] || "Leaderboard data will load here."); + } + + function updateDrops() { + const mode = qs("#drops-mode")?.value || "peeps"; + const version = qs("#drops-version")?.value || "v1"; + const versionWrap = qs("#drops-version-wrap"); + const epWrap = qs("#drops-episode-wrap"); + + if (!versionWrap || !epWrap) return; + + if (mode === "hardcore") { + versionWrap.hidden = true; + epWrap.hidden = false; + setText("#drops-placeholder", "Hardcore drop table placeholder."); + return; + } + + versionWrap.hidden = false; + epWrap.hidden = version !== "v4"; + setText("#drops-placeholder", `Peeps ${version.toUpperCase()} drop table placeholder.`); + } + + function updateBestiaryEpisodes(version) { + const ep = qs("#bestiary-episode"); + if (!ep) return; + + const eps = version === "v4" + ? [["ep1", "Episode 1"], ["ep2", "Episode 2"], ["ep4", "Episode 4"]] + : [["ep1", "Episode 1"], ["ep2", "Episode 2"]]; + + ep.innerHTML = eps.map(([value, label]) => ``).join(""); + } + + function updateBestiary() { + const version = qs("#bestiary-version")?.value || "v2"; + const epWrap = qs("#bestiary-episode-wrap"); + const bpWrap = qs("#bestiary-bp-wrap"); + + if (!epWrap || !bpWrap) return; + + epWrap.hidden = version === "v2"; + bpWrap.hidden = !(version === "v2" || version === "v4"); + + if (version === "v3" || version === "v4") { + updateBestiaryEpisodes(version); + } + + setText("#bestiary-placeholder", `${version.toUpperCase()} bestiary placeholder.`); + } + + function bind() { + qs("#leaderboard-mode")?.addEventListener("change", updateLeaderboards); + qs("#leaderboard-page-size")?.addEventListener("change", (event) => { + leaderboardState.pageSize = Number(event.target.value || 10); + leaderboardState.page = 1; + renderHardcoreLeaderboard(); + }); + + qs("#drops-mode")?.addEventListener("change", updateDrops); + qs("#drops-version")?.addEventListener("change", updateDrops); + + qs("#bestiary-version")?.addEventListener("change", updateBestiary); + qs("#bestiary-episode")?.addEventListener("change", updateBestiary); + qs("#bestiary-bp")?.addEventListener("change", updateBestiary); + + updateLeaderboards(); + updateDrops(); + updateBestiary(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/site/style.css b/site/style.css new file mode 100644 index 0000000..a450508 --- /dev/null +++ b/site/style.css @@ -0,0 +1,2739 @@ +:root { + color-scheme: dark; + --page-bg: #0a0a0a; + --panel-bg: #181818; + --panel-bg-soft: #1a1a1a; + --panel-border: #2a2a2a; + --input-bg: #111111; + --input-border: #333333; + --text: #e0e0e0; + --text-soft: #b9b9b9; + --muted: #888888; + --muted-dark: #5f5f5f; + --accent: #e05050; + --accent-hover: #ff6b6b; + --shadow: 0 18px 50px rgba(0, 0, 0, 0.28); + --radius: 8px; + --gap: 1rem; +} + +* { + box-sizing: border-box; +} + +html { + min-width: 320px; + background: var(--page-bg); +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 50% -20%, rgba(224, 80, 80, 0.12), transparent 34rem), + linear-gradient(180deg, #111111 0%, var(--page-bg) 38rem); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 16px; + line-height: 1.55; +} + +a { + color: inherit; + text-decoration: none; +} + +a:focus-visible, +button:focus-visible, +input:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 3px; +} + +.site-shell { + width: min(1120px, calc(100% - 2rem)); + margin: 0 auto; + padding: 1rem 0 2rem; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 5rem; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.85rem; +} + +.brand-logo { + width: 48px; + height: 48px; + object-fit: contain; + border-radius: 50%; + background: #101010; + border: 1px solid var(--panel-border); +} + +.brand-name { + font-size: clamp(1.35rem, 3vw, 2rem); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + line-height: 1; +} + +.hero { + margin-bottom: var(--gap); +} + +.hero-image { + min-height: clamp(160px, 31.25vw, 350px); + aspect-ratio: 16 / 5; + width: 100%; + border: 1px solid var(--panel-border); + border-radius: var(--radius); + background: + linear-gradient(90deg, rgba(10, 10, 10, 0.2), rgba(10, 10, 10, 0.68)), + radial-gradient(circle at 20% 30%, rgba(224, 80, 80, 0.24), transparent 22rem), + url("images/hero.jpg") center center / cover no-repeat, + #121212; + box-shadow: var(--shadow); + overflow: hidden; +} + +.nav-bar { + display: flex; + justify-content: space-evenly; + align-items: center; + gap: 0.75rem; + margin-bottom: var(--gap); + padding: 0.85rem 1rem; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 999px; + box-shadow: var(--shadow); +} + +.nav-bar a { + color: var(--text-soft); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.18em; + text-transform: uppercase; + white-space: nowrap; + transition: color 140ms ease, transform 140ms ease; +} + +.nav-bar a:hover { + color: var(--accent-hover); + transform: translateY(-1px); +} + +.main-grid { + display: grid; + grid-template-columns: 55fr 45fr; + gap: var(--gap); + align-items: start; +} + +.right-stack { + display: grid; + gap: var(--gap); +} + +.card, +.footer-bar { + background: var(--panel-bg-soft); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.card { + padding: 1.5rem; +} + +.section-title { + margin: 0 0 1rem; + color: var(--muted); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.2em; + line-height: 1.35; + text-transform: uppercase; +} + +.server-card { + min-height: 100%; +} + +.server-blurb { + margin: 0 0 1.5rem; + max-width: 60ch; + color: var(--text-soft); +} + +.status-list { + display: grid; + gap: 0.25rem; + padding: 1rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + +.status-row { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 1rem; + color: var(--text-soft); +} + +.status-parent { + margin-top: 0.75rem; + color: var(--text); + font-weight: 700; +} + +.status-parent:first-child { + margin-top: 0; +} + +.status-child { + padding-left: 1.25rem; + color: #a9a9a9; +} + +.account-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--gap); +} + +.account-form { + display: grid; + gap: 0.55rem; +} + +.account-form h3 { + margin: 0 0 0.25rem; + color: var(--text); + font-size: 0.9rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-form label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.optional { + color: var(--muted-dark); + font-weight: 600; + letter-spacing: 0.08em; +} + +input { + width: 100%; + min-height: 2.35rem; + padding: 0.5rem 0.65rem; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 4px; + color: var(--text); + font: inherit; +} + +input:hover, +input:focus { + border-color: #464646; +} + +button { + width: 100%; + min-height: 2.45rem; + margin-top: 0.25rem; + padding: 0.55rem 0.75rem; + cursor: pointer; + background: #242424; + border: 1px solid #383838; + border-radius: 4px; + color: var(--text); + font: inherit; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + transition: background 140ms ease, border-color 140ms ease, color 140ms ease; +} + +button:hover { + background: #2a1a1a; + border-color: rgba(224, 80, 80, 0.65); + color: var(--accent-hover); +} + +.leaderboard-list { + display: grid; + gap: 0.4rem; + margin: 0; + padding: 0; + list-style: none; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; +} + +.leaderboard-list li { + display: grid; + grid-template-columns: 2rem 1fr; + gap: 0.75rem; + padding: 0.25rem 0; + color: var(--text-soft); + border-bottom: 1px solid rgba(255, 255, 255, 0.045); +} + +.leaderboard-list li:last-child { + border-bottom: 0; +} + +.rank { + color: var(--muted); +} + +.footer-bar { + margin-top: var(--gap); + padding: 1.25rem 1rem; + text-align: center; +} + +.social-links { + display: flex; + justify-content: center; + align-items: center; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.65rem; +} + +.social-links a { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--text-soft); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + transition: color 140ms ease; +} + +.social-links a:hover { + color: var(--accent-hover); +} + +.social-links img { + width: 24px; + height: 24px; + object-fit: contain; +} + +.footer-bar p { + margin: 0; + color: var(--muted-dark); + font-size: 0.8rem; +} + +@media (max-width: 900px) { + .account-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 700px) { + .site-shell { + width: min(100% - 1rem, 1120px); + } + + .site-header { + min-height: 4.5rem; + } + + .brand-logo { + width: 42px; + height: 42px; + } + + .main-grid { + grid-template-columns: 1fr; + } + + .nav-bar { + display: grid; + grid-template-columns: 1fr; + justify-items: center; + border-radius: var(--radius); + } + + .card { + padding: 1.15rem; + } + + .status-list { + padding: 0.85rem; + font-size: 0.86rem; + } + + .status-row { + gap: 0.75rem; + } +} + +.account-indicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(24, 24, 24, 0.88); + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--text-soft); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.account-indicator a:hover { + color: var(--accent-hover); +} + +.status-dot, +.ready-dot { + display: inline-block; + width: 0.55rem; + height: 0.55rem; + border-radius: 999px; + background: var(--accent); + box-shadow: 0 0 0.75rem rgba(224, 80, 80, 0.35); +} + +.account-indicator--guest .status-dot { + background: #777777; + box-shadow: none; +} + +.hero--slim .hero-image { + min-height: clamp(110px, 18vw, 220px); + aspect-ratio: 16 / 4; +} + +.form-note, +.card-copy, +.fine-print { + margin: 0; + color: var(--text-soft); +} + +.form-note { + font-size: 0.78rem; + line-height: 1.45; +} + +.fine-print { + margin-top: 0.9rem; + color: var(--muted); + font-size: 0.82rem; +} + +.small-link { + display: inline-flex; + width: fit-content; + margin-top: 0.35rem; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.small-link:hover { + color: var(--accent-hover); +} + +.account-layout { + display: grid; + gap: var(--gap); +} + +.account-hero-card { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 1.5rem; + align-items: center; +} + +.eyebrow { + margin: 0 0 0.35rem; + color: var(--accent); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.account-hero-card h1 { + margin: 0 0 0.5rem; + font-size: clamp(1.6rem, 4vw, 2.5rem); + line-height: 1.1; + letter-spacing: 0.03em; +} + +.account-hero-card p:last-child { + margin: 0; + max-width: 68ch; + color: var(--text-soft); +} + +.status-badges { + display: grid; + gap: 0.45rem; + justify-items: end; +} + +.badge { + display: inline-flex; + justify-content: center; + min-width: 12rem; + padding: 0.4rem 0.65rem; + background: #111111; + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.badge--ok { + color: #d7f4d2; + border-color: rgba(126, 210, 117, 0.5); +} + +.badge--warn { + color: #ffd2d2; + border-color: rgba(224, 80, 80, 0.58); +} + +.dashboard-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--gap); + align-items: start; +} + +.dashboard-wide { + grid-column: 1 / -1; +} + +.single-form { + margin-top: 1rem; + max-width: 28rem; +} + +.setup-list { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; +} + +.setup-list li { + display: grid; + grid-template-columns: 2rem 1fr; + gap: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.setup-list li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.setup-list span { + display: inline-grid; + place-items: center; + width: 2rem; + height: 2rem; + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--accent); + font-size: 0.8rem; + font-weight: 800; +} + +.setup-list strong, +.setup-list em { + display: block; +} + +.setup-list strong { + color: var(--text); +} + +.setup-list em { + margin-top: 0.15rem; + color: var(--muted); + font-style: normal; + font-size: 0.84rem; +} + +.region-list, +.platform-status-list, +.account-summary { + display: grid; + gap: 0.45rem; + margin: 0; +} + +.region-row, +.platform-status-list div, +.account-summary div { + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + gap: 1rem; + padding: 0.65rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.055); +} + +.region-row:last-child, +.platform-status-list div:last-child, +.account-summary div:last-child { + border-bottom: 0; +} + +.region-row span, +.platform-status-list span, +.account-summary dt { + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.region-row strong, +.platform-status-list strong, +.account-summary dd { + margin: 0; + color: var(--text-soft); + font-weight: 700; +} + +.account-summary dd { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.ready-dot { + width: 0.45rem; + height: 0.45rem; + background: #7ed275; + box-shadow: 0 0 0.75rem rgba(126, 210, 117, 0.35); +} + +.platform-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--gap); + margin: 1rem 0 0; + padding: 0; + border: 0; +} + +.platform-panel { + display: grid; + gap: 0.55rem; + padding: 1rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; +} + +.platform-panel h3 { + margin: 0 0 0.1rem; + color: var(--text); + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.platform-panel label { + color: var(--muted); + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.locked-card fieldset[disabled] { + opacity: 0.42; +} + +@media (max-width: 900px) { + .account-hero-card, + .dashboard-grid, + .platform-grid { + grid-template-columns: 1fr; + } + + .status-badges { + justify-items: start; + } + + .badge { + min-width: 0; + } +} + +@media (max-width: 700px) { + .site-header { + align-items: flex-start; + gap: 0.75rem; + flex-direction: column; + } + + .account-indicator { + width: 100%; + justify-content: center; + } +} + +/* v3 account-core prototype additions */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.brand-logo--text { + display: inline-grid; + place-items: center; + color: var(--accent-hover); + font-weight: 900; + letter-spacing: 0; +} + +.site-header--accountline { + gap: 1rem; +} + +.top-account-form { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(8rem, 1fr) auto auto; + gap: 0.5rem; + align-items: center; + width: min(100%, 39rem); +} + +.top-account-form input { + min-height: 2.15rem; + font-size: 0.85rem; +} + +.button-compact, +.top-account-form button { + width: auto; + min-height: 2.15rem; + margin: 0; + padding: 0.45rem 0.75rem; + white-space: nowrap; +} + +.button-secondary { + background: #151515; +} + +.top-account-status { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background: rgba(24, 24, 24, 0.88); + border: 1px solid var(--panel-border); + border-radius: 999px; + color: var(--text-soft); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.top-account-status a:hover { + color: var(--accent-hover); +} + +.main-grid--home { + grid-template-columns: 60fr 40fr; +} + +.quick-account-card .small-link { + margin-top: 1rem; +} + +.dashboard-grid--account { + grid-template-columns: 1fr 1fr; +} + +.account-summary--large div { + padding: 0.8rem 0; +} + +.region-list--sync strong { + display: inline-flex; + align-items: center; + gap: 0.45rem; +} + +.split-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(16rem, 0.45fr); + gap: var(--gap); + align-items: start; +} + +.callout-box, +.warning-box { + padding: 1rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; +} + +.callout-box h3 { + margin: 0 0 0.45rem; + color: var(--text); + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.callout-box p, +.warning-box { + margin: 0; + color: var(--text-soft); + font-size: 0.88rem; +} + +.key-management-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--gap); + margin-top: 1rem; +} + +.platform-panel--status { + align-content: start; +} + +.linked-state { + margin: 0 0 0.35rem; + color: var(--text-soft); + font-size: 0.86rem; + font-weight: 700; +} + +.linked-state--empty { + color: var(--muted); +} + +.mini-form, +.danger-zone { + display: grid; + gap: 0.55rem; +} + +.danger-zone { + padding-top: 0.6rem; + border-top: 1px solid rgba(255, 255, 255, 0.065); +} + +.danger-zone p { + margin: 0; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; +} + +.button-danger { + background: #2a1111; + border-color: rgba(224, 80, 80, 0.5); + color: #ffd2d2; +} + +.button-danger:hover { + background: #3a1515; + border-color: var(--accent-hover); + color: #ffffff; +} + +.warning-box { + margin-top: 1rem; + border-color: rgba(224, 80, 80, 0.4); +} + +@media (max-width: 980px) { + .site-header--accountline { + align-items: flex-start; + flex-direction: column; + } + + .top-account-form { + width: 100%; + } + + .key-management-grid, + .split-panel, + .dashboard-grid--account, + .main-grid--home { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .top-account-form { + grid-template-columns: 1fr 1fr; + } + + .top-account-form input { + grid-column: 1 / -1; + } + + .button-compact, + .top-account-form button { + width: 100%; + } +} + + +.pending-panel { + display: grid; + gap: 0.55rem; + margin-top: 1rem; + padding: 1rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; +} + +.pending-panel h3 { + margin: 0; + color: var(--text); + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.pending-panel p { + margin: 0; + color: var(--text-soft); + font-size: 0.88rem; +} + +.pending-pill { + display: inline-flex; + width: fit-content; + margin-top: 0.2rem; + padding: 0.35rem 0.55rem; + border: 1px solid rgba(224, 80, 80, 0.55); + border-radius: 999px; + color: #ffd2d2; + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +/* v4 account dashboard layout */ +.save-sync-card { + max-width: none; +} + +.region-list--wide { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.region-list--wide .region-row { + display: grid; + grid-template-columns: 1fr; + gap: 0.35rem; + padding: 0.85rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; +} + +.dashboard-grid--setup { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); +} + +select { + width: 100%; + min-height: 2.35rem; + padding: 0.5rem 0.65rem; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: 4px; + color: var(--text); + font: inherit; +} + +select:hover, +select:focus { + border-color: #464646; +} + +.key-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} + +.key-row { + display: grid; + gap: 0.9rem; + align-content: space-between; + min-height: 12rem; + padding: 1rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; +} + +.key-row h3 { + margin: 0 0 0.25rem; + color: var(--text); + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.key-row p { + margin: 0; + color: var(--text-soft); +} + +.key-meta { + margin-top: 0.25rem !important; + color: var(--muted) !important; + font-size: 0.82rem; +} + +.key-row .button-danger { + width: 100%; + margin: 0; +} + +@media (max-width: 980px) { + .region-list--wide, + .dashboard-grid--setup { + grid-template-columns: 1fr; + } +} + +@media (max-width: 620px) { + .key-list { + grid-template-columns: 1fr; + } + + .key-row .button-danger { + width: 100%; + } +} + +/* v9 account setup alignment */ +.dashboard-grid--setup { + align-items: stretch; + grid-template-rows: auto minmax(8rem, 1fr); +} + +.dashboard-grid--setup > .setup-card--bb { + grid-column: 1; + grid-row: 1; +} + +.dashboard-grid--setup > .setup-card--xbox { + grid-column: 1; + grid-row: 2; +} + +.dashboard-grid--setup > .setup-card--register { + grid-column: 2; + grid-row: 1 / 3; +} + +.setup-card { + min-width: 0; +} + +.setup-card--bb, +.setup-card--register, +.setup-card--xbox { + align-content: start; +} + +.setup-card--xbox { + min-height: 8rem; +} + +.setup-card--xbox .pending-pill { + margin-top: 0.35rem; +} + +@media (max-width: 980px) { + .dashboard-grid--setup { + grid-template-rows: none; + } + + .dashboard-grid--setup > .setup-card--bb, + .dashboard-grid--setup > .setup-card--xbox, + .dashboard-grid--setup > .setup-card--register { + grid-column: auto; + grid-row: auto; + } +} + +.bb-account-form { + display: grid; + gap: 0.85rem; + margin-top: 1rem; +} + +.bb-account-form label { + display: grid; + gap: 0.35rem; + font-size: 0.82rem; + color: var(--muted); +} + +.bb-account-form input { + width: 100%; +} + +.auth-message, +.bb-message { + min-height: 1rem; + font-size: 0.78rem; + color: var(--muted); +} + +.auth-message.is-error, +.bb-message.is-error { + color: #ffb4b4; +} + +.auth-message.is-ok, +.bb-message.is-ok { + color: #a8f0c6; +} + +/* live V2 key profile UI */ +.key-status-message { + min-height: 1.2rem; + margin: 0.75rem 0 0; + color: var(--muted); + font-size: 0.86rem; + font-weight: 700; +} + +.key-status-message[data-kind="ok"] { + color: #d7f4d2; +} + +.key-status-message[data-kind="warn"] { + color: #ffd2d2; +} + +.key-status-message[data-kind="pending"] { + color: var(--text-soft); +} + +.key-sync-summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.85rem; + align-items: center; + margin-top: 1rem; + padding: 0.75rem 0.85rem; + background: #111111; + border: 1px solid #252525; + border-radius: 6px; + color: var(--muted); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.key-sync-summary strong { + color: var(--text-soft); +} + +.sync-pill { + display: inline-flex; + padding: 0.25rem 0.45rem; + border: 1px solid var(--panel-border); + border-radius: 999px; +} + +.sync-pill--current { + color: #d7f4d2; + border-color: rgba(126, 210, 117, 0.5); +} + +.sync-pill--pending { + color: #ffd2d2; + border-color: rgba(224, 80, 80, 0.58); +} + +.key-row--empty { + min-height: 7rem; +} + +.key-list--empty { + grid-template-columns: 1fr; +} + +button[disabled] { + cursor: wait; + opacity: 0.58; +} + +/* account status pill highlights */ +.pso-status-good { + color: #d7f4d2 !important; + border-color: rgba(105, 215, 105, 0.82) !important; + background: rgba(59, 132, 58, 0.14) !important; + box-shadow: inset 0 0 0 1px rgba(105, 215, 105, 0.14) !important; +} + +.pso-status-warn { + color: #ffd2d2 !important; + border-color: rgba(224, 80, 80, 0.74) !important; + background: rgba(120, 30, 30, 0.18) !important; +} + +/* force exact ready/online account pills green */ +body .pso-status-good.pso-status-good, +body button.pso-status-good.pso-status-good, +body a.pso-status-good.pso-status-good, +body span.pso-status-good.pso-status-good, +body div.pso-status-good.pso-status-good { + color: #d7f4d2 !important; + border-color: rgba(105, 215, 105, 0.82) !important; + background: rgba(59, 132, 58, 0.14) !important; + box-shadow: inset 0 0 0 1px rgba(105, 215, 105, 0.14) !important; +} + +/* local-syncer save sync panel */ +.pending-dot { + display: inline-block; + width: 0.55rem; + height: 0.55rem; + margin-right: 0.4rem; + border-radius: 999px; + background: #e05050; + box-shadow: 0 0 0 3px rgba(224, 80, 80, 0.14); +} + +.save-sync-state--good { + color: #d7f4d2; +} + +.save-sync-state--warn { + color: #ffd2d2; +} + +.save-sync-message { + margin: 0.75rem 0 0; + color: var(--muted); + font-size: 0.82rem; + line-height: 1.35; +} + +.save-sync-message[data-status="current"] { + color: #d7f4d2; +} + +.save-sync-message[data-status="pending"], +.save-sync-message[data-status="unknown"] { + color: #ffd2d2; +} + +/* force account dashboard good pills to filled green */ +.pso-status-good, +.status-pill.pso-status-good, +.badge.pso-status-good, +.account-status .pso-status-good, +.dashboard-status .pso-status-good { + color: #d7f4d2 !important; + border-color: rgba(105, 215, 105, 0.82) !important; + background: rgba(59, 132, 58, 0.22) !important; + box-shadow: + inset 0 0 0 1px rgba(105, 215, 105, 0.18), + 0 0 0 1px rgba(105, 215, 105, 0.10) !important; +} + +/* cleaner two-row header registration form */ +.top-account-form.is-register { + display: grid; + grid-template-columns: minmax(8.5rem, 10rem) minmax(10rem, 12rem) minmax(11rem, 13rem); + gap: 0.5rem; + align-items: center; + justify-content: end; +} + +.top-account-form.is-register .top-account-actions { + grid-column: 2 / 4; + display: flex; + justify-content: flex-end; + gap: 0.5rem; +} + +.top-account-form.is-register .auth-message { + grid-column: 1 / 4; + justify-self: start; +} + +.top-account-form.is-register .top-account-actions .button-compact { + min-width: 9rem; +} + +.top-account-form.is-register .top-account-actions [data-action="cancel-register"] { + min-width: 7rem; +} + +@media (max-width: 900px) { + .top-account-form.is-register { + grid-template-columns: 1fr; + justify-content: stretch; + } + + .top-account-form.is-register .top-account-actions, + .top-account-form.is-register .auth-message { + grid-column: 1; + } + + .top-account-form.is-register .top-account-actions { + justify-content: stretch; + } + + .top-account-form.is-register .top-account-actions .button-compact { + flex: 1; + } +} + +/* footer legal/license text */ +.footer-legal { + margin-top: 0.75rem; + color: var(--muted); + font-size: 0.78rem; + line-height: 1.45; + text-align: center; +} + +.footer-legal p { + margin: 0.2rem 0; +} + +.footer-legal a { + color: var(--text-soft); + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.footer-legal a:hover, +.footer-legal a:focus-visible { + color: var(--accent); +} + +/* account email summary/update */ +.account-email-summary { + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.95rem; +} + +.account-email-line { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.65rem; + align-items: baseline; +} + +.account-email-label { + color: var(--muted); +} + +.account-email-line strong { + color: var(--text-soft); +} + +.account-email-state { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-email-state.is-verified { + color: #d7f4d2; +} + +.account-email-state.is-pending { + color: #ffd2d2; +} + +.link-button { + appearance: none; + border: 0; + background: transparent; + color: var(--text-soft); + cursor: pointer; + padding: 0; + font: inherit; + font-size: 0.82rem; + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.link-button:hover, +.link-button:focus-visible { + color: var(--accent); +} + +.account-email-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.75rem; +} + +.account-email-form input { + min-width: min(22rem, 100%); +} + +.account-email-message { + min-height: 1.2rem; + margin: 0.55rem 0 0; + font-size: 0.82rem; +} + +.account-email-message.is-ok { + color: #d7f4d2; +} + +.account-email-message.is-error { + color: #ffd2d2; +} + +.account-email-message a { + color: var(--text-soft); + text-decoration: underline; + text-underline-offset: 0.18em; +} + +/* clean account hero controls */ +.account-email-summary { + margin-top: 0.45rem; + color: var(--muted); + font-size: 0.95rem; +} + +.account-control-line { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.65rem; + align-items: baseline; +} + +.account-control-label { + color: var(--muted); +} + +.account-control-line strong { + color: var(--text-soft); +} + +.account-email-state { + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.account-email-state.is-verified { + color: #d7f4d2; +} + +.account-email-state.is-pending { + color: #ffd2d2; +} + +.inline-link, +button.inline-link, +.account-control-line button.inline-link { + appearance: none !important; + border: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: var(--text-soft) !important; + cursor: pointer; + padding: 0 !important; + min-width: 0 !important; + width: auto !important; + height: auto !important; + font: inherit; + font-size: 0.82rem; + letter-spacing: normal !important; + text-transform: none !important; + text-decoration: underline; + text-underline-offset: 0.18em; +} + +.inline-link:hover, +.inline-link:focus-visible { + color: var(--accent) !important; +} + +.account-inline-form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--panel-border); + border-radius: 6px; + background: rgba(0, 0, 0, 0.16); +} + +.account-inline-form[hidden] { + display: none !important; +} + +.account-inline-form input { + min-width: min(18rem, 100%); +} + +.account-password-form input { + min-width: min(13rem, 100%); +} + +.account-control-message { + min-height: 1.2rem; + margin: 0.55rem 0 0; + font-size: 0.82rem; +} + +.account-control-message.is-ok { + color: #d7f4d2; +} + +.account-control-message.is-error { + color: #ffd2d2; +} + +.account-control-message a { + color: var(--text-soft); + text-decoration: underline; + text-underline-offset: 0.18em; +} + +/* registered key access-key reveal */ +.key-secret-line { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: baseline; + margin: 0.45rem 0 0; + color: var(--muted); + font-size: 0.86rem; +} + +.key-secret-value { + color: var(--text-soft); + background: rgba(0, 0, 0, 0.22); + border: 1px solid var(--panel-border); + border-radius: 4px; + padding: 0.12rem 0.35rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + letter-spacing: 0.04em; +} + +/* per-key reveal error/status */ +.key-card-message { + margin: 0.55rem 0 0; + min-height: 1rem; + font-size: 0.82rem; +} + +.key-card-message.is-error { + color: #ffd2d2; +} + +.key-card-message.is-ok { + color: #d7f4d2; +} + +/* account setup layout: BB + key sync left, V2/V3 + Xbox right */ +.dashboard-grid--setup { + grid-template-areas: + "bb register" + "key-sync xbox"; + align-items: start; +} + +.dashboard-grid--setup .setup-card--bb { + grid-area: bb; +} + +.dashboard-grid--setup .setup-card--key-sync { + grid-area: key-sync; +} + +.dashboard-grid--setup .setup-card--register { + grid-area: register; +} + +.dashboard-grid--setup .setup-card--xbox { + grid-area: xbox; +} + +.key-sync-summary--panel { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1rem; + align-items: center; + min-height: 4rem; + color: var(--muted); + font-size: 0.82rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.key-sync-summary--panel strong { + color: var(--text-soft); +} + +@media (max-width: 900px) { + .dashboard-grid--setup { + grid-template-areas: + "bb" + "key-sync" + "register" + "xbox"; + } +} + +/* align V2/V3 registration card with Blue Burst card */ +.dashboard-grid--setup .setup-card--register { + margin-top: 0.9rem; +} + +@media (max-width: 900px) { + .dashboard-grid--setup .setup-card--register { + margin-top: 0; + } +} + +/* equal height BB and V2/V3 cards */ +.dashboard-grid--setup { + align-items: stretch; +} + +.dashboard-grid--setup .setup-card--bb, +.dashboard-grid--setup .setup-card--register { + height: 100%; +} + +.dashboard-grid--setup .setup-card--register { + margin-top: 0; +} + +/* Blue Burst card cleanup */ +.setup-card--bb .eyebrow { + color: var(--muted); +} + +.setup-card--bb h2, +.setup-card--bb .badge, +.setup-card--bb .bb-status, +.setup-card--bb .bb-status-badge { + display: none !important; +} + +/* placeholder data pages */ +.page-placeholder-layout { + gap: 1rem; +} + +.page-title-card { + min-height: unset; +} + +.placeholder-control-card, +.placeholder-results-card { + width: 100%; +} + +.blank-data-box { + min-height: 22rem; + border: 1px dashed rgba(255, 255, 255, 0.22); + border-radius: 18px; + display: grid; + place-items: center; + color: var(--muted); + background: rgba(0, 0, 0, 0.16); + font-weight: 800; + letter-spacing: 0.04em; + text-align: center; + padding: 2rem; +} + +.placeholder-form [hidden] { + display: none !important; +} + +/* fixed placeholder pages using normal site shell */ +.placeholder-control-card, +.placeholder-results-card { + width: 100%; +} + +.blank-data-box { + min-height: 18rem; + border: 1px dashed rgba(255, 255, 255, 0.22); + border-radius: 14px; + display: grid; + place-items: center; + color: var(--muted); + background: rgba(0, 0, 0, 0.16); + font-weight: 800; + letter-spacing: 0.04em; + text-align: center; + padding: 2rem; +} + +.placeholder-form [hidden] { + display: none !important; +} + + +/* homepage server status touch-up */ +.server-status-card .site-intro { + margin: 0 0 1.35rem; + color: var(--text); + line-height: 1.65; +} + +.status-list { + margin-top: 1.25rem; +} + +.status-server { + margin-top: 1.2rem; + color: var(--text); + font-family: var(--mono); + font-weight: 900; +} + +.status-server:first-child { + margin-top: 0; +} + +.status-label { + margin: 0.25rem 0 0.4rem; + color: var(--muted); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.status-ship { + padding-top: 0.25rem; + font-family: var(--mono); + font-weight: 900; +} + +.status-ship--solo { + justify-content: flex-start; +} + +.status-child { + padding-left: 1.15rem; + color: var(--muted); +} + +/* public placeholder pages */ +.placeholder-layout { + display: grid; + gap: var(--gap); +} + +.placeholder-form { + display: grid; + gap: 0.65rem; + max-width: 24rem; +} + +.placeholder-form label { + color: var(--muted); + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.placeholder-form [hidden] { + display: none !important; +} + +.blank-data-box { + min-height: 18rem; + border: 1px dashed rgba(255, 255, 255, 0.22); + border-radius: 14px; + display: grid; + place-items: center; + color: var(--muted); + background: rgba(0, 0, 0, 0.16); + font-weight: 800; + letter-spacing: 0.04em; + text-align: center; + padding: 2rem; +} + +.key-sync-status { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-right: 1.1rem; + white-space: nowrap; +} + +.key-sync-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 999px; + display: inline-block; + flex: 0 0 auto; + vertical-align: middle; +} + +.key-sync-dot.is-synced { + background: #38d66b; + box-shadow: 0 0 0 2px rgba(56, 214, 107, 0.15); +} + +.key-sync-dot.is-syncing { + background: #e05252; + box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.16); +} + +.key-sync-status { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-right: 1.1rem; + white-space: nowrap; +} + +.key-sync-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 999px; + display: inline-block; + flex: 0 0 auto; + vertical-align: middle; +} + +.key-sync-dot.is-synced { + background: #38d66b; + box-shadow: 0 0 0 2px rgba(56, 214, 107, 0.15); +} + +.key-sync-dot.is-syncing { + background: #e05252; + box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.16); +} + +.key-sync-status { + display: inline-flex; + align-items: center; + gap: 0.45rem; + margin-right: 1.1rem; + white-space: nowrap; +} + +.key-sync-dot { + width: 0.65rem; + height: 0.65rem; + border-radius: 999px; + display: inline-block; + flex: 0 0 auto; + vertical-align: middle; +} + +.key-sync-dot.is-synced { + background: #38d66b; + box-shadow: 0 0 0 2px rgba(56, 214, 107, 0.15); +} + +.key-sync-dot.is-syncing { + background: #e05252; + box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.16); +} + +#key-sync-summary.key-sync-summary .key-sync-status { + display: inline-flex !important; + align-items: center !important; + gap: 0.45rem !important; + margin-right: 1.1rem !important; + white-space: nowrap !important; +} + +#key-sync-summary.key-sync-summary .key-sync-dot { + display: inline-block !important; + width: 0.65rem !important; + height: 0.65rem !important; + min-width: 0.65rem !important; + min-height: 0.65rem !important; + border-radius: 999px !important; + background-color: #e05252 !important; + box-shadow: 0 0 0 2px rgba(224, 82, 82, 0.16) !important; + opacity: 1 !important; + visibility: visible !important; +} + +#key-sync-summary.key-sync-summary .key-sync-dot.is-synced { + background-color: #38d66b !important; + box-shadow: 0 0 0 2px rgba(56, 214, 107, 0.15) !important; +} + +#key-sync-summary .key-sync-dot { + display: inline-block !important; + width: auto !important; + height: auto !important; + min-width: 0 !important; + min-height: 0 !important; + background: transparent !important; + box-shadow: none !important; + font-size: 0.95rem !important; + line-height: 1 !important; + color: #e05252 !important; +} + +#key-sync-summary .key-sync-dot.is-synced { + color: #38d66b !important; +} + +/* homepage server status touch-up */ +.server-status-card .site-intro { + margin: 0 0 1.35rem; + color: var(--text); + line-height: 1.65; +} + +.status-list { + margin-top: 1.25rem; +} + +.status-server { + margin-top: 1.2rem; + color: var(--text); + font-family: var(--mono); + font-weight: 900; +} + +.status-server:first-child { + margin-top: 0; +} + +.status-label { + margin: 0.25rem 0 0.4rem; + color: var(--muted); + font-family: var(--mono); + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.status-ship { + padding-top: 0.25rem; + font-family: var(--mono); + font-weight: 900; +} + +.status-ship--solo { + justify-content: flex-start; +} + +.status-child { + padding-left: 1.15rem; + color: var(--muted); +} + + +/* Hardcore leaderboard table */ +.leaderboard-box { + align-items: stretch; + justify-content: flex-start; + min-height: 260px; + text-align: left; +} + +#leaderboard-page-size-wrap { + display: grid; + gap: 0.45rem; +} + +.leaderboard-status { + display: grid; + min-height: 220px; + place-items: center; + color: var(--muted); + font-weight: 700; + text-align: center; +} + +.leaderboard-status--error { + color: #ff9a9a; +} + +.leaderboard-table-wrap { + width: 100%; + overflow-x: auto; +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.leaderboard-table th, +.leaderboard-table td { + border-bottom: 1px solid var(--line); + padding: 0.8rem 0.65rem; + text-align: left; + white-space: nowrap; +} + +.leaderboard-table th { + color: var(--muted); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.leaderboard-sort { + appearance: none; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + letter-spacing: inherit; + padding: 0; + text-transform: inherit; +} + +.leaderboard-sort:hover, +.leaderboard-sort:focus { + color: var(--text); +} + +.leaderboard-table td:nth-child(1), +.leaderboard-table td:nth-child(3), +.leaderboard-table td:nth-child(6), +.leaderboard-table td:nth-child(7) { + font-variant-numeric: tabular-nums; +} + +.leaderboard-pager { + align-items: center; + color: var(--muted); + display: flex; + font-size: 0.85rem; + font-weight: 700; + gap: 1rem; + justify-content: center; + margin-top: 1rem; +} + +.leaderboard-pager button { + border: 1px solid var(--line); + border-radius: 0.45rem; + background: rgba(255, 255, 255, 0.03); + color: var(--text); + cursor: pointer; + font: inherit; + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.12em; + padding: 0.6rem 0.85rem; + text-transform: uppercase; +} + +.leaderboard-pager button:disabled { + cursor: not-allowed; + opacity: 0.35; +} + +@media (max-width: 720px) { + .leaderboard-table { + min-width: 720px; + } +} + + +/* Keep wide leaderboard table contained on small screens */ +.placeholder-results-card, +.leaderboard-box { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + +.leaderboard-table-wrap { + min-width: 0; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; +} + +.leaderboard-table { + min-width: 820px; +} + +.leaderboard-table th, +.leaderboard-table td { + box-sizing: border-box; +} + +.leaderboard-table td:nth-child(1), +.leaderboard-table td:nth-child(3), +.leaderboard-table td:nth-child(7), +.leaderboard-table td:nth-child(8) { + font-variant-numeric: tabular-nums; +} + +@media (max-width: 720px) { + .leaderboard-box { + display: block; + padding: 1rem; + } + + .leaderboard-table { + width: 820px; + min-width: 820px; + } + + .leaderboard-pager { + min-width: 0; + max-width: 100%; + } +} + + +/* Mobile containment for wide leaderboard tables */ +html, +body { + max-width: 100%; + overflow-x: hidden; +} + +.site-shell, +.placeholder-layout, +.placeholder-control-card, +.placeholder-results-card, +.blank-data-box, +.leaderboard-box { + box-sizing: border-box; + max-width: 100%; + min-width: 0; +} + +.leaderboard-box { + overflow: hidden; +} + +.leaderboard-table-wrap { + display: block; + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; +} + +.leaderboard-table { + width: max-content; + min-width: 820px; + max-width: none; +} + +@media (max-width: 720px) { + .site-shell { + width: 100%; + max-width: 100%; + } + + .placeholder-results-card { + overflow: hidden; + } + + .leaderboard-box { + display: block; + width: 100%; + padding: 1rem; + } + + .leaderboard-table-wrap { + width: 100%; + } + + .leaderboard-table { + width: max-content; + min-width: 820px; + } +} + +/* Hard clamp mobile leaderboard overflow */ +@media (max-width: 720px) { + html, + body { + width: 100%; + max-width: 100%; + overflow-x: hidden; + } + + .site-shell, + .hero, + .nav-bar, + .placeholder-layout, + .card, + .placeholder-control-card, + .placeholder-results-card, + .footer-bar { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: hidden; + } + + .blank-data-box.leaderboard-box { + box-sizing: border-box; + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + padding: 1rem; + } + + .leaderboard-table-wrap { + box-sizing: border-box; + display: block; + width: 100%; + max-width: 100%; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + } + + .leaderboard-table { + display: table; + width: 820px; + min-width: 820px; + max-width: none; + } + + .leaderboard-pager { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + } +} + +/* Final mobile leaderboard containment: keep page width fixed, scroll table only */ +@media (max-width: 720px) { + html, + body { + width: 100%; + max-width: 100vw; + overflow-x: hidden; + } + + .site-shell { + box-sizing: border-box; + width: 100%; + max-width: 100vw; + min-width: 0; + overflow-x: hidden; + } + + .placeholder-layout, + .placeholder-control-card, + .placeholder-results-card { + box-sizing: border-box; + width: calc(100vw - 1rem) !important; + max-width: calc(100vw - 1rem) !important; + min-width: 0 !important; + overflow: hidden !important; + } + + .placeholder-results-card, + .blank-data-box.leaderboard-box { + contain: inline-size; + } + + .blank-data-box.leaderboard-box { + box-sizing: border-box; + display: block; + width: 100% !important; + max-width: 100% !important; + min-width: 0 !important; + overflow: hidden !important; + } + + .leaderboard-table-wrap { + box-sizing: border-box; + display: block; + inline-size: 100% !important; + max-inline-size: 100% !important; + min-inline-size: 0 !important; + overflow-x: auto !important; + overflow-y: hidden !important; + -webkit-overflow-scrolling: touch; + overscroll-behavior-x: contain; + } + + .leaderboard-table { + inline-size: max-content !important; + width: max-content !important; + min-width: 820px !important; + max-width: none !important; + } + + .leaderboard-pager { + box-sizing: border-box; + width: 100%; + max-width: 100%; + min-width: 0; + } +} + + +/* Disable failed mobile card experiment */ +.leaderboard-mobile-wrap { + display: none !important; +} + +/* Home page Hardcore leaderboard */ +.home-leaderboard-wrap { + width: 100%; + overflow-x: auto; +} + +.home-leaderboard-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} + +.home-leaderboard-table th, +.home-leaderboard-table td { + border-bottom: 1px solid var(--line); + padding: 0.65rem 0.45rem; + text-align: left; + white-space: nowrap; +} + +.home-leaderboard-table th { + color: var(--muted); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.home-leaderboard-table td:nth-child(2) { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.home-leaderboard-table th:nth-child(2) { + text-align: right; +} + +.home-leaderboard-more { + display: inline-block; + margin-top: 0.85rem; +} + +/* Home Hardcore compact leaderboard list */ +.home-leaderboard-wrap { + width: 100%; +} + +.home-leaderboard-list { + display: grid; + gap: 0; + width: 100%; +} + +.home-leaderboard-row { + align-items: center; + border-bottom: 1px solid var(--line); + display: grid; + gap: 0.65rem; + grid-template-columns: 2rem minmax(0, 1fr) auto auto; + min-width: 0; + padding: 0.55rem 0; +} + +.home-leaderboard-row:last-child { + border-bottom: 0; +} + +.home-leaderboard-row--head { + color: var(--muted); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.12em; + padding-top: 0; + text-transform: uppercase; +} + +.home-leaderboard-rank { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.home-leaderboard-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-leaderboard-points { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.home-leaderboard-status { + color: var(--muted); +} + +.home-leaderboard-status--alive { + color: #9fd6a3; +} + +.home-leaderboard-status--dead { + color: #f09a9a; +} + +.home-leaderboard-empty { + color: var(--muted); + padding: 0.65rem 0; +} + +.home-leaderboard-more { + display: inline-block; + margin-top: 0.8rem; +} + +/* Home Hardcore entries inside standard leaderboard-list */ +.leaderboard-list--home-hardcore .home-hardcore-entry { + display: grid; + gap: 0.75rem; + grid-template-columns: minmax(0, 1fr) auto auto; + min-width: 0; +} + +.leaderboard-list--home-hardcore .home-hardcore-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-list--home-hardcore .home-hardcore-points { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.leaderboard-list--home-hardcore .home-hardcore-status { + color: var(--muted); +} + +.home-leaderboard-more { + display: inline-block; + margin-top: 0.8rem; +} + + +/* Align Home Hardcore leaderboard columns like an invisible table */ +.leaderboard-list--home-hardcore .home-hardcore-entry { + display: grid; + grid-template-columns: minmax(0, 1fr) 5.25rem 4.25rem; + gap: 0.75rem; + align-items: baseline; + min-width: 0; +} + +.leaderboard-list--home-hardcore .home-hardcore-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-list--home-hardcore .home-hardcore-points { + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.leaderboard-list--home-hardcore .home-hardcore-status { + color: var(--muted); + text-align: left; + white-space: nowrap; +} + +/* Home Hardcore leaderboard: invisible table columns */ +.leaderboard-list.leaderboard-list--home-hardcore li.home-hardcore-row { + display: grid; + grid-template-columns: 2rem minmax(0, 1fr) 5.5rem 4.25rem; + gap: 0.75rem; + align-items: baseline; +} + +.leaderboard-list--home-hardcore .home-hardcore-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaderboard-list--home-hardcore .home-hardcore-points { + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; +} + +.leaderboard-list--home-hardcore .home-hardcore-status { + color: var(--muted); + text-align: left; + white-space: nowrap; +} + +/* Home Hardcore invisible table matching C Rank list */ +.home-hardcore-table { + border-collapse: collapse; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + width: 100%; +} + +.home-hardcore-table td { + border-bottom: 1px solid rgba(255, 255, 255, 0.045); + color: var(--text-soft); + padding: 0.25rem 0; + vertical-align: baseline; + white-space: nowrap; +} + +.home-hardcore-table tr:last-child td { + border-bottom: 0; +} + +.home-hardcore-table .rank { + color: var(--muted); + width: 2rem; +} + +.home-hardcore-table .home-hardcore-name { + max-width: 8rem; + overflow: hidden; + padding-right: 0.75rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-hardcore-table .home-hardcore-points { + padding-right: 0.75rem; + text-align: right; + width: 5.5rem; +} + +.home-hardcore-table .home-hardcore-status { + color: var(--muted); + text-align: left; + width: 4rem; +} + +/* Home Hardcore leaderboard: visually match C Rank list */ +.home-hardcore-table { + border-collapse: collapse; + border-spacing: 0; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + line-height: 1.45; + margin: 0; + width: 100%; +} + +.home-hardcore-table td { + border-bottom: 1px solid rgba(255, 255, 255, 0.045); + color: var(--text-soft); + padding: 0.25rem 0; + vertical-align: baseline; + white-space: nowrap; +} + +.home-hardcore-table tr:last-child td { + border-bottom: 0; +} + +.home-hardcore-table .rank { + color: var(--muted); + padding-right: 0.75rem; + width: 2rem; +} + +.home-hardcore-table .home-hardcore-name { + max-width: 8rem; + overflow: hidden; + padding-right: 1.25rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-hardcore-table .home-hardcore-points { + padding-left: 1rem; + padding-right: 1.25rem; + text-align: right; + width: 5.75rem; +} + +.home-hardcore-table .home-hardcore-status { + color: var(--muted); + padding-left: 0.75rem; + text-align: left; + width: 4rem; +} + +.home-leaderboard-more { + display: inline-block; + margin-top: 0.8rem; +} + +/* Home Hardcore leaderboard: aligned grid rows */ +.home-hardcore-table { + display: block; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.95rem; + font-variant-numeric: tabular-nums; + line-height: 1.45; + width: 100%; +} + +.home-hardcore-table tbody { + display: grid; + gap: 0; + width: 100%; +} + +.home-hardcore-table tr { + align-items: baseline; + border-bottom: 1px solid rgba(255, 255, 255, 0.045); + display: grid; + gap: 0.9rem; + grid-template-columns: 2rem minmax(0, 1fr) 6.25rem 4.25rem; + padding: 0.25rem 0; +} + +.home-hardcore-table tr:last-child { + border-bottom: 0; +} + +.home-hardcore-table td { + border-bottom: 0 !important; + color: var(--text-soft); + display: block; + padding: 0 !important; + white-space: nowrap; +} + +.home-hardcore-table .rank { + color: var(--muted); +} + +.home-hardcore-table .home-hardcore-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.home-hardcore-table .home-hardcore-points { + text-align: right; +} + +.home-hardcore-table .home-hardcore-status { + color: var(--muted); + text-align: left; +} + +/* Home Hardcore padded line inside standard leaderboard-list */ +.leaderboard-list--home-hardcore .home-hardcore-line { + white-space: pre; +}