Rewrite server: FastAPI + auth (users/roles/tokens) + admin WebUI + server-authoritative covers/metadata

This commit is contained in:
2026-09-14 19:40:58 -04:00
parent 14b0b955f5
commit 7b7d31bdfa
26 changed files with 1528 additions and 18 deletions
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}psppt server{% endblock %}</title>
<link rel="stylesheet" href="/static/app.css">
</head>
<body>
{% if user %}
<nav>
<a href="/dashboard">Dashboard</a>
<a href="/releases">Games &amp; Videos</a>
{% if user.role in ('admin', 'editor') %}<a href="/store">Store</a>{% endif %}
{% if user.role == 'admin' %}<a href="/users">Users</a>{% endif %}
{% if user.role == 'admin' %}<a href="/settings">Settings</a>{% endif %}
<span class="spacer"></span>
<span class="who">{{ user.username }} · {{ user.role }}</span>
<form method="post" action="/logout" class="inline"><button class="ghost">Logout</button></form>
</nav>
{% endif %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}Dashboard · psppt{% endblock %}
{% block content %}
<h1>Dashboard</h1>
<div class="cards">
<div class="stat"><b>{{ users }}</b><span>users</span></div>
<div class="stat"><b>{{ downloads }}</b><span>downloads</span></div>
<div class="stat"><b>{{ covers }}</b><span>covers</span></div>
<div class="stat"><b>{{ store_games }}</b><span>game archives</span></div>
<div class="stat"><b>{{ store_videos }}</b><span>video archives</span></div>
</div>
<h2>Catalog</h2>
<ul>
{% for s in systems %}
<li>{{ s.system }} — {{ s.releases }} release(s)</li>
{% else %}
<li>No catalog data ingested yet.</li>
{% endfor %}
</ul>
<h2>Recent downloads</h2>
<table>
<tr><th>When</th><th>User</th><th>System</th><th>Name</th><th>Size</th></tr>
{% for r in recent %}
<tr>
<td>{{ r.created_at }}</td>
<td>{{ r.username or '—' }}</td>
<td>{{ r.system }}</td>
<td>{{ r.name }}</td>
<td>{{ (r.bytes / 1048576) | round(1) }} MB</td>
</tr>
{% else %}
<tr><td colspan="5">No downloads yet.</td></tr>
{% endfor %}
</table>
{% endblock %}
+13
View File
@@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Login · psppt{% endblock %}
{% block content %}
<div class="card narrow">
<h1>psppt server</h1>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="/login">
<label>Username <input name="username" required autofocus></label>
<label>Password <input name="password" type="password" required></label>
<button>Log in</button>
</form>
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{{ rel.name }} · psppt{% endblock %}
{% block content %}
<p><a href="/releases">← back</a></p>
<h1>{{ rel.name }}</h1>
<form method="post" action="/releases/{{ rel.id }}/edit" class="grid">
<label>Name <input name="name" value="{{ rel.name }}"></label>
<label>Region <input name="region" value="{{ rel.region or '' }}"></label>
<label>Serial <input name="serial" value="{{ rel.serial or '' }}"></label>
<label>Description <textarea name="description">{{ rel.description or '' }}</textarea></label>
<label>Title <input name="title" value="{{ meta.title or '' }}"></label>
<label>Release date <input name="release_date" value="{{ meta.release_date or '' }}"></label>
<label>Developer <input name="developer" value="{{ meta.developer or '' }}"></label>
<label>Publisher <input name="publisher" value="{{ meta.publisher or '' }}"></label>
<label>Genre <input name="genre" value="{{ meta.genre or '' }}"></label>
<label class="wide">Synopsis <textarea name="synopsis">{{ meta.synopsis or '' }}</textarea></label>
<button>Save</button>
</form>
<h2>Cover</h2>
{% if has_cover %}<img src="/api/cover/{{ rel.id }}" class="cover" alt="cover">{% else %}<p class="muted">No cover.</p>{% endif %}
<form method="post" action="/releases/{{ rel.id }}/cover" enctype="multipart/form-data" class="inline">
<input type="file" name="cover" accept="image/*">
<button>Upload cover</button>
</form>
{% if has_cover %}
<form method="post" action="/releases/{{ rel.id }}/cover/delete" class="inline">
<button class="danger">Delete cover</button>
</form>
{% endif %}
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Games &amp; Videos · psppt{% endblock %}
{% block content %}
<h1>Games &amp; Videos</h1>
<form method="get" action="/releases" class="filters">
<select name="system">
<option value="">All systems</option>
<option value="games" {% if system == 'games' %}selected{% endif %}>Games</option>
<option value="videos" {% if system == 'videos' %}selected{% endif %}>Videos</option>
</select>
<input name="q" value="{{ q }}" placeholder="Search…">
<button>Filter</button>
</form>
<table>
<tr><th>Name</th><th>System</th><th>Region</th><th>Serial</th><th>Cover</th><th></th></tr>
{% for it in items %}
<tr>
<td>{{ it.name }}</td>
<td>{{ it.system }}</td>
<td>{{ it.region or '' }}</td>
<td>{{ it.serial or '' }}</td>
<td>{% if it.has_cover %}✓{% else %}—{% endif %}</td>
<td><a href="/releases/{{ it.id }}/edit">Edit</a></td>
</tr>
{% else %}
<tr><td colspan="6">Nothing found.</td></tr>
{% endfor %}
</table>
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}Settings · psppt{% endblock %}
{% block content %}
<h1>Settings</h1>
<ul>
<li>Store root (games): <code>{{ store_root }}</code></li>
<li>Store root (videos): <code>{{ store_videos_root }}</code></li>
<li>Covers directory: <code>{{ covers_dir }}</code></li>
</ul>
<p class="muted">These paths are configured via environment variables — restart the server to change them.</p>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Store · psppt{% endblock %}
{% block content %}
<h1>Store</h1>
<h2>Upload archive</h2>
<form method="post" action="/store/upload" enctype="multipart/form-data" class="inline">
<select name="system">
<option value="games">Games</option>
<option value="videos">Videos</option>
</select>
<input type="file" name="file">
<button>Upload</button>
</form>
<h2>Games ({{ games | length }})</h2>
<table>
<tr><th>Name</th><th>Size</th><th></th></tr>
{% for g in games %}
<tr>
<td>{{ g.name }}</td>
<td>{{ (g.size / 1048576) | round(1) }} MB</td>
<td>
<form method="post" action="/store/games/{{ g.filename }}/delete" class="inline">
<button class="danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3">No game archives.</td></tr>
{% endfor %}
</table>
<h2>Videos ({{ videos | length }})</h2>
<table>
<tr><th>Name</th><th>Size</th><th></th></tr>
{% for v in videos %}
<tr>
<td>{{ v.name }}</td>
<td>{{ (v.size / 1048576) | round(1) }} MB</td>
<td>
<form method="post" action="/store/videos/{{ v.filename }}/delete" class="inline">
<button class="danger">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3">No video archives.</td></tr>
{% endfor %}
</table>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}Users · psppt{% endblock %}
{% block content %}
<h1>Users</h1>
<h2>Add user</h2>
<form method="post" action="/users" class="inline">
<input name="username" placeholder="username" required>
<input name="password" type="password" placeholder="password" required>
<select name="role">
<option value="user">user</option>
<option value="editor">editor</option>
<option value="admin">admin</option>
</select>
<button>Add</button>
</form>
<h2>Existing</h2>
<table>
<tr><th>Username</th><th>Role</th><th>Active</th><th>Actions</th></tr>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.role }}</td>
<td>{{ 'yes' if u.is_active else 'no' }}</td>
<td>
<form method="post" action="/users/{{ u.id }}" class="inline">
<select name="role">
<option value="user" {% if u.role == 'user' %}selected{% endif %}>user</option>
<option value="editor" {% if u.role == 'editor' %}selected{% endif %}>editor</option>
<option value="admin" {% if u.role == 'admin' %}selected{% endif %}>admin</option>
</select>
<input name="password" type="password" placeholder="new password">
<label class="check"><input type="checkbox" name="is_active" {% if u.is_active %}checked{% endif %}> active</label>
<button name="action" value="save">Save</button>
<button name="action" value="delete" class="danger">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}