444 lines
20 KiB
Python
444 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""Audiobook player server: user accounts + a safe proxy to OpenAudible.
|
|
|
|
python3 server.py setup one-time: store the OpenAudible login the server uses
|
|
python3 server.py adduser NAME create an account (prompts for a password)
|
|
python3 server.py passwd NAME change a password
|
|
python3 server.py deluser NAME remove an account
|
|
python3 server.py users list accounts
|
|
python3 server.py [serve] run (--port 8080 --upstream http://172.16.16.106:8000)
|
|
|
|
Each person signs in with their own account and gets their own listening progress.
|
|
Only the server knows the OpenAudible password; it never reaches the browser.
|
|
"""
|
|
import argparse
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import sys
|
|
import threading
|
|
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__))
|
|
DATA = os.environ.get("DATA_DIR", HERE) # where accounts/progress live (a volume in Docker)
|
|
F_CONFIG = os.path.join(DATA, "config.json") # OpenAudible login (chmod 600)
|
|
F_USERS = os.path.join(DATA, "users.json") # name -> salted scrypt hash
|
|
F_SESSIONS = os.path.join(DATA, "sessions.json") # token hash -> {user, exp}
|
|
F_PROGRESS = os.path.join(DATA, "progress_users.json")
|
|
F_LEGACY = os.path.join(DATA, "progress.json") # single-user file from the first version
|
|
COOKIE = "oa_session"
|
|
SESSION_DAYS = 90
|
|
|
|
# Files the browser fetches without a session (home-screen icon and manifest).
|
|
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
|
|
"/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"}
|
|
|
|
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
|
|
ALLOWED = [re.compile(p) for p in (
|
|
r"^/api/books$",
|
|
r"^/api/books/[^/]+$",
|
|
r"^/api/books/[^/]+/(chapters|chapters/flat|cover)$",
|
|
r"^/api/books/[^/]+/files/[^/]+$",
|
|
r"^/admin/api/books/[^/]+/files$",
|
|
)]
|
|
PASS_HEADERS = ("Range", "If-None-Match", "If-Modified-Since")
|
|
RESP_HEADERS = ("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Last-Modified", "Cache-Control")
|
|
|
|
lock = threading.Lock()
|
|
|
|
|
|
# ---------- tiny JSON stores ----------
|
|
def read(path, default):
|
|
try:
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
except (OSError, ValueError):
|
|
return default
|
|
|
|
|
|
def write(path, data, private=False):
|
|
tmp = path + ".tmp"
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if private else 0o644)
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(data, f, indent=1)
|
|
os.replace(tmp, path)
|
|
|
|
|
|
def hash_pw(password, salt=None):
|
|
salt = salt or secrets.token_bytes(16)
|
|
h = hashlib.scrypt(password.encode(), salt=salt, n=2 ** 14, r=8, p=1)
|
|
return {"salt": salt.hex(), "hash": h.hex()}
|
|
|
|
|
|
def check_pw(password, rec):
|
|
h = hash_pw(password, bytes.fromhex(rec["salt"]))["hash"]
|
|
return hmac.compare_digest(h, rec["hash"])
|
|
|
|
|
|
def oa_login():
|
|
"""OpenAudible credentials: env vars win over config.json (handy for containers)."""
|
|
cfg = read(F_CONFIG, {})
|
|
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()
|
|
|
|
|
|
# ---------- command-line account management ----------
|
|
def ask_password(confirm=True):
|
|
while True:
|
|
p = getpass.getpass("Password: ")
|
|
if len(p) < 6:
|
|
print("Use at least 6 characters.")
|
|
elif confirm and getpass.getpass("Again: ") != p:
|
|
print("Those didn't match.")
|
|
else:
|
|
return p
|
|
|
|
|
|
def cli(argv):
|
|
cmd = argv[0]
|
|
users = read(F_USERS, {})
|
|
if cmd == "setup":
|
|
user = input("OpenAudible username: ")
|
|
pw = getpass.getpass("OpenAudible password: ")
|
|
write(F_CONFIG, {"user": user, "password": pw}, private=True)
|
|
print("Saved to config.json (readable only by you). All player accounts share this login.")
|
|
elif cmd == "adduser":
|
|
name = argv[1].lower()
|
|
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, {})
|
|
if legacy and all(isinstance(v, dict) and "pos" in v for v in legacy.values()):
|
|
write(F_PROGRESS, {name: legacy})
|
|
print(f"Imported your earlier listening progress into '{name}'.")
|
|
print(f"Created '{name}'.")
|
|
elif cmd == "passwd":
|
|
name = argv[1].lower()
|
|
if name not in users:
|
|
sys.exit("No such user.")
|
|
users[name] = {**hash_pw(ask_password()), **({"admin": True} if users[name].get("admin") else {})}
|
|
write(F_USERS, users, private=True)
|
|
revoke_sessions(name)
|
|
print("Password changed; that user was signed out of all devices.")
|
|
elif cmd == "deluser":
|
|
name = argv[1].lower()
|
|
users.pop(name, None)
|
|
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)
|
|
print(f"Removed '{name}'.")
|
|
elif cmd == "users":
|
|
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 = """<!doctype html><html lang=en><meta charset=utf-8>
|
|
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><title>Sign in</title>
|
|
<style>
|
|
:root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)}
|
|
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}}
|
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--fg);font:16px -apple-system,system-ui,sans-serif;padding:20px}
|
|
form{width:100%;max-width:360px;background:var(--s);border:1px solid var(--b);border-radius:20px;padding:28px;box-shadow:0 10px 40px rgba(0,0,0,.15)}
|
|
h1{margin:0 0 4px;font-size:24px;letter-spacing:-.02em}p{margin:0 0 20px;color:var(--m)}
|
|
input{width:100%;padding:14px;margin-bottom:12px;border-radius:12px;border:1px solid var(--b);background:var(--bg);color:var(--fg);font-size:16px;outline:0}
|
|
input:focus{border-color:var(--a)}
|
|
button{width:100%;padding:14px;border:0;border-radius:12px;background:var(--a);color:#fff;font-size:16px;font-weight:700}
|
|
#e{color:#e03131;min-height:22px;font-size:14px;margin-bottom:6px}
|
|
</style>
|
|
<form id=f><h1>Audiobooks</h1><p>Sign in to listen.</p>
|
|
<input name=u placeholder=Username autocomplete=username autocapitalize=none autocorrect=off required autofocus>
|
|
<input name=p type=password placeholder=Password autocomplete=current-password required>
|
|
<div id=e></div><button>Sign in</button></form>
|
|
<script>
|
|
f.onsubmit=async e=>{e.preventDefault();const E=document.getElementById('e');E.textContent='';
|
|
const r=await fetch('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:f.u.value,password:f.p.value})});
|
|
if(r.ok)location.href='/';else E.textContent=r.status==429?'Too many attempts. Wait a minute.':'Wrong username or password.'}
|
|
</script></html>"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
upstream = ""
|
|
fails = {} # ip -> [timestamps] for simple login throttling
|
|
|
|
# ----- helpers -----
|
|
def send_body(self, code, body, ctype, extra=()):
|
|
if isinstance(body, str):
|
|
body = body.encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", ctype)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
for k, v in extra:
|
|
self.send_header(k, v)
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def json(self, code, obj, extra=()):
|
|
self.send_body(code, json.dumps(obj), "application/json", extra)
|
|
|
|
def user(self):
|
|
c = SimpleCookie(self.headers.get("Cookie", ""))
|
|
if COOKIE not in c:
|
|
return None
|
|
s = read(F_SESSIONS, {}).get(sha(c[COOKIE].value))
|
|
return s["user"] if s and s["exp"] > time.time() else None
|
|
|
|
def deny(self, path):
|
|
if self.command == "GET" and path in ("/", "/index.html"):
|
|
self.send_response(302)
|
|
self.send_header("Location", "/login")
|
|
self.end_headers()
|
|
else:
|
|
self.json(401, {"error": "not signed in"})
|
|
|
|
# ----- routes -----
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0]
|
|
if path == "/login":
|
|
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
|
|
if path == "/logout":
|
|
return self.logout()
|
|
if path in PUBLIC:
|
|
with open(os.path.join(HERE, path[1:]), "rb") as f:
|
|
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "no-cache" if path in ("/sw.js", "/manifest.webmanifest") else "public, max-age=86400")])
|
|
user = self.user()
|
|
if not user:
|
|
return self.deny(path)
|
|
if path in ("/", "/index.html"):
|
|
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, "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):
|
|
return self.proxy()
|
|
self.send_error(404)
|
|
|
|
def do_POST(self):
|
|
path = self.path.split("?")[0]
|
|
n = int(self.headers.get("Content-Length", 0) or 0)
|
|
if n > 1_000_000:
|
|
return self.send_error(413)
|
|
try:
|
|
body = json.loads(self.rfile.read(n) or b"{}")
|
|
except ValueError:
|
|
return self.json(400, {"error": "bad json"})
|
|
if path == "/login":
|
|
return self.login(body)
|
|
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, {})
|
|
mine = allp.setdefault(user, {})
|
|
for k, v in body.items(): # newest timestamp wins
|
|
if isinstance(v, dict) and v.get("t", 0) >= mine.get(k, {}).get("t", 0):
|
|
mine[k] = {"pos": v.get("pos", 0), "dur": v.get("dur", 0), "t": v.get("t", 0)}
|
|
write(F_PROGRESS, allp)
|
|
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()
|
|
recent = [t for t in self.fails.get(ip, []) if now - t < 60]
|
|
if len(recent) >= 8:
|
|
return self.json(429, {"error": "slow down"})
|
|
name = str(body.get("username", "")).strip().lower()
|
|
rec = read(F_USERS, {}).get(name)
|
|
ok = bool(rec) and check_pw(str(body.get("password", "")), rec)
|
|
if not ok:
|
|
self.fails[ip] = recent + [now]
|
|
return self.json(401, {"error": "wrong"})
|
|
token = secrets.token_urlsafe(32)
|
|
with lock:
|
|
sessions = {k: v for k, v in read(F_SESSIONS, {}).items() if v["exp"] > now}
|
|
sessions[sha(token)] = {"user": name, "exp": now + SESSION_DAYS * 86400}
|
|
write(F_SESSIONS, sessions, private=True)
|
|
cookie = f"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={SESSION_DAYS * 86400}"
|
|
self.json(200, {"user": name}, [("Set-Cookie", cookie)])
|
|
|
|
def logout(self):
|
|
c = SimpleCookie(self.headers.get("Cookie", ""))
|
|
if COOKIE in c:
|
|
with lock:
|
|
s = read(F_SESSIONS, {})
|
|
s.pop(sha(c[COOKIE].value), None)
|
|
write(F_SESSIONS, s, private=True)
|
|
self.send_response(302)
|
|
self.send_header("Set-Cookie", f"{COOKIE}=; Path=/; Max-Age=0")
|
|
self.send_header("Location", "/login")
|
|
self.end_headers()
|
|
|
|
def proxy(self):
|
|
oa_user, oa_pass = oa_login()
|
|
req = urllib.request.Request(self.upstream + self.path)
|
|
if oa_user:
|
|
cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode()
|
|
req.add_header("Authorization", "Basic " + cred)
|
|
for h in PASS_HEADERS:
|
|
if self.headers.get(h):
|
|
req.add_header(h, self.headers[h])
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=30)
|
|
except urllib.error.HTTPError as e:
|
|
resp = e
|
|
except Exception as e:
|
|
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
|
|
code = getattr(resp, "status", None) or resp.code
|
|
if code == 401: # the server-side OpenAudible login is wrong; not the listener's fault
|
|
return self.json(502, {"error": "OpenAudible login rejected; run: python3 server.py setup"})
|
|
self.send_response(code)
|
|
for h in RESP_HEADERS:
|
|
if resp.headers.get(h):
|
|
self.send_header(h, resp.headers[h])
|
|
self.end_headers()
|
|
try:
|
|
while chunk := resp.read(64 * 1024):
|
|
self.wfile.write(chunk)
|
|
except (BrokenPipeError, ConnectionResetError):
|
|
pass # player seeked or closed the stream
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
|
|
def serve(argv):
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--upstream", default=os.environ.get("OPENAUDIBLE_URL", "http://172.16.16.106:8000"))
|
|
ap.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8080)))
|
|
ap.add_argument("--host", default="0.0.0.0")
|
|
a = ap.parse_args(argv)
|
|
users = read(F_USERS, {})
|
|
du, dp = os.environ.get("PLAYER_USER", "").strip().lower(), os.environ.get("PLAYER_PASSWORD", "")
|
|
if du and dp and du not in users: # default account for containers; never overwrites an existing one
|
|
users[du] = hash_pw(dp)
|
|
write(F_USERS, users, private=True)
|
|
if len(users) == 1 and os.path.exists(F_LEGACY):
|
|
legacy = read(F_LEGACY, {})
|
|
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]:
|
|
print("Note: no OpenAudible login set. Run `python3 server.py setup` or set OPENAUDIBLE_USER/OPENAUDIBLE_PASSWORD.")
|
|
Handler.upstream = a.upstream.rstrip("/")
|
|
print(f"Player: http://localhost:{a.port} (OpenAudible at {Handler.upstream})")
|
|
ThreadingHTTPServer((a.host, a.port), Handler).serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
args = sys.argv[1:]
|
|
if args and args[0] in ("setup", "adduser", "passwd", "deluser", "users"):
|
|
if args[0] in ("adduser", "passwd", "deluser") and len(args) < 2:
|
|
sys.exit(f"Usage: python3 server.py {args[0]} NAME")
|
|
cli(args)
|
|
elif args and args[0] in ("-h", "--help"):
|
|
print(__doc__)
|
|
else:
|
|
serve(args[1:] if args and args[0] == "serve" else args)
|