1338 lines
41 KiB
Python
1338 lines
41 KiB
Python
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 -------------------------------------------
|