Initial psopeeps site import

This commit is contained in:
2026-06-11 01:32:41 -04:00
commit 47088e40c7
51 changed files with 12402 additions and 0 deletions
+41
View File
@@ -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/
+14
View File
@@ -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.
+26
View File
@@ -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}
}
}
+13
View File
@@ -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"]
+1337
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+540
View File
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
@@ -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/<int:key_id>/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/<int:key_id>")
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),
})
+3
View File
@@ -0,0 +1,3 @@
Flask==3.0.3
gunicorn==22.0.0
psycopg[binary]==3.2.1
+79
View File
@@ -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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

+62
View File
@@ -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
+14
View File
@@ -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.
+555
View File
@@ -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 => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"\"": "&quot;",
"'": "&#39;",
}[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 = [
'<option value="dc_v2">DC V2</option>',
'<option value="pc_v2">PC V2</option>',
'<option value="gc_v3">GC V3</option>',
].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 `
<span class="key-sync-status">
<span class="key-sync-dot ${syncDotClass(safeStatus)}" style="color: ${syncDotClass(safeStatus) === 'is-synced' ? '#38d66b' : '#e05252'};" aria-hidden="true">●</span>
<span>${esc(label)}: <strong>${esc(safeStatus)}</strong></span>
</span>
`;
}
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 + `
<article class="key-row key-row--empty" role="listitem">
<div>
<h3>No V2 / V3 keys registered</h3>
<p>Add a DC V2, PC V2, or GC V3 key profile above.</p>
</div>
</article>
`;
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 `
<article class="key-row" role="listitem" data-key-id="${esc(key.id)}" data-key-version="${esc(key.game_version || "")}">
<div>
<h3>${esc(version)}</h3>
<p>Registered profile <strong>${esc(serial)}</strong></p>
<p class="key-meta">Serial: ${esc(displaySerial)}</p>
${label ? `<p class="key-meta">Label: ${esc(label)}</p>` : ""}
<p class="key-secret-line">
<span>Access key:</span>
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="access_key">••••••••••••</code>
<button class="inline-link key-reveal-button" type="button" data-key-id="${esc(key.id)}">
show
</button>
</p>
${isGC ? `<p class="key-secret-line">
<span>Password:</span>
<code class="key-secret-value" data-key-secret="${esc(key.id)}" data-key-secret-kind="password">••••••••••••</code>
</p>` : ""}
</div>
<button class="button-danger key-delete-button" type="button" data-key-id="${esc(key.id)}">
Delete Key
</button>
</article>
`;
}).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 = `
<article class="key-row key-row--empty" role="listitem">
<div>
<h3>Could not load keys</h3>
<p>${esc(err.message || err)}</p>
</div>
</article>
`;
});
return true;
}
function start() {
[0, 500, 1500, 3000].forEach(ms => {
window.setTimeout(bind, ms);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();
+151
View File
@@ -0,0 +1,151 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account Ready · PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=key-sync-dots-20260609-5">
<script src="app.js?v=account-status-label-20260609" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="top-account-status"><span class="status-dot" aria-hidden="true"></span><a href="account-ready.html">Signed in as chuudoku</a></div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
</nav>
<main class="account-layout">
<section class="card account-hero-card" aria-labelledby="account-title">
<div>
<p class="eyebrow">Account Dashboard</p>
<h1 id="account-title">chuudoku</h1>
<p>
Manage your Blue Burst login and the serial/access keys you use for DC V2, PC V2, and GC V3.
Linked saves are mirrored between US and EU automatically.
</p>
</div>
<div class="status-badges" aria-label="Account setup status">
<span class="badge badge--ok">BB account ready</span>
<span class="badge badge--ok">Saves synced</span>
</div>
</section>
<section class="card save-sync-card" aria-labelledby="sync-heading">
<h2 id="sync-heading" class="section-title">Save Sync</h2>
<div class="region-list region-list--sync region-list--wide">
<div class="region-row"><span>US saves</span><strong><span class="ready-dot"></span> Current</strong></div>
<div class="region-row"><span>EU saves</span><strong><span class="ready-dot"></span> Current</strong></div>
<div class="region-row"><span>Last sync</span><strong>Less than 1 minute ago</strong></div>
</div>
</section>
<section class="dashboard-grid dashboard-grid--setup">
<section class="card setup-card setup-card--bb" aria-labelledby="bb-heading">
<h2 id="bb-heading" class="section-title">Blue Burst Account</h2>
<dl class="account-summary account-summary--large">
<div><dt>BB username</dt><dd>chuudoku</dd></div>
<div><dt>BB account ID</dt><dd>0126326509</dd></div>
</dl>
<p class="fine-print">Blue Burst is limited to one account per website account. Password reset can come later.</p>
</section>
<section class="card setup-card setup-card--key-sync" aria-labelledby="key-sync-heading">
<h2 id="key-sync-heading" class="section-title">Key Sync</h2>
<div id="key-sync-summary" class="key-sync-summary key-sync-summary--panel">
<span>Key sync: <strong>unknown</strong></span>
<span>US: <strong>unknown</strong></span>
<span>EU: <strong>unknown</strong></span>
</div>
</section>
<section class="card setup-card setup-card--register" aria-labelledby="new-key-heading">
<h2 id="new-key-heading" class="section-title">Register V2 / V3 Key</h2>
<p class="card-copy">
Add each DC V2, PC V2, or GC V3 key profile you use. You can register more than one key profile.
</p>
<form onsubmit="return false" class="account-form single-form" action="#" method="post">
<label for="key-version">Game Version</label>
<select id="key-version" name="key-version">
<option>DC V2</option>
<option>PC V2</option>
<option>GC V3</option>
</select>
<label for="key-label">Label <span class="optional">optional</span></label>
<input id="key-label" name="key-label" type="text" placeholder="Dreamcast US disc, GameCube JP, etc.">
<label for="key-serial">Serial Number</label>
<input id="key-serial" name="key-serial" type="text" inputmode="numeric">
<label for="key-access">Access Key</label>
<input id="key-access" name="key-access" type="text">
<button type="button">Register Key Profile</button>
</form>
</section>
<section class="card setup-card setup-card--xbox" aria-labelledby="xbox-v3-heading">
<h2 id="xbox-v3-heading" class="section-title">Xbox V3 / Insignia</h2>
<p class="card-copy">Linking an Xbox V3 profile with an Insignia token will live here.</p>
<span class="pending-pill">Insignia support pending</span>
</section>
</section>
<section class="card" aria-labelledby="keys-heading">
<h2 id="keys-heading" class="section-title">Registered V2 / V3 Keys</h2>
<p class="card-copy">
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.
</p>
<div class="key-list" role="list">
</div>
<div class="warning-box" role="note"><strong>Delete warning:</strong> deleting a key removes that V2 login profile from this website account and syncs the removal to US and EU.</div>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="account-keys.js?v=20260611-gc-v3-raw-serial-neutral-copy" defer></script>
<script src="account-save-sync.js?v=account-fix-1780987245" defer></script>
</body>
</html>
+90
View File
@@ -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" ? '<span class="ready-dot"></span> ' : "";
}
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 || {});
});
})();
+58
View File
@@ -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));
}
});
})();
+108
View File
@@ -0,0 +1,108 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Verify Email · PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="app.js?v=fix-1780987163" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<img class="brand-logo" src="logo.png" alt="" width="48" height="48">
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="account-indicator account-indicator--user" aria-label="Account status">
<span class="status-dot" aria-hidden="true"></span>
<a href="account-ready.html">Signed in as chuudoku</a>
</div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork">
<div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div>
</section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
<a href="leaderboards.html">Leaderboards</a>
<a href="drops.html">Drops</a>
<a href="bestiary.html">Bestiary</a>
<a href="account-ready.html">Account</a>
</nav>
<main class="account-layout">
<section class="card account-hero-card" aria-labelledby="account-title">
<div>
<p class="eyebrow">Account Dashboard</p>
<h1 id="account-title">Verify your email to continue</h1>
<p>
Your website account exists, but no PSO Peeps game account will be created until your email address is verified.
After verification, you can set a Player Password and link your serial/access keys.
</p>
</div>
<div class="status-badges" aria-label="Account setup status">
<span class="badge badge--warn">Email pending</span>
<span class="badge">Player Password locked</span>
<span class="badge">Keys locked</span>
</div>
</section>
<section class="dashboard-grid">
<section class="card" aria-labelledby="verify-heading">
<h2 id="verify-heading" class="section-title">Email Verification</h2>
<p class="card-copy">Check your inbox for the PSO Peeps verification link. The game account provisioner stays disabled until verification is complete.</p>
<form class="account-form single-form" action="#" method="post">
<button type="submit">Resend Verification</button>
</form>
</section>
<section class="card locked-card" aria-labelledby="locked-heading">
<h2 id="locked-heading" class="section-title">Next Steps</h2>
<ol class="setup-list">
<li><span>1</span><strong>Verify email</strong><em>Required before game provisioning.</em></li>
<li><span>2</span><strong>Set Player Password</strong><em>Your Blue Burst / game-side password.</em></li>
<li><span>3</span><strong>Link serials</strong><em>DC V2, PC V2, and GC V3 keys attach to the same mirrored account.</em></li>
</ol>
<a class="small-link" href="account.html">View verified state mockup</a>
</section>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Account · PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=key-sync-dots-20260609-5">
<script src="app.js?v=account-status-label-20260609" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="top-account-status">
<span class="status-dot" aria-hidden="true"></span>
<a href="account-ready.html">Signed in as chuudoku</a>
</div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork">
</section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
<a href="leaderboards.html">Leaderboards</a>
<a href="drops.html">Drops</a>
<a href="bestiary.html">Bestiary</a>
<a href="account-ready.html">Account</a>
</nav>
<main class="account-layout">
<section class="card account-hero-card" aria-labelledby="account-title">
<div>
<p class="eyebrow">Account Dashboard</p>
<h1 id="account-title">chuudoku</h1>
<p>
Manage your Blue Burst login and the serial/access keys you use for DC V2, PC V2, and GC V3.
Linked saves are mirrored between US and EU automatically.
</p>
</div>
<div class="status-badges" aria-label="Account setup status">
<span class="badge badge--warn">BB password needed</span>
<span class="badge badge--ok">Saves synced</span>
</div>
</section>
<section class="card save-sync-card" aria-labelledby="sync-heading">
<h2 id="sync-heading" class="section-title">Save Sync</h2>
<div class="region-list region-list--sync region-list--wide">
<div class="region-row"><span>US saves</span><strong><span class="ready-dot"></span> Current</strong></div>
<div class="region-row"><span>EU saves</span><strong><span class="ready-dot"></span> Current</strong></div>
<div class="region-row"><span>Last sync</span><strong>Less than 1 minute ago</strong></div>
</div>
</section>
<section class="dashboard-grid dashboard-grid--setup">
<section class="card setup-card setup-card--bb" aria-labelledby="bb-heading">
<h2 id="bb-heading" class="section-title">Blue Burst Account</h2>
<p class="card-copy">
No Blue Burst account has been created yet. Set one BB password here; your BB username will be <strong>chuudoku</strong> on both US and EU.
</p>
<form class="account-form single-form" action="#" method="post">
<label for="bb-password">Set Blue Burst Password</label>
<input id="bb-password" name="bb-password" type="password" autocomplete="new-password">
<label for="bb-password-confirm">Confirm Blue Burst Password</label>
<input id="bb-password-confirm" name="bb-password-confirm" type="password" autocomplete="new-password">
<button type="submit">Create Blue Burst Account</button>
</form>
<p class="fine-print">Blue Burst is limited to one account per website account.</p>
</section>
<section class="card setup-card setup-card--key-sync" aria-labelledby="key-sync-heading">
<h2 id="key-sync-heading" class="section-title">Key Sync</h2>
<div id="key-sync-summary" class="key-sync-summary key-sync-summary--panel">
<span>Key sync: <strong>unknown</strong></span>
<span>US: <strong>unknown</strong></span>
<span>EU: <strong>unknown</strong></span>
</div>
</section>
<section class="card setup-card setup-card--register" aria-labelledby="new-key-heading">
<h2 id="new-key-heading" class="section-title">Register V2 / V3 Key</h2>
<p class="card-copy">
Add each DC V2, PC V2, or GC V3 key profile you use. You can register more than one key profile.
</p>
<form onsubmit="return false" class="account-form single-form" action="#" method="post">
<label for="key-version">Game Version</label>
<select id="key-version" name="key-version">
<option>DC V2</option>
<option>PC V2</option>
<option>GC V3</option>
</select>
<label for="key-label">Label <span class="optional">optional</span></label>
<input id="key-label" name="key-label" type="text" placeholder="Dreamcast US disc, GameCube JP, etc.">
<label for="key-serial">Serial Number</label>
<input id="key-serial" name="key-serial" type="text" inputmode="numeric">
<label for="key-access">Access Key</label>
<input id="key-access" name="key-access" type="text">
<button type="button">Register Key Profile</button>
</form>
</section>
<section class="card setup-card setup-card--xbox" aria-labelledby="xbox-v3-heading">
<h2 id="xbox-v3-heading" class="section-title">Xbox V3 / Insignia</h2>
<p class="card-copy">Linking an Xbox V3 profile with an Insignia token will live here.</p>
<span class="pending-pill">Insignia support pending</span>
</section>
</section>
<section class="card" aria-labelledby="keys-heading">
<h2 id="keys-heading" class="section-title">Registered V2 / V3 Keys</h2>
<p class="card-copy">
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.
</p>
<div class="warning-box" role="note"><strong>Delete warning:</strong> deleting a key removes that V2 login profile from this website account and syncs the removal to US and EU.</div>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="account-keys.js?v=20260611-gc-v3-raw-serial-neutral-copy" defer></script>
<script src="account-save-sync.js?v=account-fix-1780987245" defer></script>
</body>
</html>
+614
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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 = `
<label class="sr-only" for="top-username">Username</label>
<input id="top-username" name="username" type="text" placeholder="username" autocomplete="username">
<label class="sr-only" for="top-email">Email</label>
<input id="top-email" name="email" type="email" placeholder="email" autocomplete="email">
<label class="sr-only" for="top-password">Password</label>
<input id="top-password" name="password" type="password" placeholder="password" autocomplete="new-password">
<div class="top-account-actions">
<button class="button-compact" type="submit" data-action="create-account">Create Account</button>
<button class="button-compact button-secondary" type="button" data-action="cancel-register">Cancel</button>
</div>
<div class="auth-message" role="status"></div>
`;
return;
}
form.innerHTML = `
<label class="sr-only" for="top-username">Username</label>
<input id="top-username" name="username" type="text" placeholder="username" autocomplete="username">
<label class="sr-only" for="top-password">Password</label>
<input id="top-password" name="password" type="password" placeholder="password" autocomplete="current-password">
<button class="button-compact" type="submit" data-action="login">Login</button>
<button class="button-compact button-secondary" type="button" data-action="show-register">Register</button>
<div class="auth-message" role="status"></div>
`;
}
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 = `
<span class="status-dot" aria-hidden="true"></span>
<a href="account-ready.html">Signed in as ${escapeHtml(user.username)}</a>
<button class="button-compact button-secondary" type="button" data-logout>Logout</button>
`;
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 `<span class="${cls}">${escapeHtml(text)}</span>`;
}
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 = `
<div class="setup-card-header">
<div>
<p class="eyebrow">Blue Burst</p>
<h2>Blue Burst Account</h2>
</div>
${badge(bbBadgeLabel, bbReady ? "" : "badge--warn")}
</div>
<div class="account-kv">
<div>
<span>BB username</span>
<strong>${escapeHtml(bb.username)}</strong>
</div>
<div>
<span>BB account ID</span>
<strong>${escapeHtml(bb.account_id)}</strong>
</div>
</div>
<form class="bb-account-form" data-bb-action="change-password">
<p class="muted">Change your Blue Burst login password. This updates the account file, then it needs to sync to the ships.</p>
<label>
New BB password
<input name="password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<label>
Confirm new BB password
<input name="confirm_password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<button class="button" type="submit">Change Blue Burst Password</button>
<div class="bb-message" role="status"></div>
</form>
`;
} else {
card.innerHTML = `
<div class="setup-card-header">
<div>
<p class="eyebrow">Blue Burst</p>
<h2>Create Blue Burst Account</h2>
</div>
${badge("BB PASSWORD NEEDED", "badge--warn")}
</div>
<div class="account-kv">
<div>
<span>BB username</span>
<strong>${escapeHtml(user.username)}</strong>
</div>
<div>
<span>BB account ID</span>
<strong>created after setup</strong>
</div>
</div>
<form class="bb-account-form" data-bb-action="create">
<p class="muted">Your Blue Burst username will match your website account name.</p>
<label>
BB password
<input name="password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<label>
Confirm BB password
<input name="confirm_password" type="password" autocomplete="new-password" maxlength="16" required>
</label>
<button class="button" type="submit">Create Blue Burst Account</button>
<div class="bb-message" role="status"></div>
</form>
`;
}
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 = `
<div class="account-control-line">
<span class="account-control-label">Email</span>
<strong>${escapeHtml(emailText)}</strong>
<span class="account-email-state ${verified ? "is-verified" : "is-pending"}">
${verified ? "Verified" : "Verification needed"}
</span>
<button class="inline-link account-email-update" type="button">update email address</button>
<button class="inline-link account-password-update" type="button">change password</button>
</div>
<form class="account-inline-form account-email-form" hidden>
<label class="sr-only" for="account-email-input">New email address</label>
<input id="account-email-input" name="email" type="email" placeholder="new email address" autocomplete="email">
<button class="button-compact" type="submit">Send Verification Email</button>
<button class="button-compact button-secondary" type="button" data-email-cancel>Cancel</button>
</form>
<form class="account-inline-form account-password-form" hidden>
<label class="sr-only" for="account-current-password">Current password</label>
<input id="account-current-password" name="current_password" type="password" placeholder="current password" autocomplete="current-password">
<label class="sr-only" for="account-new-password">New password</label>
<input id="account-new-password" name="password" type="password" placeholder="new password" autocomplete="new-password">
<label class="sr-only" for="account-confirm-password">Confirm new password</label>
<input id="account-confirm-password" name="confirm_password" type="password" placeholder="confirm new password" autocomplete="new-password">
<button class="button-compact" type="submit">Change Password</button>
<button class="button-compact button-secondary" type="button" data-password-cancel>Cancel</button>
</form>
<p class="account-control-message" role="status"></p>
`;
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 = `
<section class="card account-hero-card" aria-labelledby="account-title">
<div>
<p class="eyebrow">Account Dashboard</p>
<h1 id="account-title">Sign in or register</h1>
<p>Use the account form at the top right to create or open your PSO Peeps website account.</p>
</div>
<div class="status-badges" aria-label="Account setup status">
<span class="badge badge--warn">Not signed in</span>
</div>
</section>
`;
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);
})();
+94
View File
@@ -0,0 +1,94 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Bestiary - PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="app.js?v=saves-synced-20260609-2" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="top-account-status"><span class="status-dot" aria-hidden="true"></span><a href="account-ready.html">Signed in as chuudoku</a></div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
</nav>
<main class="placeholder-layout">
<section class="card placeholder-control-card">
<h1 class="section-title">Bestiary</h1>
<form class="placeholder-form">
<label for="bestiary-version">Version</label>
<select id="bestiary-version">
<option>V2</option>
<option>V3</option>
<option>V4</option>
</select>
<label for="bestiary-episode" data-bestiary-episode-wrap hidden>Episode</label>
<select id="bestiary-episode" data-bestiary-episode-wrap hidden>
<option>Episode 1</option>
<option>Episode 2</option>
<option>Episode 4</option>
</select>
<label for="bestiary-tier" data-bestiary-tier-wrap>BP Tier</label>
<select id="bestiary-tier" data-bestiary-tier-wrap>
<option>BP+1</option><option>BP+2</option><option>BP+3</option>
<option>BP+4</option><option>BP+5</option><option>BP+6</option>
<option>BP+7</option><option>BP+8</option><option>BP+9</option>
<option>BP+10</option><option>BP+11</option>
</select>
</form>
</section>
<section class="card placeholder-results-card">
<div class="blank-data-box" id="bestiary-placeholder">Bestiary data placeholder</div>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
</body>
</html>
+93
View File
@@ -0,0 +1,93 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Drops - PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<script src="app.js?v=saves-synced-20260609-2" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="top-account-status"><span class="status-dot" aria-hidden="true"></span><a href="account-ready.html">Signed in as chuudoku</a></div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
</nav>
<main class="placeholder-layout">
<section class="card placeholder-control-card">
<h1 class="section-title">Drops</h1>
<form class="placeholder-form">
<label for="drops-mode">Drop Table</label>
<select id="drops-mode">
<option value="peeps">Peeps</option>
<option value="hardcore">Hardcore</option>
</select>
<label for="drops-version" data-drops-version-wrap>Version</label>
<select id="drops-version" data-drops-version-wrap>
<option>V1</option>
<option>V2</option>
<option>V3</option>
<option>V4</option>
</select>
<label for="drops-episode" data-drops-episode-wrap hidden>Episode</label>
<select id="drops-episode" data-drops-episode-wrap hidden>
<option>Episode 1</option>
<option>Episode 2</option>
<option>Episode 4</option>
</select>
</form>
</section>
<section class="card placeholder-results-card">
<div class="blank-data-box" id="drops-placeholder">Drop table placeholder</div>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
</body>
</html>
View File
+1
View File
@@ -0,0 +1 @@
// disabled: using static hero image
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+95
View File
@@ -0,0 +1,95 @@
(() => {
"use strict";
function escapeHtml(value) {
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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 = `<li><span class="rank">!</span><span>${escapeHtml(lastError?.message || "Unable to load.")}</span></li>`;
return;
}
const top = rows.slice(0, 5);
if (!top.length) {
list.innerHTML = `<li><span class="rank">1.</span><span>—</span></li>`;
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 `<li>
<span class="rank">${rank}.</span>
<span class="home-hardcore-line">${escapeHtml(line)}</span>
</li>`;
}).join("");
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", loadHomeHardcoreLeaderboard);
} else {
loadHomeHardcoreLeaderboard();
}
})();
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

+144
View File
@@ -0,0 +1,144 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PSO Peeps</title>
<meta name="description" content="PSO Peeps private multi-platform Phantasy Star Online server.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=home-hardcore-preline-20260610-1">
<script src="app.js?v=account-status-label-20260609" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<form class="top-account-form" action="#" method="post" aria-label="Account login or registration">
<label class="sr-only" for="top-username">Username</label>
<input id="top-username" name="username" type="text" placeholder="username" autocomplete="username">
<label class="sr-only" for="top-password">Password</label>
<input id="top-password" name="password" type="password" placeholder="password" autocomplete="current-password">
<button class="button-compact" type="submit" formaction="#login">Login</button>
<button class="button-compact button-secondary" type="submit" formaction="#register">Register</button>
</form>
</header>
<section class="hero" aria-label="Phantasy Star Online artwork">
<div class="hero-image" role="img" aria-label="PSO Peeps hero artwork placeholder"></div>
</section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a>
<a href="leaderboards.html">Leaderboards</a>
<a href="drops.html">Drops</a>
<a href="bestiary.html">Bestiary</a>
<a href="account-ready.html">Account</a>
</nav>
<main class="main-grid main-grid--home">
<section class="card server-card" aria-labelledby="server-status-heading">
<p class="server-blurb">
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.
</p>
<h1 id="server-status-heading" class="section-title">Server Status</h1>
<div class="status-list" role="list" aria-label="Current server player counts">
<div class="status-row status-parent" role="listitem"><span>US Server</span><span></span></div>
<div class="status-row status-parent" role="listitem"><span>Alis</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>V2</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>V3</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>BB</span><span>0 Players</span></div>
<div class="status-row status-parent" role="listitem"><span>Abion</span><span></span></div>
<div class="status-row status-child" role="listitem"><span>HC</span><span>0 Players</span></div>
<div class="status-row status-parent" role="listitem"><span>EU Server</span><span></span></div>
<div class="status-row status-parent" role="listitem"><span>Palma</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>V2</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>V3</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>BB</span><span>0 Players</span></div>
<div class="status-row status-parent" role="listitem"><span>Aiedo</span><span></span></div>
<div class="status-row status-child" role="listitem"><span>HC</span><span>0 Players</span></div>
<div class="status-row status-parent" role="listitem"><span>PSP Ship</span><span></span></div>
<div class="status-row status-child" role="listitem"><span>PSP1</span><span>0 Players</span></div>
<div class="status-row status-child" role="listitem"><span>PSP2i</span><span>0 Players</span></div>
</div>
</section>
<aside class="right-stack" aria-label="Account and leaderboard panels">
<section class="card quick-account-card" aria-labelledby="quick-account-heading">
<h2 id="quick-account-heading" class="section-title">Account Setup</h2>
<p class="card-copy">
One website account owns your game identity. Blue Burst saves, V2/V3 key profiles, and server backups attach to the account name.
</p>
<a class="small-link" href="account-ready.html">Open account dashboard</a>
</section>
<section class="card leaderboard-card" aria-labelledby="hardcore-heading">
<h2 id="hardcore-heading" class="section-title">Hardcore Leaderboard</h2>
<ol class="leaderboard-list leaderboard-list--home-hardcore" id="home-hardcore-leaderboard-body" aria-label="Top five hardcore players">
<li><span class="rank">1.</span><span>Loading...</span></li>
</ol>
<a class="small-link home-leaderboard-more" href="leaderboards.html">more</a>
</section>
<section class="card leaderboard-card" aria-labelledby="crank-heading">
<h2 id="crank-heading" class="section-title">C Rank Points</h2>
<ol class="leaderboard-list" aria-label="Top five C Rank point totals">
<li><span class="rank">1.</span><span></span></li>
<li><span class="rank">2.</span><span></span></li>
<li><span class="rank">3.</span><span></span></li>
<li><span class="rank">4.</span><span></span></li>
<li><span class="rank">5.</span><span></span></li>
</ol>
</section>
</aside>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="hero-cycle.js?v=force-cycle-3" defer></script>
<script src="home-leaderboard.js?v=home-hardcore-preline-20260610-1" defer></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leaderboards - PSO Peeps</title>
<meta name="description" content="PSO Peeps account dashboard prototype.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css?v=leaderboard-table-restore-20260610-1">
<script src="app.js?v=saves-synced-20260609-2" defer></script>
</head>
<body>
<div class="site-shell">
<header class="site-header site-header--accountline" aria-label="Site header">
<a class="brand" href="index.html" aria-label="PSO Peeps home">
<span class="brand-logo brand-logo--text" aria-hidden="true">P</span>
<span class="brand-name">PSO PEEPS</span>
</a>
<div class="top-account-status"><span class="status-dot" aria-hidden="true"></span><a href="account-ready.html">Signed in as chuudoku</a></div>
</header>
<section class="hero hero--slim" aria-label="Phantasy Star Online artwork"><div class="hero-image" role="img" aria-label="PSO Peeps hero artwork"></div></section>
<nav class="nav-bar" aria-label="Primary navigation">
<a href="https://circlewithadot.gitbook.io/psopeeps/connection-guide/connection-guide" target="_blank" rel="noopener noreferrer">Connection Guide</a><a href="leaderboards.html">Leaderboards</a><a href="drops.html">Drops</a><a href="bestiary.html">Bestiary</a><a href="account-ready.html">Account</a>
</nav>
<main class="placeholder-layout">
<section class="card placeholder-control-card">
<h1 class="section-title">Leaderboards</h1>
<form class="placeholder-form">
<label for="leaderboard-mode">Leaderboard</label>
<select id="leaderboard-mode">
<option value="hardcore">Hardcore</option>
<option value="cmode">CMode</option>
<option value="hardcore-cmode">Hardcore CMode</option>
</select>
<div id="leaderboard-page-size-wrap">
<label for="leaderboard-page-size">Show</label>
<select id="leaderboard-page-size">
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="25">25</option>
</select>
</div>
</form>
</section>
<section class="card placeholder-results-card">
<div class="blank-data-box leaderboard-box" id="leaderboard-placeholder">Leaderboard data placeholder</div>
</section>
</main>
<footer class="footer-bar">
<div class="social-links" aria-label="Social links">
<a href="#" aria-label="Discord">
<img src="icons/discord.png" alt="" width="24" height="24">
<span>Discord</span>
</a>
<a href="#" aria-label="Mastodon">
<img src="icons/mastodon.png" alt="" width="24" height="24">
<span>Mastodon</span>
</a>
<a href="#" aria-label="Bluesky">
<img src="icons/bluesky.png" alt="" width="24" height="24">
<span>Bluesky</span>
</a>
</div>
<div class="footer-legal" aria-label="Site license and software credits">
<p>
All content on this website is licensed under
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/" target="_blank" rel="noopener noreferrer">CC BY-NC-SA 4.0</a>.
</p>
<p>
PSO Peeps uses a modified version of
<a href="https://github.com/fuzziqersoftware/newserv" target="_blank" rel="noopener noreferrer">newserv</a>
from fuzziqersoftware.
<a href="https://github.com/fuzziqersoftware/newserv/blob/master/LICENSE" target="_blank" rel="noopener noreferrer">LICENSE</a>
</p>
</div>
</footer>
</div>
<script src="placeholder-pages.js?v=hardcore-leaderboard-table-restore-20260610-1" defer></script>
</body>
</html>
+313
View File
@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[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 = `<div class="leaderboard-status">Loading Hardcore leaderboard...</div>`;
return;
}
if (leaderboardState.error) {
box.innerHTML = `<div class="leaderboard-status leaderboard-status--error">${escapeHtml(leaderboardState.error)}</div>`;
return;
}
if (!leaderboardState.loaded) {
box.innerHTML = `<div class="leaderboard-status">Leaderboard data will load here.</div>`;
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 `<th><button type="button" class="leaderboard-sort" data-sort="${col.key}">${escapeHtml(col.label)}${marker}</button></th>`;
}).join("");
const body = pageRows.map((row, idx) => {
const rank = start + idx + 1;
return `<tr>
<td data-label="Rank">${rank}</td>
<td data-label="Player Name">${escapeHtml(row.name)}</td>
<td data-label="Points">${fmtNumber(row.points)}</td>
<td data-label="Status">${escapeHtml(row.status)}</td>
<td data-label="Class">${escapeHtml(row.class || "—")}</td>
<td data-label="SecID">${escapeHtml(row.secid || "—")}</td>
<td data-label="Kills">${fmtNumber(row.kills)}</td>
<td data-label="Playtime" data-sort-value="${row.playtime}">${escapeHtml(fmtPlaytime(row.playtime))}</td>
</tr>`;
}).join("");
box.innerHTML = `
<div class="leaderboard-table-wrap">
<table class="leaderboard-table">
<thead><tr>${head}</tr></thead>
<tbody>${body || `<tr><td colspan="8">No Hardcore leaderboard rows yet.</td></tr>`}</tbody>
</table>
</div>
<div class="leaderboard-pager">
<button type="button" id="leaderboard-prev" ${leaderboardState.page <= 1 ? "disabled" : ""}>Previous</button>
<span>Page ${leaderboardState.page} of ${totalPages}</span>
<button type="button" id="leaderboard-next" ${leaderboardState.page >= totalPages ? "disabled" : ""}>Next</button>
</div>
`;
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]) => `<option value="${value}">${label}</option>`).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();
}
})();
+2739
View File
File diff suppressed because it is too large Load Diff