diff --git a/.gitignore b/.gitignore index 2d6a363..6ded754 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ __pycache__/ *.tar.gz server/data/ server.log +pi-session-*.html +.pytest_cache/ diff --git a/ani-web.service b/ani-web.service new file mode 100644 index 0000000..071c4ff --- /dev/null +++ b/ani-web.service @@ -0,0 +1,17 @@ +# Supervised replacement for run.sh's nohup (crash -> auto-restart). +# Install (then stop the run.sh instance: pkill -f 'uvicorn .*app:app'): +# sudo ln -s /home/jozamudi/code/ani-cli-front-end/ani-web.service /etc/systemd/system/ +# sudo systemctl daemon-reload && sudo systemctl enable --now ani-web +[Unit] +Description=ani-web (FastAPI anime front-end) +After=network-online.target + +[Service] +WorkingDirectory=/home/jozamudi/code/ani-cli-front-end +Environment=ANIWEB_SECURE=1 +ExecStart=/home/jozamudi/code/ani-cli-front-end/.venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/server/app.py b/server/app.py index 05af4fe..5745ada 100644 --- a/server/app.py +++ b/server/app.py @@ -11,7 +11,7 @@ import time from pathlib import Path from base64 import urlsafe_b64encode, urlsafe_b64decode from collections import OrderedDict -from urllib.parse import quote, urljoin +from urllib.parse import quote, urljoin, urlparse from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response @@ -57,6 +57,19 @@ def _lock(user: str, pw: str) -> str: return "bad" +@app.middleware("http") +async def csrf_header(request, call_next): + # ponytail: a cross-site form POST (top-level navigation) does carry the + # SameSite=Lax cookie, so
from a + # malicious page would work; forms can't set a custom header, the SPA + # always sends it. Runs *after* require_auth (registered earlier), so + # unauthenticated requests still get 302/401 first. + if request.method in ("POST", "DELETE") and request.url.path != "/login": + if request.headers.get("x-aniweb") != "1": + return JSONResponse({"detail": "forbidden"}, status_code=403) + return await call_next(request) + + @app.middleware("http") async def require_auth(request, call_next): if (request.url.path, request.method) in _PUBLIC: @@ -140,8 +153,9 @@ def _load_hist(user: str): def _save_hist(user: str, entries): HIST_DIR.mkdir(exist_ok=True) - # ponytail: unguarded read-modify-write per file, fine at this scale - (HIST_DIR / f"{user}.json").write_text(json.dumps(entries, indent=1)) + # ponytail: unguarded read-modify-write per file, fine at this scale; + # atomic_write keeps a crash/race from corrupting the file + users.atomic_write(HIST_DIR / f"{user}.json", json.dumps(entries, indent=1)) @app.get("/api/history") @@ -202,7 +216,7 @@ def _load_searches(user: str): def _save_searches(user: str, items): SEARCH_DIR.mkdir(exist_ok=True) - (SEARCH_DIR / f"{user}.json").write_text(json.dumps(items, indent=1)) + users.atomic_write(SEARCH_DIR / f"{user}.json", json.dumps(items, indent=1)) @app.get("/api/searches") @@ -305,6 +319,13 @@ _STREAM_MAX = 24 # segments ~1-2MB -> <= ~48MB def stream(token: str): # ponytail: buffers whole response in memory; .ts segments are small url = _url(token) + # ponytail: token is plain base64, so allowlist the host — otherwise any + # logged-in user could use this as a GET proxy to arbitrary URLs (SSRF). + # Verified 2026-08: master, variant and segment URLs all live on + # hls.anidb.app; if the CDN moves, this 400s loudly and the list updates. + p = urlparse(url) + if p.scheme != "https" or p.netloc != "hls.anidb.app": + raise HTTPException(400, "bad stream token") hit = _STREAM_CACHE.get(url) if hit: _STREAM_CACHE.move_to_end(url) diff --git a/server/upstream.py b/server/upstream.py index 36a9012..78f3ff5 100644 --- a/server/upstream.py +++ b/server/upstream.py @@ -131,7 +131,7 @@ def _master_variants(master_url): # cached public API (TTLs in seconds) search = lambda q: _cached(f"search:{q.lower()}", 300, _search, q) details = lambda s: _cached(f"details:{s}", 3600, _details, s) -episodes = lambda i: _cached(f"episodes:{i}", 3600, _episodes, i) +episodes = lambda i: _cached(f"episodes:{i}", 300, _episodes, i) # 5m: airing shows gain eps languages = lambda e: _cached(f"languages:{e}", 3600, _languages, e) master_m3u8 = lambda e, l="eng": _cached(f"master:{e}:{l}", 3600, _master_m3u8, e, l) master_variants = lambda u: _cached(f"variants:{u}", 3600, _master_variants, u) diff --git a/server/users.py b/server/users.py index 755064c..06a11ac 100644 --- a/server/users.py +++ b/server/users.py @@ -51,12 +51,20 @@ def load() -> dict: return {} +def atomic_write(path, text): + """tmp + os.replace: a crash mid-write leaves the previous file intact + (in-place writes corrupted the file -> load() returned {} -> accounts + or history silently vanished).""" + tmp = path.parent / (path.name + ".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(text) + os.replace(tmp, path) + + 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) + atomic_write(USERS, json.dumps(users, indent=1)) def add(username: str, password: str): @@ -90,6 +98,7 @@ def remove(username: str): users.pop(u, None) save(users) (DATA / "history" / f"{u}.json").unlink(missing_ok=True) + (DATA / "searches" / f"{u}.json").unlink(missing_ok=True) if __name__ == "__main__": diff --git a/static/app.js b/static/app.js index fa79222..8262c41 100644 --- a/static/app.js +++ b/static/app.js @@ -9,7 +9,7 @@ const main = document.getElementById("main"); let hls = null, detail = null, lang = "jpn", seasonId = null; async function api(path) { - const r = await fetch(BASE + path.replace(/^\//, "")); + const r = await fetch(BASE + path.replace(/^\//, ""), {headers: {"X-Aniweb": "1"}}); if (r.status === 401) { location.href = BASE + "login?next=" + encodeURIComponent(location.pathname + location.search); throw new Error("not authenticated"); @@ -56,10 +56,11 @@ function showStreamError(msg) { // ---------- history (server-persisted, incl. watch position) ---------- let history = []; async function refreshHistory() { - try { history = await api("/api/history"); } catch { /* home just shows empty */ } + // ?all=1: resume positions live beyond the top-5 cap + try { history = await api("/api/history?all=1"); } catch { /* home just shows empty */ } } function postHistory(entry) { - fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json"}, + fetch(BASE + "api/history", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, body: JSON.stringify(entry)}).catch(() => {}); } const fmtPos = s => { s = Math.floor(s || 0); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; @@ -139,7 +140,7 @@ function showHome() { // ---------- search ---------- function saveSearch(q) { - fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json"}, + fetch(BASE + "api/searches", {method: "POST", headers: {"Content-Type": "application/json", "X-Aniweb": "1"}, body: JSON.stringify({q})}).catch(() => {}); } @@ -313,7 +314,8 @@ function histCard(x) { e.preventDefault(); e.stopPropagation(); await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) + - "&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE"}); + "&ep_id=" + encodeURIComponent(x.ep_id), + {method: "DELETE", headers: {"X-Aniweb": "1"}}); toast("Removed from history"); await showHistory(); }; @@ -477,18 +479,24 @@ async function play(ep_id, ep_no) { const meta = v => ({...(Number.isFinite(v.duration) && v.duration > 0 ? {dur: v.duration} : {}), poster: detail ? (detail.poster || null) : null}); -function watchPos(v) { - const save = () => { - if (!currentEp || v.currentTime <= 3) return; - lastSavedPos = v.currentTime; - postHistory({...meta(v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, - ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: v.currentTime}); - }; - v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) save(); }); - v.addEventListener("pause", save); - window.addEventListener("pagehide", save); +// one savePos covers all video elements: it reads currentEp, so stale +// closures from earlier plays can't write the wrong episode's position +function savePos() { + if (!currentEp || currentEp.v.currentTime <= 3) return; + lastSavedPos = currentEp.v.currentTime; + postHistory({...meta(currentEp.v), title: detail.name, slug_id: detail.slug_id, season_id: seasonId, + ep_id: currentEp.ep_id, lang, ep: currentEp.ep_no, pos: currentEp.v.currentTime}); } +function watchPos(v) { + v.addEventListener("timeupdate", () => { if (v.currentTime - lastSavedPos >= 10) savePos(); }); + v.addEventListener("pause", savePos); + // pagehide is bound once at boot (per-play listeners accumulated and fired + // with stale video elements) +} + +window.addEventListener("pagehide", savePos); + // ---------- boot ---------- (async () => { document.querySelector(".logo").href = BASE; @@ -498,7 +506,7 @@ function watchPos(v) { initRecent(); const lo = document.getElementById("logout"); lo.onclick = async () => { - await fetch(BASE + "logout", {method: "POST"}); location.href = BASE + "login?signedout=1"; + await fetch(BASE + "logout", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?signedout=1"; }; const mb = document.getElementById("menubox"); mb.style.display = ""; @@ -510,7 +518,7 @@ function watchPos(v) { const da = document.getElementById("delacct"); da.onclick = async () => { if (!confirm("Delete your account and watch history?")) return; - await fetch(BASE + "account/delete", {method: "POST"}); location.href = BASE + "login?deleted=1"; + await fetch(BASE + "account/delete", {method: "POST", headers: {"X-Aniweb": "1"}}); location.href = BASE + "login?deleted=1"; }; } catch { /* not logged in: api() already redirected to /login */ } await refreshHistory(); diff --git a/tests/test_api.py b/tests/test_api.py index aa50409..725ef6c 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -25,7 +25,7 @@ def no_redirect(method, path, data=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"}) + headers={"Content-Type": "application/json", "X-Aniweb": "1"}) try: resp = opener.open(r) return resp.getcode(), resp.headers @@ -40,7 +40,8 @@ def 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"}) + method=method, + headers={"Content-Type": "application/json", "X-Aniweb": "1"}) if jar is not None: opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) try: @@ -168,14 +169,42 @@ def test_recent_searches(base=BASE): def test_self_delete(base=BASE): jar = login("dave", "davepw12345", base) + # give dave a searches file too: account delete must remove it + assert req("POST", "/api/searches", {"q": "dave-search"}, jar=jar, base=base) == 200 assert req("POST", "/account/delete", jar=jar, base=base) == 200 assert "dave" not in json.load(open(_users_file())) + assert not os.path.exists(os.path.join(os.environ["ANIWEB_DATA"], "searches", "dave.json")) # old session cookie is dead immediately (middleware re-checks existence) assert req("GET", "/api/me", jar=jar, base=base) == 401 jar2 = CJ() assert req("POST", "/login", {"username": "dave", "password": "davepw12345"}, jar2, base=base) == 401 +def test_csrf_header_required(base=BASE): + # mutation routes without the X-Aniweb header -> 403 (a cross-site form + # POST can't set it; the SPA always sends it). /login is exempt. + jar = login("admin", "adminpw12345", base) + r = urllib.request.Request(base + "/logout", method="POST") + try: + assert urllib.request.build_opener( + urllib.request.HTTPCookieProcessor(jar)).open(r).status == 403 + except urllib.error.HTTPError as e: + assert e.code == 403, e.code + # with the header (what the SPA sends) it works + assert req("POST", "/logout", jar=jar, base=base) == 200 + + +def test_stream_token_host_allowlist(base=BASE): + # tokens are plain base64: a crafted token must not let a logged-in user + # turn the server into a GET proxy for arbitrary URLs + import base64 + jar = login("alice", "alicepw12345", base) + bad = base64.urlsafe_b64encode(b"https://evil.example/x.m3u8").decode() + assert req("GET", f"/stream/{bad}", jar=jar, base=base) == 400 + bad2 = base64.urlsafe_b64encode(b"http://hls.anidb.app/x").decode() # non-https + assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400 + + def test_lockout_per_user(base=BASE): # must run on a fresh server (in-memory lockout): 5 fails -> 429 for bob, # admin unaffected @@ -187,8 +216,12 @@ def test_lockout_per_user(base=BASE): # --- helpers --- +import os # noqa: E402 + + def _json(method, path, jar=None, base=BASE): - r = urllib.request.Request(base + path, method=method) + r = urllib.request.Request(base + path, method=method, + headers={"X-Aniweb": "1"}) opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) if jar else urllib.request try: resp = opener.open(r) diff --git a/tests/test_browser.py b/tests/test_browser.py index 1b4be8b..890a37e 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -120,7 +120,7 @@ async def _history_page_and_delete(): "lang": "eng", "ep": n, "pos": 5, "dur": 600} op.open(urllib.request.Request(BASE + "/api/history", data=json.dumps(e).encode(), - headers={"Content-Type": "application/json"}, method="POST")) + headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) async with async_playwright() as p: browser = await p.chromium.launch(args=LAUNCH_ARGS) @@ -190,7 +190,7 @@ async def _recent_searches(): headers={"Content-Type": "application/json"}, method="POST")) op.open(urllib.request.Request(BASE + "/api/searches", data=json.dumps({"q": "chainsaw"}).encode(), - headers={"Content-Type": "application/json"}, method="POST")) + headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) async with async_playwright() as p: browser = await p.chromium.launch(args=LAUNCH_ARGS) @@ -268,7 +268,7 @@ async def _episode_watch_state(): e1_id = await page.evaluate("document.querySelector('#eps .ep').dataset.ep") await page.evaluate( "(id) => fetch('api/history', {method: 'POST'," - " headers: {'Content-Type': 'application/json'}," + " headers: {'Content-Type': 'application/json', 'X-Aniweb': '1'}," " body: JSON.stringify({title: 'Chainsaw Man', slug_id: 'chainsaw-man-922'," " season_id: 922, ep_id: +id, lang: 'eng', ep: 1, pos: 2350, dur: 2400})})", e1_id)