multi-user auth (CLI-created accounts), per-user history, test suite

- server/users.py replaces credentials.py: users.json (0600), PBKDF2,
  one-time migration of the legacy single password to user admin
  (history.json -> history/admin.json)
- accounts are CLI-managed only: python -m server.users add|rm|list
  (no self-signup on an internet-exposed app); /signup route removed,
  login page back to username + password
- per-user 30-day signed session cookie (payload carries the
  username), per-user lockout, GET /api/me, POST /account/delete
- per-user watch history: server/data/history/<user>.json; ANIWEB_DATA
  env override for the data dir
- header chip: logged-in user, logout, delete account
- tests/: throwaway-server suite (port 8399, temp data dir) — 9 HTTP
  tests + 2 Playwright browser tests (login UI, real video playback
  behind auth); runs via python tests/run.py
This commit is contained in:
jozamudi committed 2026-08-19 16:17:56 +00:00
1 parent 76c45a8f29
commit c697500c7d
12 files changed
+595 -122

No files matched your search

+20 -5
View File
@@ -8,19 +8,30 @@ workflow: search, browse, and stream anime in the browser via anidb.app.
```sh ```sh
uv venv .venv uv venv .venv
uv pip install --python .venv/bin/python -r requirements.txt uv pip install --python .venv/bin/python -r requirements.txt
.venv/bin/python -m server.credentials # set the login password (one-time) .venv/bin/python -m server.users add <you> # create your account (one-time)
./run.sh ./run.sh
``` ```
Open <http://localhost:8321>. `run.sh` restarts cleanly if the server Open <http://localhost:8321>. `run.sh` restarts cleanly if the server
is already running; logs go to `server.log`. The server refuses to is already running; logs go to `server.log`. The server refuses to
start without a password. start without at least one user (a legacy `credentials.json`
password, if any, is migrated to user `admin`).
Account management (no self-signup by design):
```sh
.venv/bin/python -m server.users add <name> # add user (prompted password)
.venv/bin/python -m server.users rm <name> # remove user + their history
.venv/bin/python -m server.users list # list users
```
## Features ## Features
- Login: single shared password, 30-day session cookie, - Per-user accounts (CLI-created, no self-signup): 30-day signed
5 wrong attempts → 15-min lockout. PBKDF2-SHA256 hash + session session cookie, 5 wrong attempts → 15-min lockout per user.
secret in `server/data/credentials.json` (0600). PBKDF2-SHA256 hashes in `server/data/users.json` (0600).
- Per-user watch history (`server/data/history/<user>.json`);
in-header chip with logout + delete-account.
- Search with poster grid, detail view with synopsis + season tabs - Search with poster grid, detail view with synopsis + season tabs
- HLS playback (vendored hls.js), browser ABR across 1080/720/360p - HLS playback (vendored hls.js), browser ABR across 1080/720/360p
- Dub/sub toggle - Dub/sub toggle
@@ -49,6 +60,10 @@ SPEC.md design + the verified anidb.app data flow
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs. backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
- `python server/upstream.py` runs a self-check of the fetchers - `python server/upstream.py` runs a self-check of the fetchers
(search → details → episodes → dub stream for Chainsaw Man). (search → details → episodes → dub stream for Chainsaw Man).
- `python tests/run.py` runs the full test suite against a throwaway
server (:8399, temp data dir — never touches the real data). Browser
tests need playwright (`pip install playwright && playwright install
chromium`); they're skipped if absent.
- Episode titles are not available: anidb's episode API only returns - Episode titles are not available: anidb's episode API only returns
`{id, number, number2, filler}`. Adding titles needs a free `{id, number, number2, filler}`. Adding titles needs a free
MyAnimeList API key. MyAnimeList API key.
+15 -12
View File
@@ -51,19 +51,23 @@ Two moving parts, no database, no framework on the frontend.
- `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough) - `GET /api/anime/{id}/episodes` → `[{id, number, filler}]` (step 3, passthrough)
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, qualities}` - `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, qualities}`
(steps 5+6; token is base64url of the master m3u8 URL) (steps 5+6; token is base64url of the master m3u8 URL)
- `GET /api/history` / `POST /api/history` → watch history with - `GET /api/history` / `POST /api/history` → **per-user** watch
position, persisted to `server/data/history.json` (50 kept, top 5 history with position, persisted to
returned; upsert per anime+episode) `server/data/history/<user>.json` (50 kept, top 5 returned;
upsert per anime+episode).
- `GET /stream/{token}` → proxied HLS. Token = base64url of the - `GET /stream/{token}` → proxied HLS. Token = base64url of the
upstream URL. Rewrites every non-comment line of m3u8 bodies to upstream URL. Rewrites every non-comment line of m3u8 bodies to
point back at `/stream/`; proxies master, variants, and segments point back at `/stream/`; proxies master, variants, and segments
(bytes, no transcoding). (bytes, no transcoding).
- `GET /login` / `POST /login` / `POST /logout` → single-user auth: - `GET /login` / `POST /login` / `POST /logout` → multi-user auth:
PBKDF2-SHA256 password (`server/credentials.py`; hash + session PBKDF2-SHA256 passwords (`server/users.py`; `users.json` 0600),
secret in `server/data/credentials.json`, mode 0600), signed 30-day signed 30-day cookie carrying the username, 5-fail/15-min
cookie, 5-fail/15-min in-memory lockout. Everything else 401s in-memory lockout **per user**. Accounts are CLI-created only
(API) or redirects to `/login?next=` (pages). Server refuses to (`python -m server.users add|rm|list`) — no self-signup. One-time
start without credentials. migration: legacy single-password `credentials.json` → user
`admin`; `history.json` → `history/admin.json`.
- `GET /api/me`, `POST /account/delete` → current user / self-delete
(account + history file).
## Frontend ## Frontend
@@ -95,9 +99,8 @@ Open `http://localhost:8321` (bound to 0.0.0.0 for LAN access).
## Out of scope (v1) ## Out of scope (v1)
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip, Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip,
ani-schedule integration, multi-user accounts (single shared ani-schedule integration, episode titles (not in anidb's API — needs
password today — per-user history is the natural upgrade), episode a MyAnimeList API key). Add when someone asks.
titles (not in
anidb's API — needs a MyAnimeList API key). Add when someone asks. anidb's API — needs a MyAnimeList API key). Add when someone asks.
## Risks ## Risks
+76 -39
View File
@@ -6,46 +6,71 @@ Cloudflare workaround) and rewrites playlist URIs to go through itself.
""" """
import base64 import base64
import json import json
import os
import time import time
from pathlib import Path from pathlib import Path
from urllib.parse import quote, urljoin from urllib.parse import quote, urljoin
from fastapi import Body, FastAPI, HTTPException, Query from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner from itsdangerous import BadSignature, TimestampSigner
from . import credentials, upstream from . import upstream, users
ROOT = Path(__file__).parent.parent ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web") app = FastAPI(title="ani-web")
# ---------- auth: single user, signed cookie ---------- # ---------- auth: multi-user, signed cookies ----------
_SIGNER = TimestampSigner(credentials.load()["secret"]) # exits with instructions if unset users.migrate_legacy()
_SIGNER = TimestampSigner(users.get_secret())
_SESSION = "aniweb_session" _SESSION = "aniweb_session"
_SESSION_AGE = 30 * 86400 _SESSION_AGE = 30 * 86400
_PUBLIC = {("/login", "GET"), ("/login", "POST")} _PUBLIC = {("/login", "GET"), ("/login", "POST")}
# ponytail: in-memory lockout, single process — 5 fails lock for 15 min # ponytail: in-memory lockout per username, single process — 5 fails -> 15 min
_lockout = {"fails": 0, "until": 0.0} _lockout: dict = {}
def _auth_ok(request) -> bool: def _session_user(request) -> str | None:
try: try:
return _SIGNER.unsign(request.cookies.get(_SESSION, ""), payload = _SIGNER.unsign(request.cookies.get(_SESSION, ""), max_age=_SESSION_AGE)
max_age=_SESSION_AGE) == b"ok"
except BadSignature: except BadSignature:
return False return None
return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
def _lock(user: str, pw: str) -> str:
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0})
if time.time() < lo["until"]:
return "locked"
if users.verify(user, pw):
lo.update(fails=0, until=0.0)
return "ok"
lo["fails"] += 1
if lo["fails"] >= 5:
lo.update(fails=0, until=time.time() + 900)
time.sleep(0.5) # slow down brute force
return "bad"
@app.middleware("http") @app.middleware("http")
async def require_auth(request, call_next): async def require_auth(request, call_next):
if (request.url.path, request.method) in _PUBLIC or _auth_ok(request): if (request.url.path, request.method) in _PUBLIC:
return await call_next(request) return await call_next(request)
if request.url.path.startswith("/api/"): user = _session_user(request)
return JSONResponse({"detail": "not authenticated"}, status_code=401) if not user:
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "") if request.url.path.startswith("/api/"):
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302) return JSONResponse({"detail": "not authenticated"}, status_code=401)
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "")
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302)
request.state.user = user
return await call_next(request)
def _session_cookie(resp, user: str):
resp.set_cookie(_SESSION, _SIGNER.sign(b"user:" + user.encode()).decode(),
max_age=_SESSION_AGE, httponly=True, samesite="lax")
@app.get("/login", include_in_schema=False) @app.get("/login", include_in_schema=False)
@@ -55,19 +80,30 @@ def login_page():
@app.post("/login") @app.post("/login")
async def login(payload: dict = Body(...)): async def login(payload: dict = Body(...)):
if time.time() < _lockout["until"]: u = payload.get("username", "").lower()
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429) if not u:
if credentials.check(payload.get("password", "")): return JSONResponse({"detail": "username required"}, status_code=400)
_lockout.update(fails=0, until=0.0) state = _lock(u, payload.get("password", ""))
resp = JSONResponse({"ok": True}) if state == "ok":
resp.set_cookie(_SESSION, _SIGNER.sign(b"ok").decode(), resp = JSONResponse({"ok": True, "user": u})
max_age=_SESSION_AGE, httponly=True, samesite="lax") _session_cookie(resp, u)
return resp return resp
_lockout["fails"] += 1 if state == "locked":
if _lockout["fails"] >= 5: return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
_lockout.update(fails=0, until=time.time() + 900) return JSONResponse({"detail": "wrong username or password"}, status_code=401)
time.sleep(0.5) # slow down brute force
return JSONResponse({"detail": "wrong password"}, status_code=401)
@app.get("/api/me", include_in_schema=False)
def api_me(request: Request):
return {"user": request.state.user}
@app.post("/account/delete")
def delete_account(request: Request):
users.remove(request.state.user)
resp = JSONResponse({"ok": True})
resp.delete_cookie(_SESSION)
return resp
@app.post("/logout") @app.post("/logout")
@@ -77,40 +113,41 @@ def logout():
return resp return resp
HIST_FILE = Path(__file__).parent / "data" / "history.json" HIST_DIR = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data")) / "history"
def _load_hist(): def _load_hist(user: str):
try: try:
return json.loads(HIST_FILE.read_text()) return json.loads((HIST_DIR / f"{user}.json").read_text())
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
return [] return []
def _save_hist(entries): def _save_hist(user: str, entries):
HIST_FILE.parent.mkdir(exist_ok=True) HIST_DIR.mkdir(exist_ok=True)
# ponytail: unguarded read-modify-write, fine for a single-user tool # ponytail: unguarded read-modify-write per file, fine at this scale
HIST_FILE.write_text(json.dumps(entries, indent=1)) (HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1))
@app.get("/api/history") @app.get("/api/history")
def api_history(): def api_history(request: Request):
return sorted(_load_hist(), key=lambda e: e.get("t", 0), reverse=True)[:5] return sorted(_load_hist(request.state.user), key=lambda e: e.get("t", 0), reverse=True)[:5]
@app.post("/api/history") @app.post("/api/history")
def api_history_post(entry: dict = Body(...)): def api_history_post(entry: dict = Body(...), request: Request = None):
if "ep_id" not in entry: if "ep_id" not in entry:
raise HTTPException(422, "ep_id required") raise HTTPException(422, "ep_id required")
entry["t"] = time.time() entry["t"] = time.time()
entries = _load_hist() u = request.state.user
entries = _load_hist(u)
for i, e in enumerate(entries): for i, e in enumerate(entries):
if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"): if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"):
entries[i] = {**e, **entry} entries[i] = {**e, **entry}
break break
else: else:
entries.append(entry) entries.append(entry)
_save_hist(sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50]) _save_hist(u, sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
return entry return entry
-55
View File
@@ -1,55 +0,0 @@
"""credentials.py — single-user password + session secret for ani-web.
Run once: .venv/bin/python -m server.credentials
Stores a PBKDF2 password hash and a random session-signing secret in
server/data/credentials.json (mode 0600). No env vars, no DB.
"""
import getpass
import hashlib
import json
import os
import secrets
from pathlib import Path
FILE = Path(__file__).parent / "data" / "credentials.json"
ITER = 210_000 # OWASP 2023 minimum for PBKDF2-SHA256
def set_password():
pw = getpass.getpass("Choose a password (min 8 chars): ")
if len(pw) < 8:
raise SystemExit("password too short")
if pw != getpass.getpass("Again: "):
raise SystemExit("passwords did not match")
salt = secrets.token_bytes(16)
data = {
"v": 1,
"salt": salt.hex(),
"hash": hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, ITER).hex(),
"secret": secrets.token_hex(32),
}
FILE.parent.mkdir(exist_ok=True)
# ponytail: mode 0600 on create; won't tighten an existing file
fd = os.open(FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(data, f)
print(f"credentials saved to {FILE}")
def load():
if not FILE.exists():
raise SystemExit(
"no credentials yet — set a password first:\n"
" .venv/bin/python -m server.credentials")
return json.loads(FILE.read_text())
def check(password: str) -> bool:
d = load()
h = hashlib.pbkdf2_hmac("sha256", password.encode(),
bytes.fromhex(d["salt"]), d.get("iter", ITER))
return secrets.compare_digest(h.hex(), d["hash"])
if __name__ == "__main__":
set_password()
+116
View File
@@ -0,0 +1,116 @@
"""users.py — multi-user accounts for ani-web (PBKDF2, single json file).
CLI: .venv/bin/python -m server.users add <username> (password via prompt)
.venv/bin/python -m server.users rm <username>
.venv/bin/python -m server.users list
"""
import getpass
import hashlib
import json
import os
import re
import secrets
import time
from pathlib import Path
DATA = Path(os.environ.get("ANIWEB_DATA", Path(__file__).parent / "data"))
USERS = DATA / "users.json"
SECRET = DATA / "secret.key"
ITER = 210_000 # OWASP 2023 minimum for PBKDF2-SHA256
NAME_RE = re.compile(r"^[a-z0-9_-]{3,32}$")
def get_secret() -> bytes:
if SECRET.exists():
return SECRET.read_bytes()
s = secrets.token_bytes(32)
DATA.mkdir(exist_ok=True)
fd = os.open(SECRET, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(s)
return s
def migrate_legacy():
"""one-time: single-password era -> users.json + per-user history files."""
cred = DATA / "credentials.json"
if not USERS.exists() and cred.exists():
d = json.loads(cred.read_text())
save({"admin": {"salt": d["salt"], "hash": d["hash"], "created": time.time()}})
cred.unlink()
old_hist = DATA / "history.json"
if not (DATA / "history" / "admin.json").exists() and old_hist.exists():
(DATA / "history").mkdir(exist_ok=True)
old_hist.rename(DATA / "history" / "admin.json")
def load() -> dict:
try:
return json.loads(USERS.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save(users: dict):
DATA.mkdir(exist_ok=True)
# ponytail: mode 0600 on create; won't tighten an existing file
fd = os.open(USERS, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(users, f, indent=1)
def add(username: str, password: str):
u = username.lower()
if not NAME_RE.match(u):
raise ValueError("username: 3-32 chars, only a-z 0-9 _ -")
if len(password) < 8:
raise ValueError("password: min 8 chars")
users = load()
if u in users:
raise ValueError("username already taken")
salt = secrets.token_bytes(16)
users[u] = {"salt": salt.hex(),
"hash": hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITER).hex(),
"created": time.time()}
save(users)
def verify(username: str, password: str) -> bool:
d = load().get(username.lower())
if not d:
return False
h = hashlib.pbkdf2_hmac("sha256", password.encode(),
bytes.fromhex(d["salt"]), d.get("iter", ITER))
return secrets.compare_digest(h.hex(), d["hash"])
def remove(username: str):
u = username.lower()
users = load()
users.pop(u, None)
save(users)
(DATA / "history" / f"{u}.json").unlink(missing_ok=True)
if __name__ == "__main__":
import sys
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
if cmd == "add" and len(sys.argv) > 2:
name = sys.argv[2]
pw = getpass.getpass(f"Password for {name} (min 8 chars): ")
if pw != getpass.getpass("Again: "):
raise SystemExit("passwords did not match")
try:
add(name, pw)
except ValueError as e:
raise SystemExit(str(e))
print(f"added user {name.lower()}")
elif cmd == "rm" and len(sys.argv) > 2:
remove(sys.argv[2])
print(f"removed {sys.argv[2].lower()} (incl. history)")
elif cmd == "list":
for u in sorted(load()):
print(u)
else:
print(__doc__)
raise SystemExit(2)
+13
View File
@@ -217,6 +217,19 @@ function showPlayOverlay(v) {
// ---------- boot ---------- // ---------- boot ----------
(async () => { (async () => {
try {
const me = await api("/api/me");
document.getElementById("me").textContent = "hi, " + me.user;
const lo = document.getElementById("logout");
lo.style.display = ""; lo.onclick = async () => {
await fetch("/logout", {method: "POST"}); location.href = "/login";
};
const da = document.getElementById("delacct");
da.style.display = ""; da.onclick = async () => {
if (!confirm("Delete your account and watch history?")) return;
await fetch("/account/delete", {method: "POST"}); location.href = "/login";
};
} catch { /* not logged in: api() already redirected to /login */ }
await refreshHistory(); await refreshHistory();
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
+3
View File
@@ -12,6 +12,9 @@
<form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false"> <form onsubmit="const q=document.getElementById('q').value.trim(); if(q) location='?q='+encodeURIComponent(q); return false">
<input id="q" placeholder="search anime…" autocomplete="off"> <input id="q" placeholder="search anime…" autocomplete="off">
</form> </form>
<span id="me" class="muted"></span>
<button id="logout" class="tiny" style="display:none">logout</button>
<button id="delacct" class="tiny danger" style="display:none">delete</button>
</header> </header>
<main id="main"><p class="muted">Search for an anime to get started.</p></main> <main id="main"><p class="muted">Search for an anime to get started.</p></main>
<script src="hls.min.js"></script> <script src="hls.min.js"></script>
+12 -11
View File
@@ -12,8 +12,8 @@ form { background:var(--card); border:1px solid var(--bd); border-radius:10px;
padding:2rem; width:280px; } padding:2rem; width:280px; }
h1 { font-size:1.1rem; margin:0 0 1rem; color:var(--acc); } h1 { font-size:1.1rem; margin:0 0 1rem; color:var(--acc); }
input { width:100%; box-sizing:border-box; padding:.55rem .8rem; border-radius:8px; input { width:100%; box-sizing:border-box; padding:.55rem .8rem; border-radius:8px;
border:1px solid var(--bd); background:var(--bg); color:var(--fg); } border:1px solid var(--bd); background:var(--bg); color:var(--fg); margin-bottom:.6rem; }
button { width:100%; margin-top:.8rem; padding:.55rem; border:0; border-radius:8px; button { width:100%; margin-top:.4rem; padding:.55rem; border:0; border-radius:8px;
background:var(--acc); color:#000; font-weight:600; cursor:pointer; } background:var(--acc); color:#000; font-weight:600; cursor:pointer; }
.err { color:#f87171; font-size:.85rem; margin:.6rem 0 0; min-height:1.2em; } .err { color:#f87171; font-size:.85rem; margin:.6rem 0 0; min-height:1.2em; }
</style> </style>
@@ -21,22 +21,23 @@ button { width:100%; margin-top:.8rem; padding:.55rem; border:0; border-radius:8
<body> <body>
<form onsubmit="return false"> <form onsubmit="return false">
<h1>ani-web</h1> <h1>ani-web</h1>
<input type="password" placeholder="password" autofocus autocomplete="current-password"> <input id="user" placeholder="username" autocomplete="username">
<button>log in</button> <input type="password" id="pw" placeholder="password" autocomplete="current-password">
<p class="err"></p> <button id="go">log in</button>
<p class="err" id="err"></p>
</form> </form>
<script> <script>
const err = document.querySelector(".err"); const $ = id => document.getElementById(id);
document.querySelector("button").onclick = async () => { const next = new URLSearchParams(location.search).get("next");
err.textContent = ""; $("go").onclick = async () => {
$("err").textContent = "";
const r = await fetch("/login", {method: "POST", const r = await fetch("/login", {method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json"},
body: JSON.stringify({password: document.querySelector("input").value})}); body: JSON.stringify({username: $("user").value, password: $("pw").value})});
if (r.ok) { if (r.ok) {
const next = new URLSearchParams(location.search).get("next");
location.href = (next && next.startsWith("/") && !next.startsWith("//")) ? next : "/"; location.href = (next && next.startsWith("/") && !next.startsWith("//")) ? next : "/";
} else { } else {
err.textContent = (await r.json().catch(() => ({}))).detail || "login failed"; $("err").textContent = (await r.json().catch(() => ({}))).detail || "login failed";
} }
}; };
</script> </script>
+4
View File
@@ -6,6 +6,10 @@ header { display: flex; gap: 1rem; align-items: center; padding: 0.8rem 1.2rem;
border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 1; } border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--bg); z-index: 1; }
.logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); } .logo { font-weight: 700; font-size: 1.2rem; color: var(--acc); }
header form { flex: 1; max-width: 420px; } header form { flex: 1; max-width: 420px; }
button.tiny { padding: .2rem .55rem; font-size: .8rem; border-radius: 6px;
border: 1px solid var(--bd); background: var(--card); color: var(--fg); cursor: pointer; }
button.tiny.danger { border-color: #7f1d1d; color: #f87171; }
header #me { margin-left: auto; }
#q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd); #q { width: 100%; padding: 0.5rem 0.8rem; border-radius: 8px; border: 1px solid var(--bd);
background: var(--card); color: var(--fg); } background: var(--card); color: var(--fg); }
main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; } main { padding: 1.2rem; max-width: 1100px; margin: 0 auto; }
+86
View File
@@ -0,0 +1,86 @@
"""run.py — full ani-web test suite.
Spins up a throwaway server (127.0.0.1:8399, temp data dir, seeded
users admin/alice/bob), runs the API tests and (if playwright is
installed) the browser tests, then tears everything down. The real
server on :8321 and its data are never touched.
Usage: .venv/bin/python tests/run.py
"""
import asyncio
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).parent.parent
PORT = 8399
BASE = f"http://127.0.0.1:{PORT}"
USERS = (("admin", "adminpw12345"), ("alice", "alicepw12345"), ("bob", "bobpw12345"),
("dave", "davepw12345"))
def _wait_ready():
for _ in range(120):
try:
urllib.request.urlopen(BASE + "/login", timeout=1)
return
except Exception:
time.sleep(0.5)
raise SystemExit("server did not start")
def main():
data = Path(tempfile.mkdtemp(prefix="aniweb-test-"))
os.environ["ANIWEB_DATA"] = str(data)
sys.path.insert(0, str(ROOT))
from server import users
for name, pw in USERS:
users.add(name, pw)
(data / "history").mkdir()
(data / "history" / "admin.json").write_text(json.dumps([
{"title": "Seed Anime", "slug_id": "seed-1", "season_id": 1, "ep_id": "1",
"lang": "eng", "ep": "1", "pos": 10.0, "t": time.time()}]))
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app",
"--host", "127.0.0.1", "--port", str(PORT)],
cwd=ROOT, env=os.environ,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
_wait_ready()
import test_api # noqa: E402 (tests/ on sys.path via script dir)
# order matters: lockout test locks bob in memory, run it last
api_tests = [fn for n, fn in sorted(vars(test_api).items())
if n.startswith("test_") and n != "test_lockout_per_user"]
for fn in api_tests:
print(f"ok api.{fn.__name__}")
fn(BASE)
print("ok api.test_lockout_per_user")
test_api.test_lockout_per_user(BASE)
try:
import test_browser
except ImportError:
print("SKIP browser tests (pip install playwright && playwright install chromium)")
else:
print("ok browser (playwright)")
asyncio.run(test_browser.test_login_ui())
asyncio.run(test_browser.test_video_plays_behind_auth())
print("ALL TESTS PASSED")
finally:
proc.terminate()
proc.wait()
shutil.rmtree(data, ignore_errors=True)
if __name__ == "__main__":
main()
+158
View File
@@ -0,0 +1,158 @@
"""HTTP-level tests for ani-web (stdlib only). Run via tests/run.py or
standalone against a running server: python tests/test_api.py <base_url>
Uses a throwaway server + temp data dir (see run.py) — never touches
the real server/data. Seeded users: admin/alice/bob (pw <name>pw12345).
"""
import http.cookiejar as _cj
import json
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8399"
CJ = lambda: _cj.CookieJar()
class _NoRedirect(urllib.request.HTTPRedirectHandler):
"""urlopen follows 302s by default; we want to *see* them."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def no_redirect(method, path, data=None, base=BASE):
"""Request without following redirects; returns (status, headers)."""
opener = urllib.request.build_opener(_NoRedirect)
r = urllib.request.Request(base + path,
data=json.dumps(data).encode() if data is not None else None,
method=method,
headers={"Content-Type": "application/json"})
try:
resp = opener.open(r)
return resp.getcode(), resp.headers
except urllib.error.HTTPError as e:
return e.code, e.headers
def no_redirect_get(path, base=BASE):
return no_redirect("GET", path, base=base)
def req(method, path, data=None, jar=None, base=BASE):
r = urllib.request.Request(base + path,
data=json.dumps(data).encode() if data is not None else None,
method=method, headers={"Content-Type": "application/json"})
if jar is not None:
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
try:
return opener.open(r).getcode()
except urllib.error.HTTPError as e:
return e.code
try:
return urllib.request.urlopen(r).status
except urllib.error.HTTPError as e:
return e.code
def login(user, pw, base=BASE):
jar = CJ()
assert req("POST", "/login", {"username": user, "password": pw}, jar, base) == 200, user
return jar
def test_unauthenticated(base=BASE):
# page -> 302 to /login, api -> 401
code, headers = no_redirect_get("/", base)
assert code == 302 and "/login" in headers["Location"], (code, headers.get("Location"))
assert req("GET", "/api/search?q=x", base=base) == 401
def test_wrong_password(base=BASE):
assert req("POST", "/login", {"username": "admin", "password": "nope"}, base=base) == 401
def test_login_and_access(base=BASE):
jar = login("admin", "adminpw12345", base)
assert req("GET", "/api/search?q=x", jar=jar, base=base) == 200
assert req("GET", "/api/me", jar=jar, base=base) == 200
def test_logout(base=BASE):
jar = login("admin", "adminpw12345", base)
assert req("POST", "/logout", jar=jar, base=base) == 200
# session gone: cookie jar emptied by the delete-cookie response
assert not [c for c in jar if c.name == "aniweb_session"], "cookie not cleared"
# and a cookie-less request is bounced to /login
code, headers = no_redirect_get("/", base)
assert code == 302 and "/login" in headers["Location"]
def test_no_self_signup(base=BASE):
# /signup route removed — unauthenticated POST bounces to /login
code, headers = no_redirect("POST", "/signup",
{"username": "zed", "password": "whatever123"}, base)
assert code in (302, 404, 405), code
# and even authenticated it's gone (no such route)
jar = login("admin", "adminpw12345", base)
assert req("POST", "/signup", {"username": "zed", "password": "whatever123"},
jar=jar, base=base) == 405
assert "zed" not in json.load(open(_users_file()))
def test_history_requires_ep_id(base=BASE):
jar = login("admin", "adminpw12345", base)
assert req("POST", "/api/history", {"slug_id": "x"}, jar=jar, base=base) == 422
def test_history_per_user(base=BASE):
a = login("admin", "adminpw12345", base)
b = login("alice", "alicepw12345", base)
entry = {"ep_id": "111", "slug_id": "test-111", "title": "Test", "season_id": 1,
"ep": "111", "lang": "eng", "pos": 42.0}
assert req("POST", "/api/history", entry, jar=a, base=base) == 200
got_a = _json("GET", "/api/history", jar=a, base=base)
got_b = _json("GET", "/api/history", jar=b, base=base)
assert any(e["ep_id"] == "111" for e in got_a)
assert all(e.get("ep_id") != "111" for e in got_b), "leak: admin entry in alice's history"
def test_self_delete(base=BASE):
jar = login("dave", "davepw12345", base)
assert req("POST", "/account/delete", jar=jar, base=base) == 200
assert "dave" not in json.load(open(_users_file()))
jar2 = CJ()
assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401
def test_lockout_per_user(base=BASE):
# must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob,
# admin unaffected
codes = [req("POST", "/login", {"username": "bob", "password": "bad"}, base=base)
for _ in range(6)]
assert codes[:5] == [401] * 5 and codes[5] == 429, codes
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200
# --- helpers ---
def _json(method, path, jar=None, base=BASE):
r = urllib.request.Request(base + path, method=method)
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) if jar else urllib.request
try:
resp = opener.open(r)
except urllib.error.HTTPError as e:
raise AssertionError(f"{method} {path} -> {e.code}")
return json.loads(resp.read().decode())
def _users_file():
import os
return os.path.join(os.environ["ANIWEB_DATA"], "users.json")
if __name__ == "__main__":
import sys
b = sys.argv[1] if len(sys.argv) > 1 else BASE
for name, fn in sorted(globals().items()):
if name.startswith("test_"):
print("ok", name)
fn(b)
+92
View File
@@ -0,0 +1,92 @@
"""Browser tests for ani-web (Playwright, headless Chromium).
Run via tests/run.py or standalone: python tests/test_browser.py <base_url>
Covers what HTTP tests can't: the login UI, the user chip, and real
video playback behind auth. Needs: pip install playwright && playwright install chromium
"""
import asyncio
import sys
from playwright.async_api import async_playwright
BASE = "http://127.0.0.1:8399"
LAUNCH_ARGS = ["--autoplay-policy=no-user-gesture-required"]
async def _login(page, user, pw):
await page.goto(BASE + "/")
await page.wait_for_selector("input#pw")
assert "create account" not in await page.content(), "self-signup UI must be absent"
await page.fill("#user", user)
await page.fill("#pw", pw)
await page.click("#go")
await page.wait_for_selector("#q", timeout=15000)
await page.wait_for_function(
f"document.getElementById('me').textContent.includes('{user}')", timeout=5000)
async def test_login_ui():
async with async_playwright() as p:
browser = await p.chromium.launch(args=LAUNCH_ARGS)
page = await (await browser.new_context()).new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
# unauth -> login form
await page.goto(BASE + "/")
await page.wait_for_selector("input#pw")
assert "/login" in page.url, page.url
# wrong password -> inline error
await page.fill("#user", "admin")
await page.fill("#pw", "wrong")
await page.click("#go")
await page.wait_for_function("document.querySelector('#err').textContent.length > 0",
timeout=10000)
assert "wrong" in await page.inner_text("#err")
# correct -> home with user chip
await page.fill("#pw", "adminpw12345")
await page.click("#go")
await _login_settled(page)
assert await page.inner_text("#me") == "hi, admin"
assert "Recently played" in await page.inner_text("#main"), "seeded history must show"
assert not errors, errors
await browser.close()
print("ok test_login_ui")
async def _login_settled(page):
await page.wait_for_selector("#q", timeout=15000)
await page.wait_for_function(
"document.getElementById('me').textContent.length > 0", timeout=5000)
async def test_video_plays_behind_auth():
async with async_playwright() as p:
browser = await p.chromium.launch(args=LAUNCH_ARGS)
page = await (await browser.new_context()).new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
await _login(page, "alice", "alicepw12345")
await page.fill("#q", "chainsaw")
await page.keyboard.press("Enter")
await page.wait_for_selector(".card", timeout=30000)
await page.click(".card")
await page.wait_for_selector("video", timeout=30000)
await page.wait_for_function(
"document.querySelector('video').readyState >= 4 && document.querySelector('video').currentTime > 1",
timeout=60000)
real_errors = [e for e in errors if "401" not in e]
assert not real_errors, real_errors
await browser.close()
print("ok test_video_plays_behind_auth")
if __name__ == "__main__":
if len(sys.argv) > 1:
BASE = sys.argv[1]
asyncio.run(test_login_ui())
asyncio.run(test_video_plays_behind_auth())