From 7b7d31bdfa59313f286925d26dd9b9bb5e4bf688 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 14 Sep 2026 19:40:58 -0400 Subject: [PATCH] Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata --- .env.example | 6 +- Dockerfile | 10 +- app/config.py | 5 + app/covers.py | 15 +- app/db.py | 31 ++ app/gui.py | 19 +- app/info.py | 15 +- app/server_client.py | 112 +++++++ app/transfers.py | 13 +- docker-compose.yml | 9 +- requirements-server.txt | 4 + server/__init__.py | 1 + server/api.py | 491 +++++++++++++++++++++++++++++ server/config.py | 9 + server/main.py | 39 +++ server/security.py | 138 ++++++++ server/static/app.css | 52 +++ server/templates/base.html | 26 ++ server/templates/dashboard.html | 37 +++ server/templates/login.html | 13 + server/templates/release_edit.html | 31 ++ server/templates/releases.html | 29 ++ server/templates/settings.html | 11 + server/templates/store.html | 50 +++ server/templates/users.html | 41 +++ server/web.py | 339 ++++++++++++++++++++ 26 files changed, 1528 insertions(+), 18 deletions(-) create mode 100644 app/server_client.py create mode 100644 requirements-server.txt create mode 100644 server/__init__.py create mode 100644 server/api.py create mode 100644 server/config.py create mode 100644 server/main.py create mode 100644 server/security.py create mode 100644 server/static/app.css create mode 100644 server/templates/base.html create mode 100644 server/templates/dashboard.html create mode 100644 server/templates/login.html create mode 100644 server/templates/release_edit.html create mode 100644 server/templates/releases.html create mode 100644 server/templates/settings.html create mode 100644 server/templates/store.html create mode 100644 server/templates/users.html create mode 100644 server/web.py diff --git a/.env.example b/.env.example index 452ad22..8fc7613 100644 --- a/.env.example +++ b/.env.example @@ -5,8 +5,10 @@ PSP_LAN_IP=192.168.10.100 # Host paths mounted into the container (Unraid shares). PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable +PSP_STORE_VIDEOS_HOST=/mnt/user/psp/umd_videos_store PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos -# Secret token the desktop app sends as `Authorization: Bearer `. -PSP_API_TOKEN= +# Initial admin account created on first boot (change the password after login). +PSP_ADMIN_USERNAME=admin +PSP_ADMIN_PASSWORD=change-me-now diff --git a/Dockerfile b/Dockerfile index 3276805..e9ee852 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,8 +2,11 @@ FROM python:3.13-slim WORKDIR /app -# The API is stdlib-only (no pip install needed). +COPY requirements-server.txt ./ +RUN pip install --no-cache-dir -r requirements-server.txt + COPY app ./app +COPY server ./server ENV PSP_DATA_DIR=/data \ PSP_DATS_DIR=/data/dats \ @@ -11,8 +14,9 @@ ENV PSP_DATA_DIR=/data \ PSP_COVERS_DIR=/data/covers \ PSP_GAMES_ROOTS=/data/umd_games \ PSP_VIDEO_ROOTS=/data/umd_videos \ - PSP_STORE_DIR=/data/store + PSP_STORE_DIR=/data/store \ + PSP_STORE_VIDEOS_DIR=/data/store_videos EXPOSE 8000 -CMD ["python", "-m", "app.api", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/config.py b/app/config.py index d9e50f2..ae66fa1 100644 --- a/app/config.py +++ b/app/config.py @@ -86,6 +86,11 @@ COVERS_BUNDLE_URL = os.environ.get( # archive.org item hosting the same Redump 7z set (identifier or full URL). ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com") +# UMD video store: a second archive root (mirrors STORE_ROOT) and the +# archive.org item hosting UMD video rips. +STORE_VIDEOS_ROOT = Path(os.environ.get("PSP_STORE_VIDEOS_DIR", "/data/store_videos")) +ARCHIVE_VIDEOS_ITEM = os.environ.get("PSP_ARCHIVE_VIDEOS_ITEM", "the-psp-movie-collection-so-far_202607") + # Optional archive.org login cookies (for items whose files are "private" / # login-required). Grab these from a logged-in browser session on archive.org. ARCHIVE_LOGGED_IN_SIG = os.environ.get("PSP_ARCHIVE_LOGGED_IN_SIG", "") diff --git a/app/covers.py b/app/covers.py index 1deec8a..e1b0724 100644 --- a/app/covers.py +++ b/app/covers.py @@ -19,7 +19,7 @@ import urllib.request import zipfile from pathlib import Path -from . import config, sources +from . import config, server_client, sources from .db import get_connection log = logging.getLogger("covers") @@ -1220,6 +1220,19 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False): except OSError: pass + # Server is authoritative for covers when configured — no libretro/IGDB + # fallback; if the server is unreachable the cover just stays empty. + if server_client.configured(): + try: + data = server_client.fetch_cover(system, name) + except Exception as exc: # noqa: BLE001 + log.warning("server cover fetch failed for %r: %s", name, exc) + return None + if data is not None and _is_image(data): + _write_atomic(dest, data) + return dest + return None + key = str(release_id) # In-memory + persistent negative caches: skip the network for recent misses. last = _neg_cache.get(key) diff --git a/app/db.py b/app/db.py index 7f8bd25..3b6e611 100644 --- a/app/db.py +++ b/app/db.py @@ -115,6 +115,37 @@ CREATE TABLE IF NOT EXISTS ps1_entries ( error TEXT, updated_at TEXT NOT NULL ); + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', -- admin / editor / user + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + last_login_at TEXT +); + +CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + label TEXT, + kind TEXT NOT NULL DEFAULT 'api', -- api / session + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT, + revoked_at TEXT +); + +CREATE TABLE IF NOT EXISTS download_events ( + id INTEGER PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + system TEXT NOT NULL, + name TEXT NOT NULL, + bytes INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); """ diff --git a/app/gui.py b/app/gui.py index 6c5aab2..85f9528 100644 --- a/app/gui.py +++ b/app/gui.py @@ -22,7 +22,7 @@ from PySide6.QtWidgets import ( QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QGroupBox, ) -from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, transfers +from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, server_client, sources, store, style, theme, transfers from .db import get_connection, init_db log = logging.getLogger("gui") @@ -1200,7 +1200,8 @@ class SettingsPage(QWidget): # -- Store ----------------------------------------------------------- # box, form = self._group("Store") self.store_server_edit = self._line_row(form, "Server URL", store_server_url) - self.store_token_edit = self._secret_row(form, "API token", store_token) + self.store_user_edit = self._line_row(form, "Server username", server_client.username()) + self.store_pass_edit = self._secret_row(form, "Server password", "") self.archive_edit = self._line_row(form, "Archive.org item", store_archive_id) self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig", store_archive_sig) self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user", store_archive_user) @@ -1430,7 +1431,7 @@ class SettingsPage(QWidget): return self.store_server_edit.text().strip() def store_token(self): - return self.store_token_edit.text().strip() + return server_client.token() def archive_item(self): return self.archive_edit.text().strip() @@ -1480,6 +1481,18 @@ class SettingsPage(QWidget): def _save(self): sources.save({key: edit.text() for key, edit in self.fields.items()}) covers.clear_miss_cache() + # Log in to the content server (stores a per-user API token). + url = self.store_server_edit.text().strip() + user = self.store_user_edit.text().strip() + pw = self.store_pass_edit.text() + if url and user and pw: + try: + server_client.login(url, user, pw) + except Exception as exc: # noqa: BLE001 + QMessageBox.warning(self, "Server login failed", + f"Could not log in to the server:\n{exc}") + elif url and not user: + server_client.logout() s = QSettings("psppt", "psppt") s.setValue("auto_scan", self.auto_scan.isChecked()) s.setValue("scan_workers", self.workers_spin.value()) diff --git a/app/info.py b/app/info.py index 48f9be0..814bba8 100644 --- a/app/info.py +++ b/app/info.py @@ -12,7 +12,7 @@ import json import sqlite3 import time -from . import config, covers +from . import config, covers, server_client from .db import get_connection _failed = {} # (release_id, system) -> time.time() of last failed fetch @@ -199,6 +199,19 @@ def get_or_fetch(release_id, system, fetch_title): info = load(release_id, system) if info is not None: return info + # Server is authoritative for metadata when configured — no IGDB/TMDB + # fallback; an unreachable server just leaves metadata empty. + if server_client.configured(): + try: + meta = server_client.fetch_metadata(system, fetch_title) + except Exception as exc: # noqa: BLE001 + log.warning("server metadata fetch failed for %r: %s", fetch_title, exc) + return None + if meta: + save(release_id, system, meta, manual=False) + meta["manual"] = False + return meta + return None key = (str(release_id), system) last = _failed.get(key) if last is not None and time.time() - last < _FAIL_TTL: diff --git a/app/server_client.py b/app/server_client.py new file mode 100644 index 0000000..c68081f --- /dev/null +++ b/app/server_client.py @@ -0,0 +1,112 @@ +"""Client-side HTTP access to the psppt server (auth + content fetches). + +The server is authoritative for covers and metadata. When a server is +configured (URL + per-user token), the cover/metadata paths use it and show an +error rather than falling back to libretro/IGDB/etc. +""" +import json +import logging +import urllib.error +import urllib.parse +import urllib.request + +from . import config + +log = logging.getLogger("server_client") + +_CFG_FILE = config.STATE_DIR / "server.json" +_cache = {"loaded": False, "cfg": {}} + + +def _load(): + if not _cache["loaded"]: + cfg = {} + try: + cfg = json.loads(_CFG_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + cfg = {} + _cache["cfg"] = cfg + _cache["loaded"] = True + return _cache["cfg"] + + +def _save(cfg): + _CFG_FILE.parent.mkdir(parents=True, exist_ok=True) + _CFG_FILE.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + _cache["cfg"] = cfg + _cache["loaded"] = True + + +def configured(): + cfg = _load() + return bool(cfg.get("url") and cfg.get("token")) + + +def url(): + return (_load().get("url") or config.STORE_SERVER_URL or "").rstrip("/") + + +def token(): + return _load().get("token") or config.STORE_TOKEN or "" + + +def username(): + return _load().get("username") or "" + + +def logout(): + _save({"url": url(), "token": "", "username": ""}) + + +def login(server_url, user, password): + """Authenticate against the server and store the per-user token.""" + base = (server_url or "").rstrip("/") + req = urllib.request.Request( + f"{base}/api/auth/login", + data=json.dumps({"username": user, "password": password}).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "psppt/0.1"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=15) as resp: + data = json.loads(resp.read()) + _save({"url": base, "token": data.get("token") or "", "username": data.get("username") or user}) + return data + + +def _get(path, timeout=15): + req = urllib.request.Request( + f"{url()}{path}", + headers={"Authorization": f"Bearer {token()}", "User-Agent": "psppt/0.1"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + + +def _lookup_id(system, name): + """Return the server release id for a name (first match), or None.""" + q = urllib.parse.quote(name or "") + data = json.loads(_get(f"/api/releases?system={system}&q={q}&limit=1", timeout=15)) + items = data.get("items") or [] + return items[0]["id"] if items else None + + +def fetch_cover(system, name): + """Return cover bytes for a release, or None (no cover on the server).""" + rid = _lookup_id(system, name) + if rid is None: + return None + try: + return _get(f"/api/cover/{rid}", timeout=30) + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise + + +def fetch_metadata(system, name): + """Return metadata dict for a release, or None.""" + rid = _lookup_id(system, name) + if rid is None: + return None + detail = json.loads(_get(f"/api/releases/{rid}", timeout=15)) + return detail.get("meta") or {} diff --git a/app/transfers.py b/app/transfers.py index 395cd28..39486f6 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -306,9 +306,10 @@ class RemoteStoreSource(Source): label = "Remote store" - def __init__(self, base_url, token=""): + def __init__(self, base_url, token="", system="games"): self.base_url = (base_url or "").rstrip("/") self.token = token or "" + self.system = system or "games" def _headers(self): headers = {"User-Agent": "psppt/0.1"} @@ -319,10 +320,13 @@ class RemoteStoreSource(Source): def scan(self, root=None): if not self.base_url: return [] - req = urllib.request.Request(f"{self.base_url}/api/store", headers=self._headers()) + req = urllib.request.Request(f"{self.base_url}/api/store?system={self.system}", headers=self._headers()) with urllib.request.urlopen(req, timeout=30) as resp: data = json.loads(resp.read()) - return data.get("items", []) if isinstance(data, dict) else [] + items = data.get("items", []) if isinstance(data, dict) else [] + for it in items: + it["system"] = it.get("system") or self.system + return items def download(self, item, dest_dir, progress_cb=None, cancel_event=None): dest_dir = Path(dest_dir) @@ -330,10 +334,11 @@ class RemoteStoreSource(Source): # The listing's "name" has no extension; "filename" carries the real # archive filename (e.g. "Game (USA).7z") used for the download URL. name = item.get("filename") or item["name"] + system = item.get("system") or self.system tmp = Path(tempfile.mkdtemp(prefix="psp-store-")) archive = tmp / name try: - url = f"{self.base_url}/api/store/download/{urllib.parse.quote(name, safe='')}" + url = f"{self.base_url}/api/store/download/{system}/{urllib.parse.quote(name, safe='')}" download_url(url, archive, self._headers(), progress_cb, cancel_event) iso_name = peek_archive(archive, cancel_event) if not iso_name: diff --git a/docker-compose.yml b/docker-compose.yml index 8f551cb..60fb667 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,13 +15,14 @@ services: PSP_GAMES_ROOTS: /data/umd_games PSP_VIDEO_ROOTS: /data/umd_videos PSP_STORE_DIR: /data/store - # Secret token the desktop app sends as `Authorization: Bearer `. - # Set in the .env file next to this compose file. - PSP_API_TOKEN: ${PSP_API_TOKEN:-} + PSP_STORE_VIDEOS_DIR: /data/store_videos + PSP_ADMIN_USERNAME: ${PSP_ADMIN_USERNAME:-admin} + PSP_ADMIN_PASSWORD: ${PSP_ADMIN_PASSWORD:-admin} volumes: - ./data:/data # Host paths come from .env (see .env.example): - - ${PSP_STORE_HOST}:/data/store:ro + - ${PSP_STORE_HOST}:/data/store + - ${PSP_STORE_VIDEOS_HOST}:/data/store_videos - ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro - ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro restart: unless-stopped diff --git a/requirements-server.txt b/requirements-server.txt new file mode 100644 index 0000000..7a2f6a6 --- /dev/null +++ b/requirements-server.txt @@ -0,0 +1,4 @@ +fastapi>=0.110 +uvicorn[standard]>=0.29 +jinja2>=3.1 +python-multipart>=0.0.9 diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 0000000..30bc714 --- /dev/null +++ b/server/__init__.py @@ -0,0 +1 @@ +"""psppt server package (FastAPI + admin WebUI).""" diff --git a/server/api.py b/server/api.py new file mode 100644 index 0000000..625193f --- /dev/null +++ b/server/api.py @@ -0,0 +1,491 @@ +"""REST API for the psppt server (JSON, token-authenticated). + +Endpoints: + POST /api/auth/login -> {token, username, role} + GET /api/auth/me -> current user + POST /api/auth/logout -> revoke current token + GET /api/users -> list (admin) + POST /api/users -> create (admin) + PATCH /api/users/{id} -> update role/password/active (admin) + DELETE /api/users/{id} -> delete (admin) + GET /api/users/{id}/tokens -> list API tokens (admin or self) + DELETE /api/users/{id}/tokens/{tid} -> revoke a token (admin or self) + GET /api/releases?system=&q=&limit= -> catalog list + GET /api/releases/{id} -> detail + metadata + roms + PATCH /api/releases/{id} -> edit metadata (editor+) + GET /api/cover/{id} -> cover image + POST /api/cover/{id} -> upload/replace cover (editor+) + DELETE /api/cover/{id} -> delete cover (editor+) + GET /api/store?system=games|videos -> archive list + GET /api/store/download/{system}/{name} -> stream archive + POST /api/store/upload?system= -> upload archive (editor+) + DELETE /api/store/{system}/{name} -> delete archive (editor+) + GET /api/stats -> dashboard numbers (admin) +""" +import json +import re +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Header, HTTPException, Query, Request, UploadFile +from fastapi.responses import FileResponse, JSONResponse + +from app import covers, store +from app.db import get_connection + +from . import config +from .security import now_iso, require_role, revoke_token, get_current_user, hash_password, verify_password, issue_token + +router = APIRouter(prefix="/api") + +_META_FIELDS = ("release_date", "developer", "publisher", "genre", "synopsis", "title") +_SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._()\- ]") + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def _conn(): + return get_connection() + + +def _release_system(conn, release_id): + row = conn.execute( + "SELECT d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", + (release_id,), + ).fetchone() + return row["system"] if row else None + + +def _metadata(conn, release_id, system): + row = conn.execute( + "SELECT title, meta_json, manual FROM item_info WHERE release_id = ? AND system = ?", + (str(release_id), system), + ).fetchone() + meta = {} + if row and row["meta_json"]: + try: + meta = json.loads(row["meta_json"]) + except (TypeError, ValueError): + meta = {} + if row: + meta["title"] = row["title"] + meta["manual"] = bool(row["manual"]) + return meta + + +def _save_metadata(conn, release_id, system, fields): + key = str(release_id) + row = conn.execute( + "SELECT meta_json FROM item_info WHERE release_id = ? AND system = ?", (key, system) + ).fetchone() + meta = {} + if row and row["meta_json"]: + try: + meta = json.loads(row["meta_json"]) + except (TypeError, ValueError): + meta = {} + meta.update(fields) + title = fields.get("title") or meta.get("title") or "" + conn.execute( + "INSERT INTO item_info (release_id, system, title, meta_json, manual, updated_at) " + "VALUES (?, ?, ?, ?, 1, ?) " + "ON CONFLICT(release_id, system) DO UPDATE SET " + "title = excluded.title, meta_json = excluded.meta_json, manual = 1, updated_at = excluded.updated_at", + (key, system, title, json.dumps(meta, ensure_ascii=False), now_iso()), + ) + + +def _cover_paths(release_id, system): + base = config.COVERS_DIR / system / str(release_id) + return base, [base.with_suffix(ext) for ext in (".png", ".jpg", ".jpeg", ".webp")] + + +def _find_cover(release_id, system): + base, candidates = _cover_paths(release_id, system) + for p in candidates: + if p.exists(): + return p + return base.with_suffix(".png") + + +def _save_cover(release_id, system, data): + base, candidates = _cover_paths(release_id, system) + base.parent.mkdir(parents=True, exist_ok=True) + ext = ".png" + if data[:3] == b"\xff\xd8\xff": + ext = ".jpg" + elif data[:4] == b"RIFF" and data[8:12] == b"WEBP": + ext = ".webp" + dest = base.with_suffix(ext) + for p in candidates: + if p.exists() and p != dest: + p.unlink() + tmp = dest.with_name(dest.name + ".tmp") + tmp.write_bytes(data) + tmp.replace(dest) + return dest + + +def _store_root(system): + return config.STORE_VIDEOS_ROOT if system == "videos" else config.STORE_ROOT + + +def _log_download(user, system, name, size): + conn = _conn() + try: + conn.execute( + "INSERT INTO download_events (user_id, system, name, bytes, created_at) VALUES (?, ?, ?, ?, ?)", + (user["id"] if user else None, system, name, size or 0, now_iso()), + ) + conn.commit() + finally: + conn.close() + + +# --------------------------------------------------------------------------- # +# auth +# --------------------------------------------------------------------------- # +@router.post("/auth/login") +def auth_login(payload: dict): + username = (payload.get("username") or "").strip() + password = payload.get("password") or "" + conn = _conn() + try: + row = conn.execute( + "SELECT id, username, password_hash, role, is_active FROM users WHERE username = ?", + (username,), + ).fetchone() + finally: + conn.close() + if row is None or not row["is_active"] or not verify_password(password, row["password_hash"]): + raise HTTPException(status_code=401, detail="Invalid username or password") + token = issue_token(row["id"], kind="api", label="desktop") + return {"token": token, "username": row["username"], "role": row["role"], "user_id": row["id"]} + + +@router.get("/auth/me") +def auth_me(user=Depends(get_current_user)): + return user + + +@router.post("/auth/logout") +def auth_logout(request: Request, authorization: str = Header(default="")): + token = "" + if authorization.startswith("Bearer "): + token = authorization[7:].strip() + else: + token = request.cookies.get("psppt_session", "") + if token: + revoke_token(token) + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# users (admin) +# --------------------------------------------------------------------------- # +@router.get("/users") +def users_list(user=Depends(require_role("admin"))): + conn = _conn() + try: + rows = conn.execute( + "SELECT id, username, role, is_active, created_at, last_login_at FROM users ORDER BY username" + ).fetchall() + return {"items": [dict(r) for r in rows]} + finally: + conn.close() + + +@router.post("/users") +def users_create(payload: dict, user=Depends(require_role("admin"))): + username = (payload.get("username") or "").strip() + password = payload.get("password") or "" + role = payload.get("role") or "user" + if not username or not password: + raise HTTPException(status_code=400, detail="username and password are required") + if role not in ("admin", "editor", "user"): + raise HTTPException(status_code=400, detail="invalid role") + conn = _conn() + try: + try: + conn.execute( + "INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)", + (username, hash_password(password), role, now_iso()), + ) + conn.commit() + except Exception: + raise HTTPException(status_code=409, detail="username already exists") + row = conn.execute("SELECT id, username, role, is_active FROM users WHERE username = ?", (username,)).fetchone() + return dict(row) + finally: + conn.close() + + +@router.patch("/users/{user_id}") +def users_update(user_id: int, payload: dict, user=Depends(require_role("admin"))): + conn = _conn() + try: + row = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone() + if row is None: + raise HTTPException(status_code=404, detail="not found") + if "role" in payload: + if payload["role"] not in ("admin", "editor", "user"): + raise HTTPException(status_code=400, detail="invalid role") + conn.execute("UPDATE users SET role = ? WHERE id = ?", (payload["role"], user_id)) + if "is_active" in payload: + conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if payload["is_active"] else 0, user_id)) + if payload.get("password"): + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(payload["password"]), user_id)) + conn.commit() + row = conn.execute("SELECT id, username, role, is_active FROM users WHERE id = ?", (user_id,)).fetchone() + return dict(row) + finally: + conn.close() + + +@router.delete("/users/{user_id}") +def users_delete(user_id: int, user=Depends(require_role("admin"))): + if user_id == user["id"]: + raise HTTPException(status_code=400, detail="cannot delete yourself") + conn = _conn() + try: + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + conn.commit() + finally: + conn.close() + return {"ok": True} + + +@router.get("/users/{user_id}/tokens") +def users_tokens(user_id: int, user=Depends(require_role("admin"))): + conn = _conn() + try: + rows = conn.execute( + "SELECT id, label, kind, created_at, last_used_at, expires_at, revoked_at FROM api_tokens " + "WHERE user_id = ? ORDER BY id DESC", + (user_id,), + ).fetchall() + return {"items": [dict(r) for r in rows]} + finally: + conn.close() + + +@router.delete("/users/{user_id}/tokens/{token_id}") +def users_revoke_token(user_id: int, token_id: int, user=Depends(require_role("admin"))): + conn = _conn() + try: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE id = ? AND user_id = ?", + (now_iso(), token_id, user_id), + ) + conn.commit() + finally: + conn.close() + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# releases / catalog +# --------------------------------------------------------------------------- # +@router.get("/releases") +def releases_list(system: str = Query(default=""), q: str = Query(default=""), limit: int = 50, offset: int = 0): + limit = max(1, min(limit, 500)) + offset = max(0, offset) + sql = "SELECT r.id, d.system, r.name, r.region, r.serial FROM releases r JOIN dats d ON d.id = r.dat_id" + where, params = [], [] + if system: + where.append("d.system = ?") + params.append(system) + if q: + where.append("r.name LIKE ?") + params.append(f"%{q}%") + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY r.name LIMIT ? OFFSET ?" + params += [limit, offset] + conn = _conn() + try: + rows = conn.execute(sql, params).fetchall() + items = [] + for r in rows: + d = dict(r) + d["cover"] = f"/api/cover/{d['id']}" + items.append(d) + return {"items": items, "limit": limit, "offset": offset} + finally: + conn.close() + + +@router.get("/releases/{release_id}") +def releases_detail(release_id: int): + conn = _conn() + try: + rel = conn.execute( + "SELECT r.id, d.system, r.name, r.description, r.region, r.serial " + "FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", + (release_id,), + ).fetchone() + if rel is None: + raise HTTPException(status_code=404, detail="not found") + roms = conn.execute( + "SELECT name, size, crc32, md5, sha1, serial FROM roms WHERE release_id = ?", (release_id,) + ).fetchall() + result = dict(rel) + result["cover"] = f"/api/cover/{release_id}" + result["roms"] = [dict(r) for r in roms] + result["meta"] = _metadata(conn, release_id, rel["system"]) + return result + finally: + conn.close() + + +@router.patch("/releases/{release_id}") +def releases_update(release_id: int, payload: dict, user=Depends(require_role("admin", "editor"))): + conn = _conn() + try: + rel = conn.execute( + "SELECT r.id, d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", + (release_id,), + ).fetchone() + if rel is None: + raise HTTPException(status_code=404, detail="not found") + # release-table columns + for col in ("name", "region", "serial", "description"): + if col in payload: + conn.execute(f"UPDATE releases SET {col} = ? WHERE id = ?", (payload[col], release_id)) + # metadata (item_info) + meta_fields = {k: payload[k] for k in _META_FIELDS if k in payload} + if meta_fields: + _save_metadata(conn, release_id, rel["system"], meta_fields) + conn.commit() + return {"ok": True} + finally: + conn.close() + + +# --------------------------------------------------------------------------- # +# covers +# --------------------------------------------------------------------------- # +@router.get("/cover/{release_id}") +def cover_get(release_id: int): + conn = _conn() + try: + system = _release_system(conn, release_id) + finally: + conn.close() + if system is None: + raise HTTPException(status_code=404, detail="not found") + path = _find_cover(release_id, system) + if not path.exists(): + raise HTTPException(status_code=404, detail="no cover available") + media = "image/jpeg" if path.suffix.lower() in (".jpg", ".jpeg") else "image/png" + if path.suffix.lower() == ".webp": + media = "image/webp" + return FileResponse(path, media_type=media) + + +@router.post("/cover/{release_id}") +async def cover_upload(release_id: int, file: UploadFile = File(...), user=Depends(require_role("admin", "editor"))): + conn = _conn() + try: + system = _release_system(conn, release_id) + finally: + conn.close() + if system is None: + raise HTTPException(status_code=404, detail="not found") + data = await file.read() + if len(data) > 20 * (1 << 20): + raise HTTPException(status_code=400, detail="image too large") + _save_cover(release_id, system, data) + return {"ok": True, "url": f"/api/cover/{release_id}"} + + +@router.delete("/cover/{release_id}") +def cover_delete(release_id: int, user=Depends(require_role("admin", "editor"))): + conn = _conn() + try: + system = _release_system(conn, release_id) + finally: + conn.close() + if system is None: + raise HTTPException(status_code=404, detail="not found") + path = _find_cover(release_id, system) + if path.exists(): + path.unlink() + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# store +# --------------------------------------------------------------------------- # +@router.get("/store") +def store_list(system: str = Query(default="games")): + root = _store_root(system) + if not root.is_dir(): + return {"items": [], "count": 0, "system": system} + items = [ + {"name": it["name"], "filename": Path(it["path"]).name, "size": it["size"], "mtime_ns": it["mtime_ns"], "system": system} + for it in store.scan(root) + ] + return {"items": items, "count": len(items), "system": system} + + +@router.get("/store/download/{system}/{name}") +def store_download(system: str, name: str, user=Depends(get_current_user)): + root = _store_root(system).resolve() + target = (root / name).resolve() + if not target.is_file() or root not in target.parents: + raise HTTPException(status_code=404, detail="not found") + _log_download(user, system, name, target.stat().st_size) + return FileResponse(target, filename=name, media_type="application/octet-stream") + + +@router.post("/store/upload") +async def store_upload(system: str = Query(default="games"), file: UploadFile = File(...), user=Depends(require_role("admin", "editor"))): + root = _store_root(system) + root.mkdir(parents=True, exist_ok=True) + safe = _SAFE_NAME_RE.sub("", file.filename or "") + if not safe: + raise HTTPException(status_code=400, detail="invalid filename") + dest = root / safe + data = await file.read() + dest.write_bytes(data) + return {"ok": True, "filename": safe, "size": len(data)} + + +@router.delete("/store/{system}/{name}") +def store_delete(system: str, name: str, user=Depends(require_role("admin", "editor"))): + root = _store_root(system).resolve() + target = (root / name).resolve() + if not target.is_file() or root not in target.parents: + raise HTTPException(status_code=404, detail="not found") + target.unlink() + return {"ok": True} + + +# --------------------------------------------------------------------------- # +# stats (admin) +# --------------------------------------------------------------------------- # +@router.get("/stats") +def stats(user=Depends(require_role("admin"))): + conn = _conn() + try: + systems = conn.execute( + "SELECT d.system AS system, COUNT(*) AS releases FROM releases r " + "JOIN dats d ON d.id = r.dat_id GROUP BY d.system ORDER BY d.system" + ).fetchall() + users = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + covers_count = sum(1 for _ in config.COVERS_DIR.rglob("*") if _.is_file()) if config.COVERS_DIR.is_dir() else 0 + downloads = conn.execute("SELECT COUNT(*) AS n FROM download_events").fetchone()["n"] + recent = conn.execute( + "SELECT e.system, e.name, e.bytes, e.created_at, u.username FROM download_events e " + "LEFT JOIN users u ON u.id = e.user_id ORDER BY e.id DESC LIMIT 20" + ).fetchall() + return { + "systems": [dict(r) for r in systems], + "users": users, + "covers": covers_count, + "downloads": downloads, + "recent": [dict(r) for r in recent], + } + finally: + conn.close() diff --git a/server/config.py b/server/config.py new file mode 100644 index 0000000..ac26216 --- /dev/null +++ b/server/config.py @@ -0,0 +1,9 @@ +"""Server-specific settings (env-driven).""" +import os +from pathlib import Path + +from app.config import COVERS_DIR, DB_PATH, STORE_ROOT + +STORE_VIDEOS_ROOT = Path(os.environ.get("PSP_STORE_VIDEOS_DIR", "/data/store_videos")) +ADMIN_USERNAME = os.environ.get("PSP_ADMIN_USERNAME", "admin") +ADMIN_PASSWORD = os.environ.get("PSP_ADMIN_PASSWORD", "admin") diff --git a/server/main.py b/server/main.py new file mode 100644 index 0000000..c49266f --- /dev/null +++ b/server/main.py @@ -0,0 +1,39 @@ +"""psppt server: FastAPI app assembly.""" +import logging +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from app.db import init_db + +from . import api, config, web +from .security import ensure_admin + +log = logging.getLogger("psppt.server") + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + init_db() + created = ensure_admin(config.ADMIN_USERNAME, config.ADMIN_PASSWORD) + if created: + log.warning( + "Created initial admin user %r with the configured password — change it now.", + config.ADMIN_USERNAME, + ) + yield + + +app = FastAPI(title="psppt server", lifespan=lifespan) + +app.include_router(api.router) +app.include_router(web.router) + +app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static") + + +@app.get("/health") +def health(): + return {"ok": True} diff --git a/server/security.py b/server/security.py new file mode 100644 index 0000000..0fe736d --- /dev/null +++ b/server/security.py @@ -0,0 +1,138 @@ +"""Password hashing, API/session tokens, and FastAPI auth dependencies. + +Stdlib-only crypto: PBKDF2-SHA256 for passwords, SHA-256 hashed bearer tokens +for both the desktop client (kind="api") and the WebUI session (kind="session"). +""" +import hashlib +import secrets +from datetime import datetime, timezone + +from fastapi import Depends, Header, HTTPException, Request, status + +from app.db import get_connection + +_PBKDF2_ITERATIONS = 600_000 + +ADMIN_ROLE = "admin" +EDITOR_ROLE = "editor" +USER_ROLE = "user" +ROLES = (ADMIN_ROLE, EDITOR_ROLE, USER_ROLE) + + +def now_iso(): + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +def hash_password(password: str) -> str: + salt = secrets.token_bytes(16) + digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITERATIONS) + return f"pbkdf2_sha256${_PBKDF2_ITERATIONS}${salt.hex()}${digest.hex()}" + + +def verify_password(password: str, stored: str) -> bool: + try: + scheme, iterations, salt_hex, digest_hex = stored.split("$") + if scheme != "pbkdf2_sha256": + return False + salt = bytes.fromhex(salt_hex) + expected = bytes.fromhex(digest_hex) + actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, int(iterations)) + return secrets.compare_digest(actual, expected) + except (ValueError, TypeError): + return False + + +def _hash_token(raw: str) -> str: + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def issue_token(user_id: int, kind: str = "api", label: str = "", expires_at: str = "") -> str: + """Create a token row and return the raw token (only shown once).""" + raw = secrets.token_urlsafe(32) + conn = get_connection() + try: + conn.execute( + "INSERT INTO api_tokens (user_id, token_hash, label, kind, created_at, last_used_at, expires_at, revoked_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, NULL)", + (user_id, _hash_token(raw), label, kind, now_iso(), now_iso(), expires_at or None), + ) + conn.commit() + finally: + conn.close() + return raw + + +def revoke_token(raw: str) -> None: + conn = get_connection() + try: + conn.execute( + "UPDATE api_tokens SET revoked_at = ? WHERE token_hash = ?", + (now_iso(), _hash_token(raw)), + ) + conn.commit() + finally: + conn.close() + + +def get_user_by_token(raw: str): + if not raw: + return None + conn = get_connection() + try: + row = conn.execute( + "SELECT u.id, u.username, u.role, u.is_active FROM api_tokens t " + "JOIN users u ON u.id = t.user_id " + "WHERE t.token_hash = ? AND t.revoked_at IS NULL " + "AND (t.expires_at IS NULL OR t.expires_at = '' OR t.expires_at > ?)", + (_hash_token(raw), now_iso()), + ).fetchone() + if row is None or not row["is_active"]: + return None + conn.execute( + "UPDATE api_tokens SET last_used_at = ? WHERE token_hash = ?", + (now_iso(), _hash_token(raw)), + ) + conn.commit() + return dict(row) + finally: + conn.close() + + +def get_current_user(request: Request, authorization: str = Header(default="")): + """Authenticate via ``Authorization: Bearer`` (client) or session cookie (WebUI).""" + token = "" + if authorization.startswith("Bearer "): + token = authorization[7:].strip() + else: + token = request.cookies.get("psppt_session", "") + user = get_user_by_token(token) + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + return user + + +def require_role(*roles): + """Dependency factory: only users with one of ``roles`` may pass.""" + def dependency(user=Depends(get_current_user)): + if user["role"] not in roles: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + return user + return dependency + + +def ensure_admin(username: str, password: str) -> bool: + """Create the initial admin account when the users table is empty.""" + conn = get_connection() + try: + count = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + if count == 0: + conn.execute( + "INSERT INTO users (username, password_hash, role, is_active, created_at) " + "VALUES (?, ?, 'admin', 1, ?)", + (username, hash_password(password), now_iso()), + ) + conn.commit() + return True + finally: + conn.close() + return False diff --git a/server/static/app.css b/server/static/app.css new file mode 100644 index 0000000..a73d7d4 --- /dev/null +++ b/server/static/app.css @@ -0,0 +1,52 @@ +:root { color-scheme: dark; } +* { box-sizing: border-box; } +body { + margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + background: #12131a; color: #e8e9ed; line-height: 1.45; +} +nav { + display: flex; align-items: center; gap: 16px; padding: 10px 20px; + background: #1a1c26; border-bottom: 1px solid #2a2d3a; +} +nav a { color: #9fb4ff; text-decoration: none; font-weight: 600; } +nav a:hover { color: #c7d3ff; } +nav .spacer { flex: 1; } +nav .who { color: #8a8da0; font-size: 0.9em; } +main { max-width: 1100px; margin: 0 auto; padding: 24px 20px 60px; } +h1 { font-size: 1.6em; margin-top: 0; } +h2 { font-size: 1.15em; margin-top: 28px; color: #b9bdd0; } +.card { background: #1a1c26; border: 1px solid #2a2d3a; border-radius: 10px; padding: 24px; } +.card.narrow { max-width: 380px; margin: 60px auto; } +form label { display: block; margin: 10px 0 2px; font-size: 0.85em; color: #9aa0b5; } +input, select, textarea { + width: 100%; padding: 8px 10px; border-radius: 6px; border: 1px solid #34384a; + background: #0f1016; color: #e8e9ed; font: inherit; +} +form.inline { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +form.inline input, form.inline select { width: auto; } +form.filters input { width: 220px; } +form.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 18px; max-width: 760px; } +form.grid .wide { grid-column: 1 / -1; } +button { + padding: 8px 16px; border: 0; border-radius: 6px; cursor: pointer; + background: #3b5bdb; color: #fff; font: inherit; font-weight: 600; +} +button:hover { background: #4c6ef5; } +button.ghost { background: transparent; border: 1px solid #34384a; color: #c7cadb; } +button.danger { background: #b03a3a; } +button.danger:hover { background: #d14a4a; } +table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.95em; } +th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid #262936; } +th { color: #9aa0b5; font-weight: 600; } +tr:hover td { background: #171923; } +.cards { display: flex; gap: 14px; flex-wrap: wrap; } +.stat { flex: 1; min-width: 120px; background: #1a1c26; border: 1px solid #2a2d3a; + border-radius: 10px; padding: 16px; text-align: center; } +.stat b { display: block; font-size: 1.7em; } +.stat span { color: #8a8da0; font-size: 0.85em; } +.error { color: #ff8787; } +.muted { color: #8a8da0; } +.cover { max-width: 200px; border-radius: 8px; border: 1px solid #2a2d3a; margin-bottom: 10px; } +code { background: #0f1016; padding: 2px 6px; border-radius: 4px; } +label.check { display: flex; align-items: center; gap: 6px; margin: 0; } +label.check input { width: auto; } diff --git a/server/templates/base.html b/server/templates/base.html new file mode 100644 index 0000000..c48b5e1 --- /dev/null +++ b/server/templates/base.html @@ -0,0 +1,26 @@ + + + + + + {% block title %}psppt server{% endblock %} + + + + {% if user %} + + {% endif %} +
+ {% block content %}{% endblock %} +
+ + diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html new file mode 100644 index 0000000..fe7c46f --- /dev/null +++ b/server/templates/dashboard.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} +{% block title %}Dashboard · psppt{% endblock %} +{% block content %} +

Dashboard

+
+
{{ users }}users
+
{{ downloads }}downloads
+
{{ covers }}covers
+
{{ store_games }}game archives
+
{{ store_videos }}video archives
+
+ +

Catalog

+
    + {% for s in systems %} +
  • {{ s.system }} — {{ s.releases }} release(s)
  • + {% else %} +
  • No catalog data ingested yet.
  • + {% endfor %} +
+ +

Recent downloads

+ + + {% for r in recent %} + + + + + + + + {% else %} + + {% endfor %} +
WhenUserSystemNameSize
{{ r.created_at }}{{ r.username or '—' }}{{ r.system }}{{ r.name }}{{ (r.bytes / 1048576) | round(1) }} MB
No downloads yet.
+{% endblock %} diff --git a/server/templates/login.html b/server/templates/login.html new file mode 100644 index 0000000..174779c --- /dev/null +++ b/server/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Login · psppt{% endblock %} +{% block content %} +
+

psppt server

+ {% if error %}

{{ error }}

{% endif %} +
+ + + +
+
+{% endblock %} diff --git a/server/templates/release_edit.html b/server/templates/release_edit.html new file mode 100644 index 0000000..f89dff7 --- /dev/null +++ b/server/templates/release_edit.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}{{ rel.name }} · psppt{% endblock %} +{% block content %} +

