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 ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
WORKDIR /app WORKDIR /app
COPY server.py index.html ./ COPY server.py index.html admin.html ./
USER app USER app
VOLUME /data VOLUME /data
EXPOSE 8080 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 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. 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} .ctl .pp{width:76px;height:76px;font-size:34px}
.opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px} .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} .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)} .pill.on{border-color:var(--acc);color:var(--acc)}
.chaps{background:var(--surf);border:1px solid var(--bd);border-radius:14px;overflow:hidden} .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} .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"> <header><div class="hd">
<div class="logo"><i><svg><use href="#i-book"/></svg></i><span>Audiobooks</span></div> <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> <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> <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> <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> </div></header>
@@ -389,7 +391,7 @@ const unlock = () => {
} }
}; };
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true })); ['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(); load();
</script> </script>
</body> </body>
+92 -4
View File
@@ -26,6 +26,7 @@ import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from urllib.parse import unquote
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__)) 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", "") 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): def sha(token):
return hashlib.sha256(token.encode()).hexdigest() return hashlib.sha256(token.encode()).hexdigest()
@@ -115,6 +123,8 @@ def cli(argv):
if name in users: if name in users:
sys.exit("That user already exists (use passwd).") sys.exit("That user already exists (use passwd).")
users[name] = hash_pw(ask_password()) users[name] = hash_pw(ask_password())
if len(users) == 1 or "--admin" in argv:
users[name]["admin"] = True
write(F_USERS, users, private=True) write(F_USERS, users, private=True)
if len(users) == 1 and os.path.exists(F_LEGACY): # carry over progress from before accounts if len(users) == 1 and os.path.exists(F_LEGACY): # carry over progress from before accounts
legacy = read(F_LEGACY, {}) legacy = read(F_LEGACY, {})
@@ -126,9 +136,9 @@ def cli(argv):
name = argv[1].lower() name = argv[1].lower()
if name not in users: if name not in users:
sys.exit("No such user.") 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_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.") print("Password changed; that user was signed out of all devices.")
elif cmd == "deluser": elif cmd == "deluser":
name = argv[1].lower() 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) write(F_SESSIONS, {k: v for k, v in read(F_SESSIONS, {}).items() if v["user"] != name}, private=True)
print(f"Removed '{name}'.") print(f"Removed '{name}'.")
elif cmd == "users": 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 ---------- # ---------- login page ----------
@@ -214,7 +224,14 @@ class Handler(BaseHTTPRequestHandler):
with open(os.path.join(HERE, "index.html"), "rb") as f: 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")]) return self.send_body(200, f.read(), "text/html; charset=utf-8", [("Cache-Control", "no-cache")])
if path == "/me": 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": if path == "/sync/progress":
return self.json(200, read(F_PROGRESS, {}).get(user, {})) return self.json(200, read(F_PROGRESS, {}).get(user, {}))
if any(p.match(path) for p in ALLOWED): if any(p.match(path) for p in ALLOWED):
@@ -235,6 +252,13 @@ class Handler(BaseHTTPRequestHandler):
user = self.user() user = self.user()
if not user: if not user:
return self.deny(path) 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": if path == "/sync/progress":
with lock: with lock:
allp = read(F_PROGRESS, {}) allp = read(F_PROGRESS, {})
@@ -246,6 +270,62 @@ class Handler(BaseHTTPRequestHandler):
return self.json(200, mine) return self.json(200, mine)
self.send_error(404) 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): def login(self, body):
ip = self.client_address[0] ip = self.client_address[0]
now = time.time() 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()): if legacy and all(isinstance(v, dict) and "pos" in v for v in legacy.values()):
write(F_PROGRESS, {du: legacy}) write(F_PROGRESS, {du: legacy})
print(f"Created default account '{du}'.") 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: if not users:
sys.exit("No accounts yet. Run: python3 server.py adduser NAME (or set PLAYER_USER and PLAYER_PASSWORD)") sys.exit("No accounts yet. Run: python3 server.py adduser NAME (or set PLAYER_USER and PLAYER_PASSWORD)")
if not oa_login()[0]: if not oa_login()[0]: