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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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}
|
.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>
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
Reference in new issue
Block a user