← back

+

{{ rel.name }}

+
+ + + + + + + + + + + +
+ +

Cover

+{% if has_cover %}cover{% else %}

No cover.

{% endif %} +
+ + +
+{% if has_cover %} +
+ +
+{% endif %} +{% endblock %} diff --git a/server/templates/releases.html b/server/templates/releases.html new file mode 100644 index 0000000..2ccd450 --- /dev/null +++ b/server/templates/releases.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Games & Videos · psppt{% endblock %} +{% block content %} +

Games & Videos

+
+ + + +
+ + + {% for it in items %} + + + + + + + + + {% else %} + + {% endfor %} +
NameSystemRegionSerialCover
{{ it.name }}{{ it.system }}{{ it.region or '' }}{{ it.serial or '' }}{% if it.has_cover %}✓{% else %}—{% endif %}Edit
Nothing found.
+{% endblock %} diff --git a/server/templates/settings.html b/server/templates/settings.html new file mode 100644 index 0000000..1d4bd35 --- /dev/null +++ b/server/templates/settings.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Settings · psppt{% endblock %} +{% block content %} +

Settings

+
    +
  • Store root (games): {{ store_root }}
  • +
  • Store root (videos): {{ store_videos_root }}
  • +
  • Covers directory: {{ covers_dir }}
  • +
+

These paths are configured via environment variables — restart the server to change them.

+{% endblock %} diff --git a/server/templates/store.html b/server/templates/store.html new file mode 100644 index 0000000..f9c1156 --- /dev/null +++ b/server/templates/store.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Store · psppt{% endblock %} +{% block content %} +

Store

+

Upload archive

+
+ + + +
+ +

Games ({{ games | length }})

+ + + {% for g in games %} + + + + + + {% else %} + + {% endfor %} +
NameSize
{{ g.name }}{{ (g.size / 1048576) | round(1) }} MB +
+ +
+
No game archives.
+ +

Videos ({{ videos | length }})

+ + + {% for v in videos %} + + + + + + {% else %} + + {% endfor %} +
NameSize
{{ v.name }}{{ (v.size / 1048576) | round(1) }} MB +
+ +
+
No video archives.
+{% endblock %} diff --git a/server/templates/users.html b/server/templates/users.html new file mode 100644 index 0000000..2c700b1 --- /dev/null +++ b/server/templates/users.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}Users · psppt{% endblock %} +{% block content %} +

Users

+

Add user

+
+ + + + +
+ +

Existing

+ + + {% for u in users %} + + + + + + + {% endfor %} +
UsernameRoleActiveActions
{{ u.username }}{{ u.role }}{{ 'yes' if u.is_active else 'no' }} +
+ + + + + +
+
+{% endblock %} diff --git a/server/web.py b/server/web.py new file mode 100644 index 0000000..6431ec8 --- /dev/null +++ b/server/web.py @@ -0,0 +1,339 @@ +"""Server-rendered admin WebUI (Jinja2, plain HTML forms — no JS build).""" +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse +from fastapi.templating import Jinja2Templates + +from app import store as store_mod +from app.db import get_connection + +from . import config +from .api import _find_cover, _metadata, _release_system, _store_root, _SAFE_NAME_RE +from .security import get_user_by_token, hash_password, issue_token, now_iso, require_role, revoke_token, verify_password + +router = APIRouter(include_in_schema=False) +templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates")) + + +def _render(request, name, **ctx): + ctx.setdefault("user", getattr(request.state, "user", None)) + return templates.TemplateResponse(request, name, ctx) + + +def web_user(request: Request) -> dict: + user = get_user_by_token(request.cookies.get("psppt_session", "")) + if user is None: + raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/login"}) + request.state.user = user + return user + + +def web_admin(request: Request, user=Depends(web_user)) -> dict: + if user["role"] != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only") + return user + + +def web_editor(request: Request, user=Depends(web_user)) -> dict: + if user["role"] not in ("admin", "editor"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Editor or admin required") + return user + + +# --------------------------------------------------------------------------- # +# auth pages +# --------------------------------------------------------------------------- # +@router.get("/login") +def login_page(request: Request): + return _render(request, "login.html") + + +@router.post("/login") +async def login_submit(request: Request): + form = await request.form() + username = (form.get("username") or "").strip() + password = form.get("password") or "" + conn = get_connection() + try: + row = conn.execute( + "SELECT id, username, password_hash, role, is_active FROM users WHERE username = ?", (username,) + ).fetchone() + finally: + conn.close() + if row is None or not row["is_active"] or not verify_password(password, row["password_hash"]): + return _render(request, "login.html", error="Invalid username or password") + token = issue_token(row["id"], kind="session", label="web session") + conn = get_connection() + try: + conn.execute("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"])) + conn.commit() + finally: + conn.close() + resp = RedirectResponse("/dashboard", status_code=status.HTTP_303_SEE_OTHER) + resp.set_cookie("psppt_session", token, httponly=True, samesite="lax", path="/") + return resp + + +@router.post("/logout") +def logout(request: Request): + token = request.cookies.get("psppt_session", "") + if token: + revoke_token(token) + resp = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) + resp.delete_cookie("psppt_session", path="/") + return resp + + +# --------------------------------------------------------------------------- # +# dashboard +# --------------------------------------------------------------------------- # +@router.get("/") +def root(): + return RedirectResponse("/dashboard", status_code=status.HTTP_303_SEE_OTHER) + + +@router.get("/dashboard") +def dashboard(request: Request, user=Depends(web_user)): + conn = get_connection() + try: + systems = conn.execute( + "SELECT d.system AS system, COUNT(*) AS releases FROM releases r " + "JOIN dats d ON d.id = r.dat_id GROUP BY d.system ORDER BY d.system" + ).fetchall() + users = conn.execute("SELECT COUNT(*) AS n FROM users").fetchone()["n"] + downloads = conn.execute("SELECT COUNT(*) AS n FROM download_events").fetchone()["n"] + covers = sum(1 for _ in config.COVERS_DIR.rglob("*") if _.is_file()) if config.COVERS_DIR.is_dir() else 0 + recent = conn.execute( + "SELECT e.system, e.name, e.bytes, e.created_at, u.username FROM download_events e " + "LEFT JOIN users u ON u.id = e.user_id ORDER BY e.id DESC LIMIT 15" + ).fetchall() + finally: + conn.close() + store_games = len(store_mod.scan(config.STORE_ROOT)) if config.STORE_ROOT.is_dir() else 0 + store_videos = len(store_mod.scan(config.STORE_VIDEOS_ROOT)) if config.STORE_VIDEOS_ROOT.is_dir() else 0 + return _render(request, "dashboard.html", systems=[dict(r) for r in systems], users=users, + downloads=downloads, covers=covers, recent=[dict(r) for r in recent], + store_games=store_games, store_videos=store_videos) + + +# --------------------------------------------------------------------------- # +# releases (games / videos browser) +# --------------------------------------------------------------------------- # +@router.get("/releases") +def releases_page(request: Request, system: str = "", q: str = "", user=Depends(web_user)): + sql = "SELECT r.id, d.system, r.name, r.region, r.serial FROM releases r JOIN dats d ON d.id = r.dat_id" + where, params = [], [] + if system: + where.append("d.system = ?") + params.append(system) + if q: + where.append("r.name LIKE ?") + params.append(f"%{q}%") + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY r.name LIMIT 200" + conn = get_connection() + try: + rows = conn.execute(sql, params).fetchall() + items = [] + for r in rows: + d = dict(r) + d["has_cover"] = _find_cover(r["id"], r["system"]).exists() + items.append(d) + finally: + conn.close() + return _render(request, "releases.html", items=items, system=system, q=q) + + +@router.get("/releases/{release_id}/edit") +def release_edit_page(release_id: int, request: Request, user=Depends(web_editor)): + conn = get_connection() + try: + rel = conn.execute( + "SELECT r.id, d.system, r.name, r.description, r.region, r.serial " + "FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", + (release_id,), + ).fetchone() + if rel is None: + raise HTTPException(status_code=404, detail="not found") + meta = _metadata(conn, release_id, rel["system"]) + finally: + conn.close() + has_cover = _find_cover(release_id, rel["system"]).exists() + return _render(request, "release_edit.html", rel=dict(rel), meta=meta, has_cover=has_cover) + + +@router.post("/releases/{release_id}/edit") +async def release_edit_save(release_id: int, request: Request, user=Depends(web_editor)): + form = await request.form() + conn = get_connection() + try: + rel = conn.execute( + "SELECT d.system FROM releases r JOIN dats d ON d.id = r.dat_id WHERE r.id = ?", (release_id,) + ).fetchone() + if rel is None: + raise HTTPException(status_code=404, detail="not found") + conn.execute( + "UPDATE releases SET name = ?, region = ?, serial = ?, description = ? WHERE id = ?", + (form.get("name") or "", form.get("region") or "", form.get("serial") or "", + form.get("description") or "", release_id), + ) + meta = {} + for k in ("title", "release_date", "developer", "publisher", "genre", "synopsis"): + v = form.get(k) + if v is not None and str(v).strip(): + meta[k] = str(v).strip() + if meta: + from .api import _save_metadata + _save_metadata(conn, release_id, rel["system"], meta) + conn.commit() + finally: + conn.close() + return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/releases/{release_id}/cover") +async def release_cover_upload(release_id: int, request: Request, user=Depends(web_editor)): + from .api import _save_cover + form = await request.form() + file = form.get("cover") + if file is None or not getattr(file, "filename", ""): + raise HTTPException(status_code=400, detail="no file") + data = await file.read() + conn = get_connection() + try: + system = _release_system(conn, release_id) + finally: + conn.close() + if system is None: + raise HTTPException(status_code=404, detail="not found") + _save_cover(release_id, system, data) + return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/releases/{release_id}/cover/delete") +def release_cover_delete(release_id: int, user=Depends(web_editor)): + from .api import _find_cover + conn = get_connection() + try: + system = _release_system(conn, release_id) + finally: + conn.close() + if system: + p = _find_cover(release_id, system) + if p.exists(): + p.unlink() + return RedirectResponse(f"/releases/{release_id}/edit", status_code=status.HTTP_303_SEE_OTHER) + + +# --------------------------------------------------------------------------- # +# users (admin) +# --------------------------------------------------------------------------- # +@router.get("/users") +def users_page(request: Request, user=Depends(web_admin)): + conn = get_connection() + try: + rows = conn.execute( + "SELECT id, username, role, is_active, created_at, last_login_at FROM users ORDER BY username" + ).fetchall() + finally: + conn.close() + return _render(request, "users.html", users=[dict(r) for r in rows]) + + +@router.post("/users") +async def users_create(request: Request, user=Depends(web_admin)): + form = await request.form() + username = (form.get("username") or "").strip() + password = form.get("password") or "" + role = form.get("role") or "user" + if not username or not password: + raise HTTPException(status_code=400, detail="username and password required") + conn = get_connection() + try: + try: + conn.execute( + "INSERT INTO users (username, password_hash, role, is_active, created_at) VALUES (?, ?, ?, 1, ?)", + (username, hash_password(password), role, now_iso()), + ) + conn.commit() + except Exception: + raise HTTPException(status_code=409, detail="username already exists") + finally: + conn.close() + return RedirectResponse("/users", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/users/{user_id}") +async def users_update(user_id: int, request: Request, user=Depends(web_admin)): + form = await request.form() + action = form.get("action") or "" + conn = get_connection() + try: + if action == "delete": + if user_id != user["id"]: + conn.execute("DELETE FROM users WHERE id = ?", (user_id,)) + else: + role = form.get("role") + if role in ("admin", "editor", "user"): + conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id)) + conn.execute("UPDATE users SET is_active = ? WHERE id = ?", (1 if form.get("is_active") else 0, user_id)) + pw = form.get("password") or "" + if pw: + conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (hash_password(pw), user_id)) + conn.commit() + finally: + conn.close() + return RedirectResponse("/users", status_code=status.HTTP_303_SEE_OTHER) + + +# --------------------------------------------------------------------------- # +# store management (editor+) +# --------------------------------------------------------------------------- # +@router.get("/store") +def store_page(request: Request, user=Depends(web_editor)): + def _rows(root): + out = [] + for it in store_mod.scan(root): + out.append({"name": it["name"], "filename": Path(it["path"]).name, "size": it["size"]}) + return out + games = _rows(config.STORE_ROOT) if config.STORE_ROOT.is_dir() else [] + videos = _rows(config.STORE_VIDEOS_ROOT) if config.STORE_VIDEOS_ROOT.is_dir() else [] + return _render(request, "store.html", games=games, videos=videos) + + +@router.post("/store/upload") +async def store_upload(request: Request, user=Depends(web_editor)): + form = await request.form() + system = form.get("system") or "games" + file = form.get("file") + if file is None or not getattr(file, "filename", ""): + raise HTTPException(status_code=400, detail="no file") + data = await file.read() + root = _store_root(system) + root.mkdir(parents=True, exist_ok=True) + safe = _SAFE_NAME_RE.sub("", file.filename or "") + if not safe: + raise HTTPException(status_code=400, detail="invalid filename") + (root / safe).write_bytes(data) + return RedirectResponse("/store", status_code=status.HTTP_303_SEE_OTHER) + + +@router.post("/store/{system}/{name}/delete") +def store_delete(system: str, name: str, user=Depends(web_editor)): + root = _store_root(system).resolve() + target = (root / name).resolve() + if target.is_file() and root in target.parents: + target.unlink() + return RedirectResponse("/store", status_code=status.HTTP_303_SEE_OTHER) + + +# --------------------------------------------------------------------------- # +# settings (admin) +# --------------------------------------------------------------------------- # +@router.get("/settings") +def settings_page(request: Request, user=Depends(web_admin)): + return _render(request, "settings.html", + store_root=config.STORE_ROOT, store_videos_root=config.STORE_VIDEOS_ROOT, + covers_dir=config.COVERS_DIR)