Compare commits

...
10 Commits
Author SHA1 Message Date
jozamudi cea04a20f6 player: fullscreen button (scales with the window)
'⛶ full' in the controls: native requestFullscreen on the video (it fills
the viewport and rescales on window resize for free), with the iOS
webkitEnterFullscreen fallback where the Fullscreen API doesn't exist.
Click again to exit. Live browser test: enter + exit via the button.
2026-09-19 00:11:45 +00:00
jozamudi 379f591180 history: flag rows whose slug no longer means the same anime
Provider switch (anidb -> hianime-first) left anidb-era rows whose slugs
200 on hianime as a DIFFERENT anime: /watch/jujutsu-kaisen-2552 is now
Burning Kabaddi (the trailing number is a hianime id), and the stored
anidb ep id doesn't even exist in that anime's list — the row rendered
as watched JJK but linked to the wrong show.

Client-side validation (no data migration): every history row's slug is
checked against GET /api/anime/{slug} once per slug; title mismatch (or
a dead slug) marks the entry stale. Stale rows:
- home: one card per stale anime above the hero + 'history ->' link
- history page: separate 'Moved or renamed on the source' section
Each card: title, 'find it ->' (search by stored title) and x (remove,
same endpoint as regular cards). Current rows are untouched: a hianime
slug whose details name matches the stored title passes.

Tests: API contract for a stale row (deletable while on the board);
live browser test (gated) with the real jujutsu-kaisen-2552 row:
remap card renders, card doesn't link to the wrong slug, find-it
lands on the title's search.
2026-09-18 23:25:35 +00:00
jozamudi 0f6bd0a52a docs: sync README + SPEC with the shipped reality
Both docs still described the pre-provider-layer single-source app:

- README: anidb-only framing, missing stats page, 'upstream.py is the
  ONLY Cloudflare-facing code', and a self-check command that does not
  work (plain script execution breaks the providers' relative imports;
  the asserts are anidb-specific under the hianime-first default) —
  now documents ANI_SOURCE=anidb + the -m form. Dropped the stale
  'episode titles are not available' note (Jikan titles shipped).
- SPEC: 'no database' -> SQLite, architecture -> 3 providers, full
  endpoint list (intros/searches/events/stats/cdn, DELETE history,
  ?all=1, {sub}), auth hardening (per-IP budget, X-Aniweb on /login),
  tests/run.py -> pytest + ANIWEB_LIVE_TESTS gating, out-of-scope list
  (countdown/intro-skip/titles shipped), risks updated for the
  fallback layer, milestone 8 pointer.
2026-09-16 21:14:26 +00:00
jozamudi 4aca78cab8 docs(db): legacy JSON files are deletable once tables are populated: the on-disk copies of users/history/searches were verified superseded by SQLite and removed; the docstring claimed they were kept as a snapshot 2026-09-16 21:14:26 +00:00
jozamudi cbfb2f96c8 pin exact dependency versions: curl_cffi impersonation churn must never update silently 2026-09-16 02:41:25 +00:00
jozamudi d02cb017d3 gate live-upstream browser e2e behind ANIWEB_LIVE_TESTS=1: provider rotation/takedowns were turning the suite red for non-regressions; default suite is hermetic (33 tests) again 2026-09-16 02:41:25 +00:00
jozamudi 882263d276 login POST now requires the X-Aniweb header: cross-site forced-login (attacker-form session swap) dies at the 403; login.html sends the header like the SPA 2026-09-16 02:41:25 +00:00
jozamudi 3e5c7062ab login: per-source-IP failure budget (auth.py) — username rotation no longer dodges the lockout; sliding window, XFF trusted only behind the proxy (ANIWEB_TRUST_XFF); lockout logic now unit-testable (tests/test_auth.py) 2026-09-16 02:31:23 +00:00
jozamudi c5c4dff851 Pin dependencies (full env freeze): curl_cffi/curl-impersonate churn breaks impersonation silently 2026-09-16 02:06:56 +00:00
jozamudi 7f531fc309 CSRF header check now covers /login (exemption removed); login.html sends X-Aniweb 2026-09-16 02:02:09 +00:00
14 changed files with 513 additions and 203 deletions

No files matched your search

