Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata
This commit is contained in:
+491
@@ -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()
|
||||
Reference in New Issue
Block a user