"""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 os import time from collections import Counter, OrderedDict from datetime import datetime from pathlib import Path from base64 import urlsafe_b64encode, urlsafe_b64decode from urllib.parse import quote, urljoin, urlparse from fastapi import Body, FastAPI, HTTPException, Query, Request from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from itsdangerous import BadSignature, TimestampSigner from . import db, upstream, users ROOT = Path(__file__).parent.parent app = FastAPI(title="ani-web") # ---------- auth: multi-user, signed cookies ---------- users.migrate_legacy() # file-based legacy import; db.init() absorbs its output db.init() _SIGNER = TimestampSigner(users.get_secret()) _SESSION = "aniweb_session" _SESSION_AGE = 30 * 86400 _PUBLIC = {("/login", "GET"), ("/login", "POST")} _SECURE = os.environ.get("ANIWEB_SECURE") == "1" # set when served over HTTPS # ponytail: in-memory lockout per username, single process — 5 fails -> 15 min _lockout: dict = {} def _session_user(request) -> str | None: try: payload = _SIGNER.unsign(request.cookies.get(_SESSION, ""), max_age=_SESSION_AGE) except BadSignature: return None return payload.decode().split(":", 1)[1] if payload.startswith(b"user:") else None def _lock(user: str, pw: str) -> str: lo = _lockout.setdefault(user, {"fails": 0, "until": 0.0}) 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") async def csrf_header(request, call_next): # ponytail: a cross-site form POST (top-level navigation) does carry the # SameSite=Lax cookie, so