+44 -28
View File
@@ -1,9 +1,9 @@
# ani-web # ani-web
Web front-end for anime streaming: search, browse, and stream anime in Web front-end for anime streaming: search, browse, and stream anime in
the browser via anidb.app. Multi-user, with per-user watch history and the browser (hianime.at primary; anidb.app and anikototv.to as
HLS playback proxied through the backend. Tuned for phone as much as fallbacks). Multi-user, with per-user watch history and HLS playback
desktop. proxied through the backend. Tuned for phone as much as desktop.
![login](docs/img/login.png) ![login](docs/img/login.png)
@@ -66,10 +66,11 @@ relative timestamps.
### Search ### Search
The header search box queries anidb.app and shows results as a poster The header search box queries the active source and shows results as a
grid. Your last 8 searches (per user) appear in a dropdown under the poster grid. Your last 8 searches (per user) appear in a dropdown under
box, filter live as you type, and click to re-run. Results open the the box, filter live as you type, and click to re-run. Results open the
detail view: synopsis, stats, season tabs, and filler badges. detail view: synopsis and season tabs (where the source provides
them), plus filler badges.
![search](docs/img/search.png) ![search](docs/img/search.png)
@@ -106,11 +107,20 @@ stall playback.
![detail](docs/img/detail.png) ![detail](docs/img/detail.png)
### Stats
`?stats` (hamburger menu) — watch minutes, episodes started/completed,
active days + best streak, sub/dub split, weekday and per-day heatmaps,
top-10 shows, top searches. Derived server-side from the position-event
stream (`POST /api/events`), 30–365 day windows.
## Layout ## Layout
``` ```
server/ FastAPI app (app.py), anidb.app fetchers (upstream.py), server/ FastAPI app (app.py), source providers (providers/: hianime,
accounts (users.py). upstream.py is the ONLY Cloudflare-facing code anidb, anikoto) over shared HTTP + dispatch (upstream.py),
accounts (users.py). upstream.py + providers/ are the ONLY
Cloudflare-facing code
static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored) static/ front-end: index.html, login.html, app.js, style.css, hls.min.js (vendored)
tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests tests/ pytest suite: conftest.py (throwaway server), HTTP + browser tests
docs/img/ README screenshots docs/img/ README screenshots
@@ -120,28 +130,34 @@ SPEC.md design + the verified anidb.app data flow
## Dev notes ## Dev notes
- anidb.app is behind Cloudflare; all upstream requests use - The upstream sites sit behind Cloudflare; all upstream requests use
`curl_cffi` Chrome TLS impersonation. If that ever stops working, `curl_cffi` Chrome TLS impersonation. Sources are ordered by
everything is isolated in `server/upstream.py`. `ANI_SOURCE` (default `hianime,anidb,anikoto`) — a dead one falls
through to the next. All upstream-facing code is isolated in
`server/upstream.py` + `server/providers/`.
- The video CDN sends no CORS headers, so playback goes through the - The video CDN sends no CORS headers, so playback goes through the
backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs. backend HLS proxy (`/stream/{token}`), which rewrites playlist URIs.
- `.venv/bin/python server/upstream.py` runs a self-check of the - `ANI_SOURCE=anidb .venv/bin/python -m server.upstream` runs a live
fetchers (search → details → episodes → dub stream for Chainsaw Man). self-check of the anidb fetchers (search → details → episodes → dub
- A background probe fetches 64KB from the CDN every 3 min and logs stream). Must be the `-m` form — plain script execution breaks the
state flips to `server.log`; `GET /api/cdn` (public) returns the providers' relative imports.
latest throughput + ~1h history. Use it to check if "playback - A background probe fetches 64KB from the CDN (and checks the origin
broken" is the CDN again. site) every 3 min and logs state flips to `server.log`; `GET
- The front-end is version-pinned: bump `?v=N` on `app.js`/`style.css` /api/cdn` (public) returns the latest throughput + ~1h history +
in `index.html` on every static change — phones/proxies otherwise origin state. Use it to check if "playback broken" is the CDN again.
serve stale JS (index.html itself is `no-store`). - The front-end is version-pinned: the server injects the git SHA as the
`?v=` cache-bust on `app.js`/`style.css` at startup (index.html itself
is `no-store`), so a restart is a cache bump — nothing to hand-bump.
- Mutations need the `X-Aniweb: 1` header (CSRF: cross-site form POSTs
can't set it), `/login` POST included — forced-login is dead.
- Login throttling (server/auth.py): per-username 5 fails -> 15 min lock,
plus a per-source-IP budget; both sliding windows, in-memory.
- Playback failures self-report: the client posts a step timeline + - Playback failures self-report: the client posts a step timeline +
capability fingerprint to `/api/events` (see the `events` table), capability fingerprint to `/api/events` (see the `events` table),
so a broken browser is diagnosable from the server. so a broken browser is diagnosable from the server.
- `.venv/bin/pytest tests/ -q` runs the full test suite against a - `.venv/bin/pytest tests/ -q` runs the test suite against a
throwaway server (:8399, temp data dir — never touches the real throwaway server (:8399, temp data dir — never touches the real
data). The API tests use stdlib only; the browser tests need data). The default suite (API + auth + dispatch tests) is hermetic
playwright (`uv pip install playwright && playwright install and deterministic; the Playwright browser tests scrape and play LIVE
chromium`) and are skipped if absent. upstream content, so they are opt-in: `ANIWEB_LIVE_TESTS=1
- Episode titles are not available: anidb's episode API only returns .venv/bin/pytest tests/ -q` (needs playwright chromium installed).
`{id, number, number2, filler}`. Adding titles needs a free
MyAnimeList API key.
+78 -32
View File
@@ -1,16 +1,20 @@
# ani-web — Web Front-End for ani-cli # ani-web — Web Front-End for ani-cli
Browse, search, and stream anime from the browser, using the same Browse, search, and stream anime from the browser. Sources are pluggable
anidb.app data source that ani-cli scrapes. providers (`server/providers/`): hianime.at primary, anidb.app (the
original ani-cli source) and anikototv.to as fallbacks.
**Status: implemented and running.** Deviations from the original **Status: implemented and running.** Deviations from the original
draft: hls.js is vendored locally (CDN was a silent failure point), draft: hls.js is vendored locally (CDN was a silent failure point),
history is server-side with watch-position resume (was: localStorage, history is server-side with watch-position resume (was: localStorage,
last 20), and the front-end lives in `static/` with the server code in last 20), storage is SQLite (was: JSON files), the front-end lives in
`server/`. See README.md. `static/` with the server code in `server/`. See README.md.
## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation) ## Verified data flow (spiked 2026-07, all 200 OK with Chrome TLS impersonation)
The anidb.app flow — one of the three providers (hianime.at is primary;
see `server/providers/hianime.py` for its flow).
| Step | Request | Yields | | Step | Request | Yields |
|---|---|---| |---|---|---|
| 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` | | 1. Search | `GET anidb.app/browse?q={query}` | HTML cards: `href=".../anime/{slug}-{id}"`, `title="{name}"`, poster `img src` |
@@ -33,41 +37,70 @@ or segments directly — **all HLS traffic must go through a backend proxy**.
Browser (vanilla JS + hls.js) Browser (vanilla JS + hls.js)
│ /api/* JSON /stream/* proxied HLS │ /api/* JSON /stream/* proxied HLS
▼ ▼
Python backend (FastAPI, uvicorn) Python backend (FastAPI, uvicorn, SQLite)
│ curl_cffi (Chrome impersonation) │ curl_cffi (Chrome impersonation)
▼ ▼
anidb.app + hls.anidb.app hianime.at / anidb.app / anikototv.to + their CDNs
``` ```
Two moving parts, no database, no framework on the frontend. Two moving parts, SQLite (users/history/searches/events), no framework
on the frontend.
## Backend ## Backend
`server/app.py` + `server/upstream.py`. Python 3.12, deps: `server/app.py` (routes), `server/upstream.py` (shared HTTP, TTL
`fastapi`, `uvicorn`, `curl_cffi`, `itsdangerous`. cache, provider dispatch), `server/providers/` (hianime, anidb,
anikoto), `server/db.py` (SQLite), `server/auth.py` (login throttling),
`server/users.py` (accounts). Python 3.12, deps: `fastapi`, `uvicorn`,
`curl_cffi`, `itsdangerous`.
Source dispatch: `ANI_SOURCE=hianime,anidb,anikoto` — ordered fallback;
an exception falls through to the next provider, a `None` from
`master_m3u8` ("no dub") is final.
- `GET /api/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1) - `GET /api/search?q=` → `[{id, slug_id, name, poster}]` (parse step 1)
- `GET /api/anime/{slug_id}` → `{id, name, synopsis, mal_id, seasons:[{slug_id,name,id}]}` - `GET /api/anime/{slug_id}` → `{id, name, synopsis, mal_id, seasons:[{slug_id,name,id}]}`
- `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}` - `GET /api/intros/{mal_id}` → per-episode MAL intro timings + episode
(steps 5+6; token is base64url of the master m3u8 URL) titles (keyless Jikan, 30-day cache; empty list when unavailable,
- `GET /api/history` / `POST /api/history` → **per-user** watch never an error that can touch the player)
- `GET /api/episode/{ep_id}/stream?lang=eng|jpn` → `{token, sub?}`
(steps 5+6; token is base64url of the master m3u8 URL; `sub` is the
external .vtt URL when the provider serves subs separately)
- `GET /api/history?all=1` / `POST /api/history` /
`DELETE /api/history?slug_id=&ep_id=` → **per-user** watch
history with position, persisted to history with position, persisted to
`server/data/aniweb.db` (SQLite; upsert per anime+episode). `server/data/aniweb.db` (SQLite; upsert per anime+episode, newest 50).
- `GET /api/searches` / `POST /api/searches` → per-user recent
searches (newest 8, case-insensitive).
- `POST /api/events` → client self-diagnostics (playback failures,
step timelines, capability probes) + the position stream the stats
are derived from (90-day retention).
- `GET /api/stats?days=&slug=` → watch minutes, episodes
started/completed, active days + best streak, weekday/per-day
heatmaps, top-10 shows, sub/dub split — derived from the
position-event stream.
- `GET /api/cdn` → CDN + origin health (background 64KB probe every
3 min, ~1h history) — check whether "playback broken" is the CDN.
- `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 (host-allowlisted against the providers' self-registered
point back at `/stream/`; proxies master, variants, and segments CDN hosts — anti-SSRF). Rewrites every non-comment line of m3u8
(bytes, no transcoding). Responses are `Cache-Control: public, bodies to point back at `/stream/`; proxies master, variants, and
max-age=86400` — VOD playlists/segments are immutable and the token segments (bytes, no transcoding), segments across parallel byte-range
is the exact upstream URL, so the browser disk cache serves re-opens lanes with single-stream fallback. Responses are `Cache-Control:
and re-seeks without touching the proxy. public, max-age=86400` — VOD playlists/segments are immutable and the
token is the exact upstream URL, so the browser disk cache serves
re-opens and re-seeks without touching the proxy.
- `GET /login` / `POST /login` / `POST /logout` → multi-user auth: - `GET /login` / `POST /login` / `POST /logout` → multi-user auth:
PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600), PBKDF2-SHA256 passwords (`server/users.py`; SQLite `aniweb.db`, 0600),
signed 30-day cookie carrying the username, 5-fail/15-min signed 30-day cookie carrying the username, 5-fail/15-min
in-memory lockout **per user**. Accounts are CLI-created only in-memory lockout **per user** plus a per-source-IP failure budget
(`python -m server.users add|rm|list`) — no self-signup. One-time (username rotation can't dodge it; XFF trusted only behind the
migration: legacy single-password `credentials.json` → user proxy). All mutations — including `POST /login` — require the
`admin`; `history.json` → `history/admin.json`. `X-Aniweb: 1` header (cross-site form POSTs can't set it). Accounts
are CLI-created only (`python -m server.users add|rm|list`) — no
self-signup. One-time migration (done): legacy single-password
`credentials.json` → user `admin`.
- Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and - Session hardening: cookies are `HttpOnly`, `SameSite=Lax`, and
`Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware `Secure` when `ANIWEB_SECURE=1` (set by `run.sh`); the middleware
re-verifies the account still exists per request, so deleted re-verifies the account still exists per request, so deleted
@@ -82,11 +115,15 @@ Two moving parts, no database, no framework on the frontend.
produced black screens. produced black screens.
Flow: search box → results grid (poster + title, click) → detail view Flow: search box → results grid (poster + title, click) → detail view
(synopsis under the episode grid, season tabs) → episode grid (number (synopsis and season tabs where the source provides them, under the
episode grid) → episode grid (number
+ filler badge, sub/dub toggle) → `<video>` with hls.js pointed at + filler badge, sub/dub toggle) → `<video>` with hls.js pointed at
`/stream/{token}`; single quality — the proxy fetches each segment `/stream/{token}`; single quality — the proxy fetches each segment
across parallel byte-range lanes (single-stream fallback). across parallel byte-range lanes (single-stream fallback).
Views: home (continue watching), search, detail+player, full history
(`?history`), stats (`?stats`); PWA-installable.
- Default play: opening an anime resumes the last watched episode of - Default play: opening an anime resumes the last watched episode of
the season (seeking to the saved position unless ≥95% through), the season (seeking to the saved position unless ≥95% through),
else autoplays episode 1. If the browser blocks autoplay, the native else autoplays episode 1. If the browser blocks autoplay, the native
@@ -103,22 +140,28 @@ uv venv .venv && uv pip install --python .venv/bin/python -r requirements.txt
``` ```
Use the app through your HTTPS proxy (with `Secure` cookies, plain Use the app through your HTTPS proxy (with `Secure` cookies, plain
remote HTTP can't hold a session). `python tests/run.py` runs the remote HTTP can't hold a session). `.venv/bin/pytest tests/ -q` runs
full suite against a throwaway server on :8399. the hermetic suite against a throwaway server on :8399; the
live-upstream browser e2e is opt-in (`ANIWEB_LIVE_TESTS=1`).
## Out of scope (v1) ## Out of scope (v1)
Downloads (`-d`), next-episode countdown (`-N`), syncplay, intro skip, Downloads (`-d`), syncplay, ani-schedule integration. (Next-episode
ani-schedule integration, episode titles (not in anidb's API — needs countdown, intro skip, and episode titles — via keyless Jikan —
a MyAnimeList API key). Add when someone asks. shipped since this list was written.) Add when someone asks.
## Risks ## Risks
- **Cloudflare policy change** → upstream breaks. Mitigation: swap - **Cloudflare policy change** → upstream breaks. Mitigation: swap
`impersonate="chrome"` for `"safari"`/binary curl-impersonate; `impersonate="chrome"` for `"safari"`/binary curl-impersonate;
all upstream access is isolated in one module (`upstream.py`). all upstream access is isolated in `upstream.py` + `providers/`,
- **anidb.app layout change** → parsers break. The 4 API endpoints and the ordered multi-source fallback turns one dead site into a
(steps 3–6) are clean JSON; only steps 1, 2, 5 are HTML/JS scraping. degraded result set, not an outage.
- **Provider layout change** → parsers break. For anidb the 4 API
endpoints (steps 3–6) are clean JSON; only steps 1, 2, 5 are
HTML/JS scraping. hianime/anikoto are fully scraped (higher rotation
risk), so they self-register CDN hosts and re-roll servers until one
serves.
- **ToS** — anidb.app is a third-party scraper site; personal use, - **ToS** — anidb.app is a third-party scraper site; personal use,
no redistribution, bind to LAN only by default. no redistribution, bind to LAN only by default.
@@ -133,3 +176,6 @@ a MyAnimeList API key). Add when someone asks.
states, autoplay default, `run.sh`. states, autoplay default, `run.sh`.
7. Auth — login screen, then multi-user CLI-created accounts with 7. Auth — login screen, then multi-user CLI-created accounts with
per-user history + test suite. per-user history + test suite.
8. Later (see git log, `docs/CHANGES-2026-09.md`): provider layer with
hianime primary, SQLite storage, stats/events, 2026-09 security
audit batch.
+8
View File
@@ -42,3 +42,11 @@ mutation, and login.html sends the header.
`curl_cffi` version churn = impersonation churn = silent upstream 403 `curl_cffi` version churn = impersonation churn = silent upstream 403
waves. requirements.txt becomes a full freeze; bump deliberately. waves. requirements.txt becomes a full freeze; bump deliberately.
## 6. live-upstream e2e gated off by default
The Playwright tests scrape and play real provider content — they go red
whenever a provider rotates or a title is taken down (observed repeatedly).
A suite that fails for non-regressions can't gate anything, so the default
run is now hermetic only; run the live e2e deliberately with
`ANIWEB_LIVE_TESTS=1`.
+9 -6
View File
@@ -1,8 +1,11 @@
fastapi # exact pins on purpose: curl_cffi behavior = impersonation behavior; an
uvicorn # unpinned bump is a silent upstream-403 wave. Bump one at a time, re-run
curl_cffi # the upstream self-check (.venv/bin/python server/upstream.py) per bump.
itsdangerous fastapi==0.141.1
uvicorn==0.52.4
curl_cffi==0.16.0
itsdangerous==2.2.0
# tests # tests
pytest pytest==9.1.1
playwright playwright==1.62.0
+3 -2
View File
@@ -25,8 +25,9 @@ fi
# ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS # ANIWEB_SECURE=1: session cookie is Secure — only usable via the HTTPS
# proxy (plain http://<this machine>:8321 can no longer hold a session). # proxy (plain http://<this machine>:8321 can no longer hold a session).
# ANIWEB_TRUST_XFF=1: behind that same local proxy, so X-Forwarded-For may # ANIWEB_TRUST_XFF=1: prod traffic arrives via the local HTTPS proxy, so the
# be trusted for the per-IP login budget (server/auth.py). # per-IP login budget keys on X-Forwarded-For; without the flag auth.py uses
# the socket peer only (the header is spoofable on a direct connection).
ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 & ANIWEB_SECURE=1 ANIWEB_TRUST_XFF=1 nohup .venv/bin/python -m uvicorn server.app:app --host 0.0.0.0 --port 8321 --timeout-graceful-shutdown 3 > server.log 2>&1 &
echo $! > "$PIDF" echo $! > "$PIDF"
echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in" echo "ani-web on http://localhost:8321 (pid $(cat "$PIDF")) — use the HTTPS proxy to log in"
+21 -26
View File
@@ -22,7 +22,7 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Redirect
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from itsdangerous import BadSignature, TimestampSigner from itsdangerous import BadSignature, TimestampSigner
from . import db, upstream, users from . import auth, db, upstream, users
ROOT = Path(__file__).parent.parent ROOT = Path(__file__).parent.parent
app = FastAPI(title="ani-web") app = FastAPI(title="ani-web")
@@ -125,9 +125,9 @@ _SESSION_AGE = 30 * 86400
_PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")} _PUBLIC = {("/login", "GET"), ("/login", "POST"), ("/api/cdn", "GET")}
_SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS _SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min # Login throttling lives in server/auth.py: per-username lockout PLUS a
# ponytail: in-memory lockout per username, single process — 5 fails -> 15 min # per-source-IP failure budget (username rotation can't dodge it). The
_lockout: dict = {} # bucket logic is unit-tested there (tests/test_auth.py), without the app.
def _session_user(request) -> str | None: def _session_user(request) -> str | None:
@@ -138,28 +138,20 @@ def _session_user(request) -> str | None:
return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None
def _lock(user: str, pw: str) -> str: # (throttle buckets + client_ip live in server/auth.py — see auth.locked /
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) # auth.record; the old per-username-only _lock dict was the audit finding)
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 csrf_header(request, call_next): async def csrf_header(request, call_next):
# ponytail: a cross-site form POST (top-level navigation) does carry the # ponytail: a cross-site form POST (top-level navigation) does carry the
# SameSite=Lax cookie, so <form action=.../account/delete> from a # SameSite=Lax cookie, so <form action=.../account/delete> from a
# malicious page would work; forms can't set a custom header, the SPA # malicious page would work; forms can't set a custom header, the SPA and
# always sends it. Runs *after* require_auth (registered earlier), so # the login page both send one. /login is covered too: login needs no
# unauthenticated requests still get 302/401 first. # cookie, so an exempt /login let a cross-site forced-login POST swap a
if request.method in ("POST", "DELETE") and request.url.path != "/login": # visitor's session onto the attacker's account. Runs *after* require_auth
# (registered earlier), so unauthenticated GETs still 302/401 first.
if request.method in ("POST", "DELETE"):
if request.headers.get("x-aniweb") != "1": if request.headers.get("x-aniweb") != "1":
return JSONResponse({"detail": "forbidden"}, status_code=403) return JSONResponse({"detail": "forbidden"}, status_code=403)
return await call_next(request) return await call_next(request)
@@ -194,20 +186,23 @@ def login_page():
@app.post("/login") @app.post("/login")
def login(payload: dict = Body(...)): def login(request: Request, payload: dict = Body(...)):
# SYNC def on purpose: _lock's 0.5s anti-brute-force sleep must run on # SYNC def on purpose: the 0.5s anti-brute-force sleep below must run on
# uvicorn's threadpool — as async def it ran ON the event loop and every # uvicorn's threadpool — as async def it ran ON the event loop and every
# wrong password stalled every in-flight request (streams included). # wrong password stalled every in-flight request (streams included).
u = payload.get("username", "").lower() u = payload.get("username", "").lower()
if not u: if not u:
return JSONResponse({"detail": "username required"}, status_code=400) return JSONResponse({"detail": "username required"}, status_code=400)
state = _lock(u, payload.get("password", "")) ip = auth.client_ip(request)
if state == "ok": if auth.locked(u, ip):
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
if users.verify(u, payload.get("password", "")):
auth.clear(u)
resp = JSONResponse({"ok": True, "user": u}) resp = JSONResponse({"ok": True, "user": u})
_session_cookie(resp, u) _session_cookie(resp, u)
return resp return resp
if state == "locked": auth.record(u, ip)
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429) time.sleep(0.5) # slow down brute force
return JSONResponse({"detail": "wrong username or password"}, status_code=401) return JSONResponse({"detail": "wrong username or password"}, status_code=401)
+53 -52
View File
@@ -1,68 +1,69 @@
"""auth.py — login throttling: per-username lockout + per-IP failure budget. """auth.py — login throttling: per-username lockout + per-source-IP budget.
Pure logic, no app imports, so it unit-tests without booting the app Lives outside app.py so it is unit-testable without booting the app
(tests/test_auth.py). The 0.5s wrong-password sleep is exactly why app.py's (tests/test_auth.py). Both buckets are in-memory timestamp lists — the app
login handler must stay a sync def: on the event loop it would stall every is single-process by design, so no store is needed; every read prunes
stream in flight; on the threadpool it only holds that one request. timestamps older than the window (sliding, not fixed).
Two in-memory layers (single process — same assumption as everything else): - per-username: FAILS_USER timestamps inside the window -> that account is
* per-username: 5 fails -> 15 min lockout for that name (was: the only locked, right or wrong password, until the newest fail ages out.
layer, so spraying fails across many names bypassed it entirely) - per-source-IP: IP_FAILS fails from one source across ANY usernames ->
* per-IP: IP_FAILS fails inside IP_WINDOW -> that IP is refused for the every login from that IP bounces. This is what username-rotation sweeps
rest of the window. Defaults are deliberately loose (15/300s): they cannot step around.
exist to stop username spraying, not to punish typos.
Client IP is request.client.host. X-Forwarded-For is consulted ONLY when
ANIWEB_TRUST_XFF=1 (run.sh sets it, because production traffic arrives via
the local HTTPS proxy); a directly-connected client can spoof the header,
so it is never a silent trust — without the flag client.host rules even if
XFF is present.
""" """
import os import os
import time import time
from . import users FAILS_USER = 5 # prod default
WINDOW = float(os.environ.get("ANIWEB_LOCK_WINDOW", "900")) # seconds
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "20")) # prod default
TRUST_XFF = os.environ.get("ANIWEB_TRUST_XFF") == "1"
LOCK_FAILS = 5 _user_fails: dict = {} # username -> [fail timestamps, newest last]
LOCK_SECONDS = 900 _ip_fails: dict = {} # client ip -> [fail timestamps, newest last]
IP_FAILS = int(os.environ.get("ANIWEB_IP_FAILS", "15"))
IP_WINDOW = float(os.environ.get("ANIWEB_IP_WINDOW", "300"))
_lockout: dict = {} # user -> {"fails": int, "until": float}
_ip_fails: dict = {} # ip -> [failure ts] (newest last, capped)
def client_ip(request) -> str: def client_ip(request) -> str:
"""Client IP. X-Forwarded-For is trusted ONLY when ANIWEB_TRUST_XFF=1 """Source of a login attempt: socket peer, or the proxy-reported client
(run.sh sets it because production is always behind the local HTTPS when ANIWEB_TRUST_XFF=1 (see module docstring — spoofable, hence gated)."""
proxy); unconditional trust would let any client forge the header and if TRUST_XFF:
dodge the per-IP budget.""" first = (request.headers.get("x-forwarded-for") or "").split(",", 1)[0].strip()
if os.environ.get("ANIWEB_TRUST_XFF") == "1": if first:
xff = request.headers.get("x-forwarded-for", "") return first
if xff: return request.client.host if request.client is not None else "?"
return xff.split(",", 1)[0].strip()
return request.client.host if request.client else "?"
def ip_fail(ip: str) -> None: def _fresh(ts_list, now: float):
_ip_fails[ip] = (_ip_fails.get(ip, []) + [time.time()])[-50:] return [t for t in (ts_list or ()) if now - t < WINDOW]
def ip_locked(ip: str) -> bool: def locked(user: str, ip: str, now: float | None = None) -> bool:
now = time.time() """True when either bucket is over budget — caller must not verify."""
ts = [t for t in _ip_fails.get(ip, ()) if now - t < IP_WINDOW] now = time.time() if now is None else now
if ts: return (len(_fresh(_user_fails.get(user), now)) >= FAILS_USER
_ip_fails[ip] = ts or len(_fresh(_ip_fails.get(ip), now)) >= IP_FAILS)
return len(ts) >= IP_FAILS
def lock(user: str, pw: str, ip: str) -> str: def record(user: str, ip: str, now: float | None = None) -> None:
"""-> "ok" | "bad" | "locked" (per-username lockout or per-IP budget)""" """Count one failed attempt against both buckets (pruning stale fails)."""
if ip_locked(ip): now = time.time() if now is None else now
return "locked" _user_fails[user] = _fresh(_user_fails.get(user), now) + [now]
lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) _ip_fails[ip] = _fresh(_ip_fails.get(ip), now) + [now]
if time.time() < lo["until"]:
return "locked"
if users.verify(user, pw): def clear(user: str) -> None:
lo.update(fails=0, until=0.0) """A correct password resets only that username's count; the IP bucket
return "ok" keeps aging on its own (one success must not un-lock a sweep)."""
lo["fails"] += 1 _user_fails.pop(user, None)
if lo["fails"] >= LOCK_FAILS:
lo.update(fails=0, until=time.time() + LOCK_SECONDS)
time.sleep(0.5) # slow down brute force (sync handler -> threadpool) def reset() -> None:
ip_fail(ip) """Test hook: drop all buckets."""
return "bad" _user_fails.clear()
_ip_fails.clear()
+3 -3
View File
@@ -7,9 +7,9 @@ reconnect cost is zero. WAL: SQLite serializes writers itself, so the
lost-update races of the JSON read-modify-write era are gone. lost-update races of the JSON read-modify-write era are gone.
One-time import: a table that is empty while its legacy JSON still exists One-time import: a table that is empty while its legacy JSON still exists
gets absorbed (users.json, history/*.json, searches/*.json). The JSON files gets absorbed (users.json, history/*.json, searches/*.json); the files are
are left in place as a last-known-good snapshot; the empty-table guard makes deletable once the tables are populated (the empty-table guard makes a
a second import impossible. Legacy ghost rows (same episode stored with int second import impossible). Legacy ghost rows (same episode stored with int
AND str ep_id) are deduped on import: newest t per (user, slug, ep). AND str ep_id) are deduped on import: newest t per (user, slug, ep).
""" """
import json import json
+95 -3
View File
@@ -127,7 +127,53 @@ const bar = (x, cls) => {
b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`})); b.append(Object.assign(el("div", "bar-fill"), {style: `width:${p}%`}));
return b; return b;
}; };
const histHref = x => `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; function histHref(x) { return `?anime=${x.slug_id}&season=${x.season_id}&ep=${x.ep_id}&lang=${x.lang}`; }
// ---------- stale-entry remap (provider switch) ----------
// History rows written while anidb was the source carry anidb slugs/ids. Under
// hianime-first dispatch, /watch/{anidb-slug} often 200s as a DIFFERENT anime
// (id 2552 = Burning Kabaddi) while the anidb ep id silently dies (not in that
// anime's list) — the row then links to the wrong show. Validate every row
// against the details endpoint; mismatch or dead slug -> remap board.
const _norm = s => String(s || "").toLowerCase().replace(/[^a-z0-9]/g, "");
const _match = (a, b) => {
const x = _norm(a), y = _norm(b);
return !!(x && y && (x === y || x.includes(y) || y.includes(x)));
};
const _remap = {};
async function checkEntry(x) {
if (x._remapped) return true;
if (_remap[x.slug_id] !== undefined) { x._remapped = _remap[x.slug_id]; return !_remap[x.slug_id]; }
let d;
try { d = await api(`/api/anime/${x.slug_id}`); } catch { d = null; }
const ok = !!(d && d.name && _match(d.name, x.title));
_remap[x.slug_id] = !ok;
x._remapped = !ok;
return ok;
}
function remapCard(x) {
const c = el("a", "card hist remap");
c.append(poster(x.poster, x.title),
el("div", "card-name", `"${x.title}" \u2014 not found on the current source`));
const row = el("div", "chips");
const go = el("button", "tiny", "find it \u2192");
go.onclick = e => { e.preventDefault(); e.stopPropagation(); location.href = "?q=" + encodeURIComponent(x.title || ""); };
const del = el("button", "del", "\u00d7");
del.title = "remove from history";
del.onclick = async e => {
e.preventDefault();
e.stopPropagation();
await fetch(BASE + "api/history?slug_id=" + encodeURIComponent(x.slug_id) +
"&ep_id=" + encodeURIComponent(x.ep_id), {method: "DELETE", headers: {"X-Aniweb": "1"}});
c.remove();
const i = history.indexOf(x);
if (i >= 0) history.splice(i, 1);
toast("Removed from history", {label: "Undo", fn: () => postHistory(x)});
};
row.append(go, del);
c.append(row);
return c;
}
// one entry per anime (latest row wins), newest first // one entry per anime (latest row wins), newest first
const byShow = () => { const byShow = () => {
const m = new Map(); const m = new Map();
@@ -189,6 +235,8 @@ function showHome() {
return; return;
} }
const hero = history[0]; const hero = history[0];
main.append(remapBoard());
if (hero && hero._remapped) return;
// 1. continue-watching hero // 1. continue-watching hero
const sec = el("section", "hero-sec"); const sec = el("section", "hero-sec");
const card = el("a", "hero"); const card = el("a", "hero");
@@ -398,6 +446,22 @@ async function showAnime(slug_id, opts = {}) {
anB.classList.toggle("on", autoNext); anB.classList.toggle("on", autoNext);
}; };
const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB]; const ctl = [langBtn, spd, prevB, nextB, fillB, jump, anB];
const fsB = el("button", "tiny", "\u26f6 full");
fsB.title = "fullscreen";
fsB.onclick = () => {
const v = document.querySelector("#player video");
if (!v) return;
if (document.fullscreenElement || document.webkitFullscreenElement) {
(document.exitFullscreen || document.webkitExitFullscreen).call(document);
return;
}
// native Fullscreen API: the video fills the window (and scales with it);
// iOS Safari has no element fullscreen, so fall back to its in-player one
if (v.requestFullscreen) v.requestFullscreen();
else if (v.webkitEnterFullscreen) v.webkitEnterFullscreen();
else if (v.webkitRequestFullscreen) v.webkitRequestFullscreen();
};
ctl.push(fsB);
if (document.pictureInPictureEnabled) { if (document.pictureInPictureEnabled) {
const pipB = el("button", "tiny", "\u29c9 PiP"); const pipB = el("button", "tiny", "\u29c9 PiP");
pipB.title = "picture-in-picture"; pipB.title = "picture-in-picture";
@@ -467,6 +531,22 @@ function langLabel() { return lang === "eng" ? "▶ dub" : "▶ sub"; }
// ---------- history page (full list + delete + client-side filter) ---------- // ---------- history page (full list + delete + client-side filter) ----------
const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done" const histClass = x => x.dur > 0 && x.pos >= x.dur * 0.95 ? "done"
: (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching"); : (x.t && Date.now() / 1000 - x.t > 7 * 86400 ? "hold" : "watching");
function remapBoard() {
// home: one stale card per stale anime (latest row), above the hero, so a
// switched-source user sees the remap CTA before the wrong-show hero
const m = new Map();
for (const x of history) if (x._remapped && (!m.has(x.slug_id) || x.t > m.get(x.slug_id).t)) m.set(x.slug_id, x);
const out = [];
if (m.size) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one"),
link("history \u2192", "?history")));
const grid = el("div", "grid mini");
for (const x of m.values()) grid.append(remapCard(x));
out.push(grid);
}
return out;
}
async function showHistory() { async function showHistory() {
document.title = "History \u2014 ani-web"; document.title = "History \u2014 ani-web";
skel(10); skel(10);
@@ -474,6 +554,7 @@ async function showHistory() {
main.innerHTML = ""; main.innerHTML = "";
main.append(el("h2", null, "History")); main.append(el("h2", null, "History"));
if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; } if (!items.length) { main.append(el("p", "muted", "Nothing here.")); focusMain(); return; }
await Promise.all(items.map(checkEntry));
main.append(...histBoard(items)); main.append(...histBoard(items));
focusMain(); focusMain();
} }
@@ -576,12 +657,14 @@ function topAnime(d, n = 0) {
return ta; return ta;
} }
function histBoard(items, limit = 0) { function histBoard(items, limit = 0) {
const good = items.filter(x => !x._remapped);
const stale = items.filter(x => x._remapped);
const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"}; const labels = {all: "all", watching: "watching", done: "completed", hold: "on hold"};
let active = "all"; let active = "all";
const chips = el("div", "chips"); const chips = el("div", "chips");
const grid = el("div", "grid"); const grid = el("div", "grid");
const draw = () => { const draw = () => {
let f = items.filter(x => active === "all" || histClass(x) === active); let f = good.filter(x => active === "all" || histClass(x) === active);
if (limit) f = f.slice(0, limit); if (limit) f = f.slice(0, limit);
grid.replaceChildren(...f.map(histCard)); grid.replaceChildren(...f.map(histCard));
for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active); for (const c of chips.children) c.classList.toggle("on", c.dataset.f === active);
@@ -593,7 +676,15 @@ function histBoard(items, limit = 0) {
chips.append(b); chips.append(b);
} }
draw(); draw();
return [chips, grid]; const out = [chips, grid];
if (stale.length) {
out.push(el("div", "sec-head").append(
el("h2", null, "Moved or renamed on the source \u2014 pick the right one")));
const sgrid = el("div", "grid mini");
for (const x of stale) sgrid.append(remapCard(x));
out.push(sgrid);
}
return out;
} }
function heatMap(byDay, days) { function heatMap(byDay, days) {
@@ -1169,6 +1260,7 @@ window.addEventListener("pagehide", () => savePos(0, true)); // keepalive: a pl
} catch { /* not logged in: api() already redirected to /login */ } } catch { /* not logged in: api() already redirected to /login */ }
statusPill(); statusPill();
await refreshHistory(); await refreshHistory();
await Promise.all(history.map(checkEntry));
const p = new URLSearchParams(location.search); const p = new URLSearchParams(location.search);
try { try {
if (p.get("q")) await showSearch(p.get("q")); if (p.get("q")) await showSearch(p.get("q"));
+1 -1
View File
@@ -49,7 +49,7 @@ const next = new URLSearchParams(location.search).get("next");
async function login() { async function login() {
$("err").textContent = ""; $("err").textContent = "";
const r = await fetch(BASE + "login", {method: "POST", const r = await fetch(BASE + "login", {method: "POST",
headers: {"Content-Type": "application/json"}, headers: {"Content-Type": "application/json", "X-Aniweb": "1"},
body: JSON.stringify({username: $("user").value, password: $("pw").value})}); body: JSON.stringify({username: $("user").value, password: $("pw").value})});
if (r.ok) { if (r.ok) {
location.href = next ? BASE + next.replace(/^\/+/, "") : BASE; location.href = next ? BASE + next.replace(/^\/+/, "") : BASE;
+4 -4
View File
@@ -53,10 +53,10 @@ def server():
data = Path(tempfile.mkdtemp(prefix="aniweb-test-")) data = Path(tempfile.mkdtemp(prefix="aniweb-test-"))
os.environ["ANIWEB_DATA"] = str(data) os.environ["ANIWEB_DATA"] = str(data)
# the per-IP login budget must never trip during the suite (all tests # test-tuned throttle: small IP budget + short window so the per-IP test
# share 127.0.0.1); per-username lockout stays at production defaults # can both trip the gate and watch it expire inside one test run
os.environ.setdefault("ANIWEB_IP_FAILS", "1000") os.environ["ANIWEB_IP_FAILS"] = "10"
os.environ.setdefault("ANIWEB_IP_WINDOW", "300") os.environ["ANIWEB_LOCK_WINDOW"] = "8"
sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT))
from server import db, users # noqa: E402 from server import db, users # noqa: E402
db.init() # schema before users.add / seeding db.init() # schema before users.add / seeding
+65 -3
View File
@@ -297,7 +297,8 @@ def test_self_delete(base=BASE):
def test_csrf_header_required(base=BASE): def test_csrf_header_required(base=BASE):
# mutation routes without the X-Aniweb header -> 403 (a cross-site form # 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. # POST can't set it; the SPA always sends it). /login is covered too now
# (see test_login_requires_custom_header — forced-login was the hole).
jar = login("admin", "adminpw12345", base) jar = login("admin", "adminpw12345", base)
r = urllib.request.Request(base + "/logout", method="POST") r = urllib.request.Request(base + "/logout", method="POST")
try: try:
@@ -320,15 +321,54 @@ def test_stream_token_host_allowlist(base=BASE):
assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400 assert req("GET", f"/stream/{bad2}", jar=jar, base=base) == 400
def test_login_requires_custom_header(base=BASE):
# forced-login CSRF: /login was the one POST route exempt from the custom
# header check, and login needs no cookie — so any cross-site <form>
# POST could silently sign a visiting browser into the attacker's account.
# /login now demands the header like every other mutation; a cross-site
# form cannot set it, so the whole attack class dies at the 403.
r = urllib.request.Request(base + "/login", method="POST",
data=json.dumps({"username": "admin",
"password": "adminpw12345"}).encode(),
headers={"Content-Type": "application/json"})
try:
urllib.request.build_opener().open(r)
raise AssertionError("POST /login without X-Aniweb must be 403")
except urllib.error.HTTPError as e:
assert e.code == 403, e.code
def test_lockout_per_user(base=BASE): def test_lockout_per_user(base=BASE):
# a throwaway name: the lockout is per-username and in-memory, and bob is # a throwaway name: the buckets are in-memory and per-username, and bob
# also used by the browser tests (a locked-out bob breaks their logins) # is also used by the browser tests (a locked-out bob breaks their logins)
codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base) codes = [req("POST", "/login", {"username": "lockout-ghost", "password": "bad"}, base=base)
for _ in range(6)] for _ in range(6)]
assert codes[:5] == [401] * 5 and codes[5] == 429, codes assert codes[:5] == [401] * 5 and codes[5] == 429, codes
assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200 assert req("POST", "/login", {"username": "admin", "password": "adminpw12345"}, base=base) == 200
def test_lockout_per_ip_and_expiry(base=BASE):
# per-username lockout is bypassable by rotating usernames; the per-IP
# budget (conftest: ANIWEB_IP_FAILS=10, window 8s) is not. Every name
# below is fresh, so a 429 can only come from the IP bucket.
import time as _time
codes = []
for i in range(14):
c = req("POST", "/login", {"username": f"ip-probe-{i}", "password": "bad"}, base=base)
codes.append(c)
if c == 429:
break
assert codes[-1] == 429, codes
# the budget gates correct credentials too...
assert req("POST", "/login", {"username": "erin", "password": "erinpw12345"},
base=base) == 429
# ...and it is a sliding window: once it passes, login works again
_time.sleep(8.6)
jar = CJ()
assert req("POST", "/login", {"username": "erin", "password": "erinpw12345"},
jar, base=base) == 200
# --- helpers --- # --- helpers ---
import os # noqa: E402 import os # noqa: E402
@@ -466,3 +506,25 @@ def test_fetch_parallel_stitches_range_lanes():
finally: finally:
u._session = real u._session = real
u._get = real_get u._get = real_get
def test_stale_entries_surfaced_as_remappable(base=BASE):
# provider switch left anidb-era rows: their slug 200s on hianime as a
# DIFFERENT anime (id 2552 = Burning Kabaddi). The client flags the
# mismatch; the API contract is unchanged — the entry is deletable by
# slug+ep while it sits on the remap board.
jar = login("bob", "bobpw12345", base)
stale = {"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1, "pos": 42}
good = {"title": "Seed Anime", "slug_id": "seed-1", "season_id": 1,
"ep_id": 1, "lang": "eng", "ep": 1, "pos": 10}
assert req("POST", "/api/history", stale, jar=jar, base=base) == 200
assert req("POST", "/api/history", good, jar=jar, base=base) == 200
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
got = [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"]
assert got and got[0]["ep_id"] == 35996 and got[0]["pos"] == 42, got
removed = _json("DELETE", "/api/history?slug_id=jujutsu-kaisen-2552&ep_id=35996",
jar=jar, base=base)
assert removed["removed"] >= 1, removed
all_ = _json("GET", "/api/history?all=1", jar=jar, base=base)
assert not [x for x in all_ if x["slug_id"] == "jujutsu-kaisen-2552"], all_
+49 -40
View File
@@ -1,52 +1,61 @@
"""test_auth.py — pure unit tests for server/auth.py (no server, no HTTP). """test_auth.py — server/auth.py unit tests (no server, no network).
The per-IP budget and the XFF trust rule live here; the per-username Bucket logic in isolation: per-username lockout, per-source-IP budget,
lockout's HTTP behaviour stays covered in test_api.py. sliding-window expiry, and the spoof-gating of X-Forwarded-For. The
module reads its env knobs at import; inside the pytest process they stay
at defaults (5 / 20 / 900s) — the tests read auth.FAILS_USER/IP_FAILS/
WINDOW rather than hardcoding, so they hold either way.
""" """
import time import sys
from types import SimpleNamespace from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from server import auth # noqa: E402
def _req(headers=None, host="203.0.113.7"): class _Peer:
return SimpleNamespace(headers=dict(headers or {}), host = "203.0.113.9"
client=SimpleNamespace(host=host))
def test_ip_budget_counts_prunes_isolates(monkeypatch): class _Req:
from server import auth def __init__(self, headers=None):
monkeypatch.setattr(auth, "IP_FAILS", 3) self.headers = headers or {}
monkeypatch.setattr(auth, "IP_WINDOW", 60.0) self.client = _Peer()
auth._ip_fails.clear()
assert not auth.ip_locked("203.0.113.1")
for _ in range(3):
auth.ip_fail("203.0.113.1")
assert auth.ip_locked("203.0.113.1")
assert not auth.ip_locked("203.0.113.2") # per-IP isolation
auth._ip_fails["203.0.113.1"] = [time.time() - 10_000] # stale -> pruned
assert not auth.ip_locked("203.0.113.1")
def test_client_ip_xff_trust_rule(monkeypatch): def test_username_lockout_and_sliding_expiry():
from server import auth auth.reset()
r = _req({"x-forwarded-for": "9.9.9.9"}) t = 1_000_000.0
monkeypatch.delenv("ANIWEB_TRUST_XFF", raising=False) for _ in range(auth.FAILS_USER):
assert auth.client_ip(r) == "203.0.113.7" # header NOT trusted by default auth.record("locked-guy", "198.51.100.7", now=t)
monkeypatch.setenv("ANIWEB_TRUST_XFF", "1") assert auth.locked("locked-guy", "198.51.100.7", now=t)
assert auth.client_ip(r) == "9.9.9.9" # honored only when explicitly on # window is sliding: once every fail ages out, the name is free again
assert auth.client_ip(_req()) == "203.0.113.7" # no header -> socket peer assert not auth.locked("locked-guy", "198.51.100.7", now=t + auth.WINDOW + 1)
# a locked username does not gate other usernames from another ip
assert not auth.locked("fresh-guy", "198.51.100.8", now=t)
# a correct password clears only that username's bucket
auth.clear("locked-guy")
assert not auth.locked("locked-guy", "198.51.100.9", now=t)
auth.reset()
def test_lock_locked_user_never_reaches_verify(monkeypatch): def test_ip_budget_survives_username_rotation():
from server import auth auth.reset()
monkeypatch.setattr(auth, "IP_FAILS", 1000) t = 2_000_000.0
auth._lockout["ghost"] = {"fails": 0, "until": time.time() + 900} for i in range(auth.IP_FAILS): # one fresh username per fail...
assert auth.lock("ghost", "x", "203.0.113.3") == "locked" auth.record(f"sweep-{i}", "198.51.100.9", now=t)
assert auth.locked("brand-new-user", "198.51.100.9", now=t) # ...still blocked
assert not auth.locked("brand-new-user", "198.51.100.10", now=t) # other ip fine
assert not auth.locked("brand-new-user", "198.51.100.9", now=t + auth.WINDOW + 1)
auth.reset()
def test_lock_wrong_password_is_bad_and_records_ip_failure(monkeypatch): def test_client_ip_trust_gating():
from server import auth # XFF is only consulted when the trust flag is on (it is off here — the
monkeypatch.setattr(auth, "IP_FAILS", 1000) # keep the IP gate out of the way # flag is for the prod proxy path, not the test process)
auth._ip_fails.clear() r = _Req({"x-forwarded-for": "5.6.7.8, 10.0.0.1"})
auth._lockout.clear() # module state is shared across tests assert auth.TRUST_XFF is False
assert auth.lock("ghost", "wrong123456", "203.0.113.4") == "bad" # ghost: no user assert auth.client_ip(r) == "203.0.113.9" # spoofed header ignored
assert len(auth._ip_fails.get("203.0.113.4", [])) == 1 assert auth.client_ip(_Req()) == "203.0.113.9"
+80 -3
View File
@@ -2,15 +2,24 @@
Run via pytest (see conftest.py for the throwaway server). Covers what Run via pytest (see conftest.py for the throwaway server). Covers what
HTTP tests can't: the login UI, the user chip, and real video playback HTTP tests can't: the login UI, the user chip, and real video playback
behind auth. Skipped if playwright is not installed. behind auth.
These tests scrape and play LIVE upstream content, so they fail whenever a
provider is mid-rotation or a title is taken down — that is not a code
regression. They therefore stay OFF by default (the default suite must be
deterministic); run them deliberately with ANIWEB_LIVE_TESTS=1.
""" """
import asyncio import asyncio
import http.cookiejar import http.cookiejar
import json import json
import os
import urllib.request import urllib.request
import pytest import pytest
if os.environ.get("ANIWEB_LIVE_TESTS") != "1":
pytest.skip("live-upstream e2e — run with ANIWEB_LIVE_TESTS=1", allow_module_level=True)
async_playwright = pytest.importorskip("playwright.async_api").async_playwright async_playwright = pytest.importorskip("playwright.async_api").async_playwright
BASE = "http://127.0.0.1:8399" BASE = "http://127.0.0.1:8399"
@@ -114,7 +123,7 @@ async def _history_page_and_delete():
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login", op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(), data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
headers={"Content-Type": "application/json"}, method="POST")) headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
for n in range(1, 6): # 5 entries: makes the home "history →" link render for n in range(1, 6): # 5 entries: makes the home "history →" link render
e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n, e = {"title": f"Hist {n}", "slug_id": f"hist-{n}", "season_id": n, "ep_id": n,
"lang": "eng", "ep": n, "pos": 5, "dur": 600} "lang": "eng", "ep": n, "pos": 5, "dur": 600}
@@ -212,7 +221,7 @@ async def _recent_searches():
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login", op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(), data=json.dumps({"username": "alice", "password": "alicepw12345"}).encode(),
headers={"Content-Type": "application/json"}, method="POST")) headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
op.open(urllib.request.Request(BASE + "/api/searches", op.open(urllib.request.Request(BASE + "/api/searches",
data=json.dumps({"q": "chainsaw"}).encode(), data=json.dumps({"q": "chainsaw"}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST")) headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
@@ -441,3 +450,71 @@ async def _autoplay_blocked():
timeout=120000, polling=250) timeout=120000, polling=250)
assert not errors, errors assert not errors, errors
await browser.close() await browser.close()
def test_stale_history_remap_board():
asyncio.run(_stale_history_remap_board())
async def _stale_history_remap_board():
# provider switch (anidb -> hianime) left rows whose anidb slug 200s on
# hianime as a DIFFERENT anime (jujutsu-kaisen-2552 = Burning Kabaddi).
# The history page must flag it for remap instead of linking to that show.
jar = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
op.open(urllib.request.Request(BASE + "/login",
data=json.dumps({"username": "erin", "password": "erinpw12345"}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
op.open(urllib.request.Request(BASE + "/api/history",
data=json.dumps({"title": "Jujutsu Kaisen", "slug_id": "jujutsu-kaisen-2552",
"season_id": 2552, "ep_id": 35996, "lang": "jpn", "ep": 1,
"pos": 42}).encode(),
headers={"Content-Type": "application/json", "X-Aniweb": "1"}, method="POST"))
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)))
await _login(page, "erin", "erinpw12345")
await page.goto(BASE + "/?history")
await page.wait_for_function(
"document.querySelector('a.card.remap')", timeout=60000, polling=POLL)
stale = page.locator("a.card.remap", has_text="Jujutsu Kaisen")
assert "not found" in await stale.inner_text()
# the card must not link to the wrong-slug detail page; find-it goes
# to the title's search instead
assert not await stale.get_attribute("href")
await stale.locator("button:has-text('find it')").click(force=True)
await page.wait_for_function(
"location.search.includes('q=')", timeout=15000, polling=POLL)
assert not errors, errors
await browser.close()
def test_fullscreen_toggle():
asyncio.run(_fullscreen_toggle())
async def _fullscreen_toggle():
# the player controls get a fullscreen button; in headless chromium the
# native API works, so exercise enter + exit.
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)))
await _login(page, "admin", "adminpw12345")
await page.goto(BASE + "/?anime=chainsaw-man-922")
btn = page.locator("button:has-text('full')")
await btn.wait_for(timeout=30000)
assert await page.evaluate(
"() => typeof document.querySelector('#player video').requestFullscreen === 'function'")
await btn.click(force=True)
await page.wait_for_function(
"() => !!document.fullscreenElement", timeout=15000, polling=POLL)
await btn.click(force=True)
await page.wait_for_function(
"() => !document.fullscreenElement", timeout=15000, polling=POLL)
assert not errors, errors
await browser.close()