- server/credentials.py: PBKDF2 password + session secret, 0600 json, one-time setup command (server refuses to start without it) - app.py: signed 30-day cookie (itsdangerous), middleware 401s API / redirects pages to /login?next=, 5-fail/15-min lockout, /logout - static/login.html: dark-themed form, inline error display - app.js: api() 401 -> redirect to /login preserving current URL - docs: README quick start + features, SPEC auth section
188 lines
6.0 KiB
Python
188 lines
6.0 KiB
Python
"""app.py — ani-web: FastAPI over upstream.py + HLS proxy + static UI.
|
|
|
|
Browser playback: hls.anidb.app sends no CORS headers, so the player is
|
|
pointed at /stream/{token}; the proxy fetches upstream (with the same
|
|
Cloudflare workaround) and rewrites playlist URIs to go through itself.
|
|
"""
|
|
import base64
|
|
import json
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import quote, urljoin
|
|
|
|
from fastapi import Body, FastAPI, HTTPException, Query
|
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
|
|
from fastapi.staticfiles import StaticFiles
|
|
from itsdangerous import BadSignature, TimestampSigner
|
|
|
|
from . import credentials, upstream
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
app = FastAPI(title="ani-web")
|
|
|
|
# ---------- auth: single user, signed cookie ----------
|
|
_SIGNER = TimestampSigner(credentials.load()["secret"]) # exits with instructions if unset
|
|
_SESSION = "aniweb_session"
|
|
_SESSION_AGE = 30 * 86400
|
|
_PUBLIC = {("/login", "GET"), ("/login", "POST")}
|
|
|
|
# ponytail: in-memory lockout, single process — 5 fails lock for 15 min
|
|
_lockout = {"fails": 0, "until": 0.0}
|
|
|
|
|
|
def _auth_ok(request) -> bool:
|
|
try:
|
|
return _SIGNER.unsign(request.cookies.get(_SESSION, ""),
|
|
max_age=_SESSION_AGE) == b"ok"
|
|
except BadSignature:
|
|
return False
|
|
|
|
|
|
@app.middleware("http")
|
|
async def require_auth(request, call_next):
|
|
if (request.url.path, request.method) in _PUBLIC or _auth_ok(request):
|
|
return await call_next(request)
|
|
if request.url.path.startswith("/api/"):
|
|
return JSONResponse({"detail": "not authenticated"}, status_code=401)
|
|
next_ = request.url.path + (f"?{request.url.query}" if request.url.query else "")
|
|
return RedirectResponse(f"/login?next={quote(next_, safe='')}", status_code=302)
|
|
|
|
|
|
@app.get("/login", include_in_schema=False)
|
|
def login_page():
|
|
return FileResponse(ROOT / "static" / "login.html")
|
|
|
|
|
|
@app.post("/login")
|
|
async def login(payload: dict = Body(...)):
|
|
if time.time() < _lockout["until"]:
|
|
return JSONResponse({"detail": "too many attempts, try later"}, status_code=429)
|
|
if credentials.check(payload.get("password", "")):
|
|
_lockout.update(fails=0, until=0.0)
|
|
resp = JSONResponse({"ok": True})
|
|
resp.set_cookie(_SESSION, _SIGNER.sign(b"ok").decode(),
|
|
max_age=_SESSION_AGE, httponly=True, samesite="lax")
|
|
return resp
|
|
_lockout["fails"] += 1
|
|
if _lockout["fails"] >= 5:
|
|
_lockout.update(fails=0, until=time.time() + 900)
|
|
time.sleep(0.5) # slow down brute force
|
|
return JSONResponse({"detail": "wrong password"}, status_code=401)
|
|
|
|
|
|
@app.post("/logout")
|
|
def logout():
|
|
resp = JSONResponse({"ok": True})
|
|
resp.delete_cookie(_SESSION)
|
|
return resp
|
|
|
|
|
|
HIST_FILE = Path(__file__).parent / "data" / "history.json"
|
|
|
|
|
|
def _load_hist():
|
|
try:
|
|
return json.loads(HIST_FILE.read_text())
|
|
except (FileNotFoundError, json.JSONDecodeError):
|
|
return []
|
|
|
|
|
|
def _save_hist(entries):
|
|
HIST_FILE.parent.mkdir(exist_ok=True)
|
|
# ponytail: unguarded read-modify-write, fine for a single-user tool
|
|
HIST_FILE.write_text(json.dumps(entries, indent=1))
|
|
|
|
|
|
@app.get("/api/history")
|
|
def api_history():
|
|
return sorted(_load_hist(), key=lambda e: e.get("t", 0), reverse=True)[:5]
|
|
|
|
|
|
@app.post("/api/history")
|
|
def api_history_post(entry: dict = Body(...)):
|
|
if "ep_id" not in entry:
|
|
raise HTTPException(422, "ep_id required")
|
|
entry["t"] = time.time()
|
|
entries = _load_hist()
|
|
for i, e in enumerate(entries):
|
|
if e.get("slug_id") == entry.get("slug_id") and e.get("ep_id") == entry.get("ep_id"):
|
|
entries[i] = {**e, **entry}
|
|
break
|
|
else:
|
|
entries.append(entry)
|
|
_save_hist(sorted(entries, key=lambda e: e.get("t", 0), reverse=True)[:50])
|
|
return entry
|
|
|
|
|
|
def _b64(url: str) -> str:
|
|
return base64.urlsafe_b64encode(url.encode()).decode()
|
|
|
|
|
|
def _url(token: str) -> str:
|
|
return base64.urlsafe_b64decode(token.encode()).decode()
|
|
|
|
|
|
def _upstream(fn, *a, **kw):
|
|
try:
|
|
return fn(*a, **kw)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
raise HTTPException(502, f"upstream error: {e}")
|
|
|
|
|
|
@app.middleware("http")
|
|
async def no_cache(request, call_next):
|
|
# ponytail: no-store on everything except /stream (segments should cache)
|
|
r = await call_next(request)
|
|
if not request.url.path.startswith("/stream"):
|
|
r.headers["Cache-Control"] = "no-store"
|
|
return r
|
|
|
|
|
|
@app.get("/api/search")
|
|
def api_search(q: str = Query(min_length=1)):
|
|
return _upstream(upstream.search, q)
|
|
|
|
|
|
@app.get("/api/anime/{slug_id}")
|
|
def api_anime(slug_id: str):
|
|
return _upstream(upstream.details, slug_id)
|
|
|
|
|
|
@app.get("/api/anime/{anime_id}/episodes")
|
|
def api_episodes(anime_id: int):
|
|
return _upstream(upstream.episodes, anime_id)
|
|
|
|
|
|
@app.get("/api/episode/{ep_id}/stream")
|
|
def api_stream(ep_id: int, lang: str = "eng"):
|
|
master = _upstream(upstream.master_m3u8, ep_id, lang)
|
|
if not master:
|
|
raise HTTPException(404, f"no {lang} stream for episode {ep_id}")
|
|
return {"token": _b64(master),
|
|
"qualities": _upstream(upstream.master_variants, master)}
|
|
|
|
|
|
def _rewrite_m3u8(text: str, base: str) -> str:
|
|
# ponytail: rewrites plain URIs only; URI-list attributes in
|
|
# #EXT-X-* tags (e.g. SUBTITLES) are ignored — fine for video-only playlists
|
|
return "\n".join(
|
|
line if line.startswith("#") or not line
|
|
else "/stream/" + _b64(urljoin(base, line))
|
|
for line in text.splitlines()) + "\n"
|
|
|
|
|
|
@app.get("/stream/{token}")
|
|
def stream(token: str):
|
|
# ponytail: buffers whole response in memory; .ts segments are small
|
|
url = _url(token)
|
|
r = _upstream(upstream._get, url)
|
|
body = r.content
|
|
if url.endswith(".m3u8") or "mpegurl" in r.headers.get("content-type", ""):
|
|
body = _rewrite_m3u8(body.decode(), url).encode()
|
|
return Response(body, media_type=r.headers.get("content-type", "application/octet-stream"))
|
|
|
|
|
|
app.mount("/", StaticFiles(directory=ROOT / "static", html=True), name="static")
|