From f977f3294c88c518dea83983c3ee0501ed9561b2 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:53:40 -0400 Subject: [PATCH] Add admin console for managing users; default account is admin Co-Authored-By: Claude Sonnet 5.5 --- Dockerfile | 2 +- README.md | 2 +- admin.html | 125 +++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 4 +- server.py | 96 ++++++++++++++++++++++++++++++++++++++-- 5 files changed, 222 insertions(+), 7 deletions(-) create mode 100644 admin.html diff --git a/Dockerfile b/Dockerfile index bc7e87d..dd6814d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index a6d5568..3f9a44a 100644 --- a/README.md +++ b/README.md @@ -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://:8080` and sign in. Other commands: `users`, `passwd NAME`, `deluser NAME`. +Then open `http://: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. diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..cd7b119 --- /dev/null +++ b/admin.html @@ -0,0 +1,125 @@ + + + + + + +Admin · Audiobooks + + + +
+

Admin

← Back to player
+ +
+

Add a user

+
+ + + + +
+
+
+ +
+

Users

+
+
+
+ +
+

Reset password

+ +
+
+
+
+ + + + diff --git a/index.html b/index.html index 63f5a52..7e9a768 100644 --- a/index.html +++ b/index.html @@ -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
+ Admin
@@ -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(); diff --git a/server.py b/server.py index 8f98680..d3cb4a9 100644 --- a/server.py +++ b/server.py @@ -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]: