"""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)