Add admin console for managing users; default account is admin
build / check (push) Successful in 11s
build / image (push) Successful in 27s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 15:53:40 -04:00
1 parent 6f7cddd1e5
commit f977f3294c
5 files changed
+222 -7

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
WORKDIR /app
COPY server.py index.html ./
COPY server.py index.html admin.html ./
USER app
VOLUME /data
EXPOSE 8080
+1 -1
View File
@@ -10,7 +10,7 @@ python3 server.py adduser NAME # create a player account
python3 server.py serve # --upstream http://HOST:8000 --port 8080
```
Then open `http://<server-ip>:8080` and sign in. Other commands: `users`, `passwd NAME`, `deluser NAME`.
Then open `http://<server-ip>:8080` and sign in. The first account (and the `PLAYER_USER` default in Docker) is an admin. Admins manage users at `/admin`. Other commands: `users`, `passwd NAME`, `deluser NAME`.
Only MP3/M4B files can be played in a browser; convert Audible `.aax`/`.aaxc` books in OpenAudible first.
+125
View File
@@ -0,0 +1,125 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0f0f12">
<title>Admin · Audiobooks</title>
<style>
:root{color-scheme:light dark;--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--bd:rgba(0,0,0,.1);--red:#e03131}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--bd:rgba(255,255,255,.1);--red:#ff6b6b}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;padding:calc(20px + env(safe-area-inset-top)) 16px 40px}
main{max-width:820px;margin:auto}
header{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
h1{margin:0;font-size:24px;letter-spacing:-.02em}
a{color:var(--acc);text-decoration:none;font-weight:600}
.card{background:var(--surf);border:1px solid var(--bd);border-radius:16px;padding:18px;margin-bottom:18px}
.card h2{margin:0 0 12px;font-size:16px}
form.add{display:grid;grid-template-columns:1fr 1fr auto auto;gap:10px;align-items:center}
input[type=text],input[type=password]{width:100%;padding:11px 12px;border-radius:10px;border:1px solid var(--bd);background:var(--bg);color:var(--fg);font-size:16px;outline:0}
input:focus{border-color:var(--acc)}
label.chk{display:flex;gap:6px;align-items:center;color:var(--mut);white-space:nowrap}
button{font:inherit;cursor:pointer;border-radius:10px;border:1px solid var(--bd);background:var(--surf2);color:var(--fg);padding:9px 14px;font-weight:600}
button.pri{background:var(--acc);border-color:var(--acc);color:#fff}
button.danger{color:var(--red)}
button:disabled{opacity:.4;cursor:default}
.row{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--bd);flex-wrap:wrap}
.row:first-of-type{border-top:0}
.av{width:38px;height:38px;border-radius:50%;background:linear-gradient(135deg,var(--acc),#ffb067);color:#fff;display:grid;place-items:center;font-weight:800;text-transform:uppercase;flex:none}
.who{flex:1;min-width:140px}
.who b{display:flex;gap:8px;align-items:center}
.badge{font-size:11px;font-weight:700;background:var(--acc);color:#fff;border-radius:99px;padding:1px 8px}
.who small{color:var(--mut)}
.acts{display:flex;gap:8px;flex-wrap:wrap}
dialog{border:1px solid var(--bd);border-radius:16px;background:var(--surf);color:var(--fg);padding:22px;width:min(380px,92vw)}
dialog::backdrop{background:rgba(0,0,0,.55)}
dialog h3{margin:0 0 12px}
dialog .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:14px}
#err{color:var(--red);min-height:20px;margin-top:8px;font-size:14px}
#toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:99px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.on{opacity:1}
@media(max-width:640px){form.add{grid-template-columns:1fr}}
</style>
</head>
<body>
<main>
<header><h1>Admin</h1><a href="/">← Back to player</a></header>
<div class="card">
<h2>Add a user</h2>
<form class="add" id="add">
<input type="text" id="nu" placeholder="Username" autocapitalize="none" autocorrect="off" autocomplete="off" required>
<input type="password" id="np" placeholder="Password (6+ characters)" autocomplete="new-password" required>
<label class="chk"><input type="checkbox" id="na"> Admin</label>
<button class="pri">Add</button>
</form>
<div id="err"></div>
</div>
<div class="card">
<h2>Users <span id="n" style="color:var(--mut);font-weight:500"></span></h2>
<div id="list"></div>
</div>
</main>
<dialog id="pw"><form method="dialog" id="pwf">
<h3 id="pwt">Reset password</h3>
<input type="password" id="pwv" placeholder="New password (6+ characters)" autocomplete="new-password" required>
<div id="pwe" style="color:var(--red);min-height:20px;margin-top:6px;font-size:14px"></div>
<div class="btns"><button type="button" id="pwc">Cancel</button><button class="pri">Set password</button></div>
</form></dialog>
<div id="toast"></div>
<script>
const $ = id => document.getElementById(id);
let me = '', users = [], target = '';
const toast = t => { $('toast').textContent = t; $('toast').classList.add('on'); setTimeout(() => $('toast').classList.remove('on'), 2200); };
async function api(path, body) {
const r = await fetch(path, body === undefined ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (r.status === 401) { location.href = '/login'; throw new Error('sign in'); }
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || r.status);
return j;
}
const ago = t => { if (!t) return 'never'; const s = (Date.now() - t) / 1000;
return s < 90 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
async function load() {
try { me = (await api('/me')).user; users = await api('/admin/users'); }
catch (e) { document.body.innerHTML = '<main><p>' + (e.message === 'admins only' ? 'This page is for admins only.' : 'Could not load: ' + e.message) + ' <a href="/">Back</a></p></main>'; return; }
$('n').textContent = users.length;
$('list').replaceChildren(...users.map(u => {
const d = document.createElement('div'); d.className = 'row';
d.innerHTML = `<div class="av"></div><div class="who"><b><span></span></b><small></small></div><div class="acts"></div>`;
d.querySelector('.av').textContent = u.name[0];
d.querySelector('b span').textContent = u.name + (u.name === me ? ' (you)' : '');
if (u.admin) d.querySelector('b').insertAdjacentHTML('beforeend', '<span class="badge">ADMIN</span>');
d.querySelector('small').textContent = `${u.books} book${u.books === 1 ? '' : 's'} started · last listened ${ago(u.last)}`;
const acts = d.querySelector('.acts');
const mk = (label, cls, fn, dis) => { const b = document.createElement('button'); b.textContent = label; if (cls) b.className = cls; b.disabled = !!dis; b.onclick = fn; acts.append(b); };
mk('Reset password', '', () => { target = u.name; $('pwt').textContent = 'New password for ' + u.name; $('pwv').value = ''; $('pwe').textContent = ''; $('pw').showModal(); $('pwv').focus(); });
mk(u.admin ? 'Remove admin' : 'Make admin', '', () => act(`/admin/users/${encodeURIComponent(u.name)}/admin`, { admin: !u.admin }, u.admin ? u.name + ' is no longer an admin' : u.name + ' is now an admin'));
mk('Delete', 'danger', () => { if (confirm(`Delete ${u.name}? Their listening progress is removed too.`)) act(`/admin/users/${encodeURIComponent(u.name)}/delete`, {}, 'Deleted ' + u.name); }, u.name === me);
return d;
}));
}
async function act(path, body, msg) { try { await api(path, body); toast(msg); await load(); } catch (e) { toast(e.message); } }
$('add').onsubmit = async e => {
e.preventDefault(); $('err').textContent = '';
try {
await api('/admin/users', { username: $('nu').value, password: $('np').value, admin: $('na').checked });
toast('Added ' + $('nu').value.toLowerCase()); $('add').reset(); await load();
} catch (e) { $('err').textContent = e.message; }
};
$('pwc').onclick = () => $('pw').close();
$('pwf').onsubmit = async e => {
e.preventDefault();
try { await api(`/admin/users/${encodeURIComponent(target)}/password`, { password: $('pwv').value }); $('pw').close(); toast('Password changed; ' + target + ' was signed out everywhere'); }
catch (e) { $('pwe').textContent = e.message; }
};
load();
</script>
</body>
</html>
+3 -1
View File
@@ -89,6 +89,7 @@ input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;bac
.ctl .pp{width:76px;height:76px;font-size:34px}
.opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px}
.pill{background:var(--surf);border:1px solid var(--bd);border-radius:99px;padding:8px 16px;font-weight:600;font-size:13.5px}
a.pill{text-decoration:none;color:var(--fg);display:inline-block}
.pill.on{border-color:var(--acc);color:var(--acc)}
.chaps{background:var(--surf);border:1px solid var(--bd);border-radius:14px;overflow:hidden}
.chaps h3{margin:0;padding:12px 16px;font-size:13px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
@@ -116,6 +117,7 @@ input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;bac
<header><div class="hd">
<div class="logo"><i><svg><use href="#i-book"/></svg></i><span>Audiobooks</span></div>
<div class="search"><svg><use href="#i-search"/></svg><input id="q" type="search" placeholder="Search title, author, series"></div>
<a class="pill hide" id="adminlink" href="/admin" style="padding:8px 12px">Admin</a>
<button class="pill" id="who" title="Sign out" style="padding:8px 12px"></button>
<select id="sort" aria-label="Sort"><option value="author">Group by author</option><option value="title">Sort by title</option><option value="recent">Recently played</option></select>
</div></header>
@@ -389,7 +391,7 @@ const unlock = () => {
}
};
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true }));
fetch('/me').then(r => r.json()).then(m => { $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
fetch('/me').then(r => r.json()).then(m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
load();
</script>
</body>
+92 -4
View File
@@ -26,6 +26,7 @@ import time
import urllib.error
import urllib.request
from http.cookies import SimpleCookie
from urllib.parse import unquote
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
@@ -86,6 +87,13 @@ def oa_login():
return os.environ.get("OPENAUDIBLE_USER") or cfg.get("user"), os.environ.get("OPENAUDIBLE_PASSWORD") or cfg.get("password", "")
USERNAME = re.compile(r"^[a-z0-9_.-]{1,32}$")
def revoke_sessions(name):
write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
def sha(token):
return hashlib.sha256(token.encode()).hexdigest()
@@ -115,6 +123,8 @@ def cli(argv):
if name in users:
sys.exit("That user already exists (use passwd).")
users[name] = hash_pw(ask_password())
if len(users) == 1 or "--admin" in argv:
users[name]["admin"] = True
write(F_USERS, users, private=True)
if len(users) == 1 and os.path.exists(F_LEGACY): # carry over progress from before accounts
legacy = read(F_LEGACY, {})
@@ -126,9 +136,9 @@ def cli(argv):
name = argv[1].lower()
if name not in users:
sys.exit("No such user.")
users[name] = hash_pw(ask_password())
users[name] = {**hash_pw(ask_password()), **({"admin": True} if users[name].get("admin") else {})}
write(F_USERS, users, private=True)
write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
revoke_sessions(name)
print("Password changed; that user was signed out of all devices.")
elif cmd == "deluser":
name = argv[1].lower()
@@ -137,7 +147,7 @@ def cli(argv):
write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
print(f"Removed '{name}'.")
elif cmd == "users":
print("\n".join(sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
print("\n".join(n + (" (admin)" if users[n].get("admin") else "") for n in sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
# ---------- login page ----------
@@ -214,7 +224,14 @@ class Handler(BaseHTTPRequestHandler):
with open(os.path.join(HERE, "index.html"), "rb") as f:
return self.send_body(200, f.read(), "text/html; charset=utf-8", [("Cache-Control", "no-cache")])
if path == "/me":
return self.json(200, {"user": user})
return self.json(200, {"user": user, "admin": bool(read(F_USERS, {}).get(user, {}).get("admin"))})
if path in ("/admin", "/admin/users"):
if not read(F_USERS, {}).get(user, {}).get("admin"):
return self.json(403, {"error": "admins only"}) if path == "/admin/users" else self.send_body(403, "Admins only.", "text/plain")
if path == "/admin":
with open(os.path.join(HERE, "admin.html"), "rb") as f:
return self.send_body(200, f.read(), "text/html; charset=utf-8", [("Cache-Control", "no-cache")])
return self.json(200, self.user_list())
if path == "/sync/progress":
return self.json(200, read(F_PROGRESS, {}).get(user, {}))
if any(p.match(path) for p in ALLOWED):
@@ -235,6 +252,13 @@ class Handler(BaseHTTPRequestHandler):
user = self.user()
if not user:
return self.deny(path)
if path.startswith("/admin/users"):
if not read(F_USERS, {}).get(user, {}).get("admin"):
return self.json(403, {"error": "admins only"})
if not self.headers.get("Content-Type", "").startswith("application/json"):
return self.json(415, {"error": "json only"}) # blocks cross-site form posts
with lock:
return self.admin_api(user, path, body)
if path == "/sync/progress":
with lock:
allp = read(F_PROGRESS, {})
@@ -246,6 +270,62 @@ class Handler(BaseHTTPRequestHandler):
return self.json(200, mine)
self.send_error(404)
def user_list(self):
prog = read(F_PROGRESS, {})
out = []
for name, rec in sorted(read(F_USERS, {}).items()):
p = prog.get(name, {})
out.append({"name": name, "admin": bool(rec.get("admin")), "books": len(p), "last": max((v.get("t", 0) for v in p.values()), default=0)})
return out
def admin_api(self, me, path, body):
users = read(F_USERS, {})
bad = lambda msg, code=400: self.json(code, {"error": msg})
if path == "/admin/users": # create
name = str(body.get("username", "")).strip().lower()
pw = str(body.get("password", ""))
if not USERNAME.match(name):
return bad("Username: 1-32 characters, letters, numbers, . _ -")
if name in users:
return bad("That username is taken.")
if len(pw) < 6:
return bad("Password must be at least 6 characters.")
users[name] = hash_pw(pw)
if body.get("admin"):
users[name]["admin"] = True
write(F_USERS, users, private=True)
return self.json(200, {"ok": True})
m = re.match(r"^/admin/users/([^/]+)/(password|admin|delete)$", path)
if not m:
return self.send_error(404)
name, action = unquote(m.group(1)).lower(), m.group(2)
if name not in users:
return bad("No such user.", 404)
others_admin = any(r.get("admin") for n, r in users.items() if n != name)
if action == "password":
pw = str(body.get("password", ""))
if len(pw) < 6:
return bad("Password must be at least 6 characters.")
users[name] = {**hash_pw(pw), **({"admin": True} if users[name].get("admin") else {})}
write(F_USERS, users, private=True)
revoke_sessions(name) # signs them out everywhere
elif action == "admin":
flag = bool(body.get("admin"))
if not flag and not others_admin:
return bad("There must be at least one admin.")
users[name]["admin"] = flag
write(F_USERS, users, private=True)
else: # delete
if name == me:
return bad("You can't delete your own account.")
users.pop(name)
write(F_USERS, users, private=True)
revoke_sessions(name)
prog = read(F_PROGRESS, {})
if prog.pop(name, None) is not None:
write(F_PROGRESS, prog)
self.json(200, {"ok": True})
def login(self, body):
ip = self.client_address[0]
now = time.time()
@@ -327,6 +407,14 @@ def serve(argv):
if legacy and all(isinstance(v, dict) and "pos" in v for v in legacy.values()):
write(F_PROGRESS, {du: legacy})
print(f"Created default account '{du}'.")
if du in users and not users[du].get("admin"): # the default account is always an admin
users[du]["admin"] = True
write(F_USERS, users, private=True)
if users and not any(r.get("admin") for r in users.values()): # accounts from before roles existed
first = next(iter(users))
users[first]["admin"] = True
write(F_USERS, users, private=True)
print(f"No admin found; made '{first}' an admin.")
if not users:
sys.exit("No accounts yet. Run: python3 server.py adduser NAME (or set PLAYER_USER and PLAYER_PASSWORD)")
if not oa_login()[0]: