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