Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cea04a20f6 | ||
|
|
379f591180 | ||
|
|
0f6bd0a52a | ||
|
|
4aca78cab8 | ||
|
|
cbfb2f96c8 | ||
|
|
d02cb017d3 | ||
|
|
882263d276 | ||
|
|
3e5c7062ab | ||
|
|
c5c4dff851 | ||
|
|
7f531fc309 |
No files matched your search
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -106,11 +107,20 @@ stall playback.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
### 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.
|
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||||
Reference in new issue
Block a user