Add admin console for managing users; default account is admin
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f7cddd1e5
commit
f977f3294c
5 files changed
+222
-7
No files matched your search
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in new issue
Block a user