Initial psopeeps site import
@@ -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/
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Flask==3.0.3
|
||||
gunicorn==22.0.0
|
||||
psycopg[binary]==3.2.1
|
||||
@@ -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
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,555 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const API = {
|
||||
list: "/api/keys",
|
||||
register: "/api/keys/register",
|
||||
delete: (id) => `/api/keys/${encodeURIComponent(id)}`,
|
||||
reveal: (id) => `/api/keys/${encodeURIComponent(id)}/access-key`,
|
||||
};
|
||||
|
||||
let bound = false;
|
||||
|
||||
function qs(sel, root = document) {
|
||||
return root.querySelector(sel);
|
||||
}
|
||||
|
||||
function esc(v) {
|
||||
return String(v ?? "").replace(/[&<>"']/g, ch => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
"\"": """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
async function readJson(res) {
|
||||
const text = await res.text();
|
||||
if (!text) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { error: text };
|
||||
}
|
||||
}
|
||||
|
||||
function formEl() {
|
||||
const version = qs("#key-version");
|
||||
return version ? version.closest("form") : null;
|
||||
}
|
||||
|
||||
function listEl() {
|
||||
return qs(".key-list");
|
||||
}
|
||||
|
||||
function setMessage(text, kind = "") {
|
||||
const form = formEl();
|
||||
if (!form) return;
|
||||
|
||||
let box = qs("#key-profile-status");
|
||||
if (!box) {
|
||||
box = document.createElement("p");
|
||||
box.id = "key-profile-status";
|
||||
box.className = "key-status-message";
|
||||
form.insertAdjacentElement("afterend", box);
|
||||
}
|
||||
|
||||
box.textContent = text || "";
|
||||
box.dataset.kind = kind;
|
||||
}
|
||||
|
||||
function normalizeSerial(raw, gameVersion) {
|
||||
const value = String(raw || "").trim();
|
||||
|
||||
if (gameVersion === "dc_v2") {
|
||||
let v = value.toUpperCase();
|
||||
if (v.startsWith("0X")) v = v.slice(2);
|
||||
if (/^[0-9A-F]{1,8}$/.test(v)) {
|
||||
return v.padStart(8, "0");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (gameVersion === "pc_v2") {
|
||||
if (/^[0-9]{1,10}$/.test(value)) {
|
||||
const n = Number(value);
|
||||
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
||||
return n.toString(16).toUpperCase().padStart(8, "0");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
if (gameVersion === "gc_v3") {
|
||||
if (/^[0-9]{2}-[0-9]{4}-[0-9]{4}$/.test(value)) {
|
||||
const n = Number(value.replaceAll("-", ""));
|
||||
if (Number.isSafeInteger(n) && n >= 0 && n <= 0xFFFFFFFF) {
|
||||
return n.toString(16).toUpperCase().padStart(8, "0");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function setupForm(form) {
|
||||
const version = qs("#key-version", form);
|
||||
if (version) {
|
||||
version.innerHTML = [
|
||||
'<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();
|
||||
}
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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 || {});
|
||||
});
|
||||
})();
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,614 @@
|
||||
(() => {
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (_) {}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function replaceText(root, from, to) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const nodes = [];
|
||||
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.nodeValue.includes(from)) {
|
||||
nodes.push(walker.currentNode);
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
node.nodeValue = node.nodeValue.replaceAll(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLoginForm() {
|
||||
const form = document.createElement("form");
|
||||
form.className = "top-account-form";
|
||||
form.action = "#";
|
||||
form.method = "post";
|
||||
form.setAttribute("aria-label", "Account login or registration");
|
||||
|
||||
let mode = "login";
|
||||
|
||||
function render() {
|
||||
form.classList.toggle("is-register", mode === "register");
|
||||
form.classList.toggle("is-login", mode !== "register");
|
||||
|
||||
if (mode === "register") {
|
||||
form.innerHTML = `
|
||||
<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);
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// disabled: using static hero image
|
||||
|
After Width: | Height: | Size: 149 KiB |
@@ -0,0 +1,95 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function fmtNumber(value) {
|
||||
const n = Number(value || 0);
|
||||
return Number.isFinite(n) ? n.toLocaleString() : "0";
|
||||
}
|
||||
|
||||
function statusText(row) {
|
||||
if (row.Alive === false || row.alive === false) return "Dead";
|
||||
return "Alive";
|
||||
}
|
||||
|
||||
function fitName(value, width) {
|
||||
const s = String(value || "—").trimEnd();
|
||||
if (s.length <= width) return s.padEnd(width, " ");
|
||||
return s.slice(0, Math.max(0, width - 1)) + "…";
|
||||
}
|
||||
|
||||
function fitPoints(value, width) {
|
||||
return `${fmtNumber(value)} pts`.padStart(width, " ");
|
||||
}
|
||||
|
||||
async function loadHomeHardcoreLeaderboard() {
|
||||
const list = document.querySelector("#home-hardcore-leaderboard-body");
|
||||
if (!list) return;
|
||||
|
||||
const cacheBucket = Math.floor(Date.now() / 300000);
|
||||
const urls = [
|
||||
`/generated/hardcore-leaderboard-points.json?v=${cacheBucket}`,
|
||||
"/hardcore/leaderboard/points",
|
||||
];
|
||||
|
||||
let rows = null;
|
||||
let lastError = null;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`${url}: HTTP ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
rows = Array.isArray(data) ? data : (data.rows || []);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows) {
|
||||
list.innerHTML = `<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();
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 221 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,313 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
function qs(sel) {
|
||||
return document.querySelector(sel);
|
||||
}
|
||||
|
||||
function setText(id, text) {
|
||||
const el = qs(id);
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
const leaderboardState = {
|
||||
rows: [],
|
||||
sortKey: "points",
|
||||
sortDir: "desc",
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const leaderboardColumns = [
|
||||
{ key: "rank", label: "Rank", numeric: true },
|
||||
{ key: "name", label: "Player Name" },
|
||||
{ key: "points", label: "Points", numeric: true },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "class", label: "Class" },
|
||||
{ key: "secid", label: "SecID" },
|
||||
{ key: "kills", label: "Kills", numeric: true },
|
||||
{ key: "playtime", label: "Playtime", numeric: true },
|
||||
];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
function fmtNumber(value) {
|
||||
const n = Number(value || 0);
|
||||
return Number.isFinite(n) ? n.toLocaleString() : "0";
|
||||
}
|
||||
|
||||
function fmtPlaytime(seconds) {
|
||||
const total = Number(seconds || 0);
|
||||
if (!Number.isFinite(total) || total <= 0) return "0h";
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
if (hours <= 0) return `${minutes}m`;
|
||||
return minutes ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function normalizeLeaderboardRow(row, index) {
|
||||
return {
|
||||
originalRank: index + 1,
|
||||
name: row.PlayerName || row.CharacterName || row.character_name || "",
|
||||
points: Number(row.Points ?? row.TotalPoints ?? 0),
|
||||
class: row.Class || row.character_class || "",
|
||||
secid: row.SecID || row.section_id || "",
|
||||
kills: Number(row.Kills ?? row.TotalKills ?? row.total_enemies_killed ?? 0),
|
||||
status: (row.Alive === false || row.alive === false) ? "Dead" : "Alive",
|
||||
playtime: Number(row.PlayTimeSeconds ?? row.play_time_seconds ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchHardcoreLeaderboard() {
|
||||
leaderboardState.loading = true;
|
||||
leaderboardState.error = null;
|
||||
renderHardcoreLeaderboard();
|
||||
|
||||
const cacheBucket = Math.floor(Date.now() / 300000);
|
||||
const urls = [
|
||||
`/generated/hardcore-leaderboard-points.json?v=${cacheBucket}`,
|
||||
"/api/hardcore/leaderboard/points",
|
||||
"/hardcore/leaderboard/points",
|
||||
];
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const res = await fetch(url, { credentials: "same-origin" });
|
||||
if (!res.ok) {
|
||||
lastError = new Error(`${url}: HTTP ${res.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const rows = Array.isArray(data) ? data : (data.rows || data.characters || []);
|
||||
leaderboardState.rows = rows.map(normalizeLeaderboardRow);
|
||||
leaderboardState.loaded = true;
|
||||
leaderboardState.loading = false;
|
||||
leaderboardState.page = 1;
|
||||
renderHardcoreLeaderboard();
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
leaderboardState.loading = false;
|
||||
leaderboardState.error = lastError ? String(lastError.message || lastError) : "Unable to load leaderboard.";
|
||||
renderHardcoreLeaderboard();
|
||||
}
|
||||
|
||||
function sortedLeaderboardRows() {
|
||||
const key = leaderboardState.sortKey;
|
||||
const dir = leaderboardState.sortDir === "asc" ? 1 : -1;
|
||||
const col = leaderboardColumns.find((c) => c.key === key);
|
||||
const numeric = !!col?.numeric;
|
||||
|
||||
return [...leaderboardState.rows].sort((a, b) => {
|
||||
let av = key === "rank" ? a.originalRank : a[key];
|
||||
let bv = key === "rank" ? b.originalRank : b[key];
|
||||
|
||||
if (numeric) {
|
||||
av = Number(av || 0);
|
||||
bv = Number(bv || 0);
|
||||
return (av - bv) * dir;
|
||||
}
|
||||
|
||||
return String(av || "").localeCompare(String(bv || "")) * dir;
|
||||
});
|
||||
}
|
||||
|
||||
function renderHardcoreLeaderboard() {
|
||||
const box = qs("#leaderboard-placeholder");
|
||||
if (!box) return;
|
||||
|
||||
if (leaderboardState.loading) {
|
||||
box.innerHTML = `<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();
|
||||
}
|
||||
})();
|
||||