From 39baf9fa87162670e8fe46d115a6426210fd1836 Mon Sep 17 00:00:00 2001 From: Incentive Date: Sun, 14 Jun 2026 19:20:17 -0400 Subject: [PATCH] Delete backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z --- ....py.before-gc-v3-smtp-fix-20260611T001916Z | 1337 ----------------- 1 file changed, 1337 deletions(-) delete mode 100644 backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z diff --git a/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z b/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z deleted file mode 100644 index cbb6d37..0000000 --- a/backend/app.py.before-gc-v3-smtp-fix-20260611T001916Z +++ /dev/null @@ -1,1337 +0,0 @@ -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 -------------------------------------------