Files
ani-cli-web/http_handler.py
T
2026-05-24 11:47:38 +02:00

716 lines
29 KiB
Python

#!/usr/bin/env python3
"""HTTP handler and route wiring for ani-cli-web."""
import json
import mimetypes
import os
import shutil
import traceback
from dataclasses import dataclass
from http.cookies import SimpleCookie
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse
from app_support import (
ANI_CLI,
APP_NAME,
CLIENT_DISCONNECT_ERRORS,
MAX_JSON_BODY_BYTES,
MODE_CHOICES,
VERSION,
client_address_is_local,
debug_enabled,
debug_log,
normalize_config,
remote_access_allowed,
remote_access_token_configured,
remote_access_token_matches,
send_discord_webhook_event,
)
@dataclass(frozen=True)
class HandlerContext:
add_to_watchlist: object
config_html: str
download_watchlist_item: object
ensure_runtime: object
episode_list: object
get_config_snapshot: object
get_watchlist: object
get_watchlist_refresh_status: object
http_error: object
index_html: str
queue_html: str
remove_from_watchlist: object
runtime_state: object
save_runtime_config: object
search_anime: object
test_discord_webhook_config: object
thumbnail_file_path: object
update_all_watchlist_statuses: object
update_watchlist_category: object
update_watchlist_status: object
upload_watchlist_thumbnail: object
watchlist_html: str
def build_handler_class(context):
class ConfiguredHandler(Handler):
handler_context = context
return ConfiguredHandler
def dependency_status():
checks = ["curl", "sed", "grep", "openssl", "fzf", "aria2c", "ffmpeg", "yt-dlp"]
result = {name: bool(shutil.which(name)) for name in checks}
result["ani-cli"] = bool(shutil.which(ANI_CLI) or (Path(ANI_CLI).exists() and os.access(ANI_CLI, os.X_OK)))
return result
class Handler(BaseHTTPRequestHandler):
server_version = "AniCliWeb/1.0"
remote_token_cookie_name = "ani_cli_web_token"
remote_token_cookie_max_age_seconds = 30 * 24 * 60 * 60
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
def _context(self):
context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None)
if context is None:
raise RuntimeError("Handler context is not configured.")
return context
def _runtime(self):
context = Handler._context(self)
context.ensure_runtime(start_workers=True)
return context.runtime_state()
def _effective_client_host(self):
peer_host = self.client_address[0] if self.client_address else ""
if not client_address_is_local(peer_host):
return peer_host
forwarded = (
self.headers.get("X-Forwarded-For")
or self.headers.get("x-forwarded-for")
or self.headers.get("X-Real-IP")
or self.headers.get("x-real-ip")
or ""
)
if forwarded:
first = str(forwarded).split(",", 1)[0].strip().strip("[]")
if first:
return first
forwarded_header = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip()
if forwarded_header:
for part in forwarded_header.split(";"):
key, _, value = part.partition("=")
if key.strip().lower() != "for":
continue
candidate = value.strip().strip('"')
if candidate.startswith("[") and "]" in candidate:
candidate = candidate[1 : candidate.index("]")]
elif ":" in candidate and candidate.count(":") == 1:
candidate = candidate.split(":", 1)[0]
candidate = candidate.strip()
if candidate:
return candidate
return peer_host
def _remote_token(self):
return (
self.headers.get("X-AniCli-Web-Token")
or self.headers.get("x-ani-cli-web-token")
or Handler._bearer_token(self)
or Handler._cookie_token(self)
or Handler._query_token(self)
or ""
)
def _bearer_token(self):
header = str(self.headers.get("Authorization") or "").strip()
if not header.startswith("Bearer "):
return ""
return header[len("Bearer ") :].strip()
def _cookie_token(self):
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
if not raw:
return ""
try:
cookie = SimpleCookie()
cookie.load(raw)
except Exception:
return ""
morsel = cookie.get(Handler.remote_token_cookie_name)
if not morsel:
return ""
return morsel.value.strip()
def _query_token(self):
parsed = urlparse(self.path)
params = parse_qs(parsed.query, keep_blank_values=True)
for key in ("token", "remote_token"):
values = params.get(key) or []
for value in values:
text = str(value).strip()
if text:
return text
return ""
def _normalized_next_path(self, value):
raw = str(value or "").strip()
if not raw:
return "/"
parsed = urlparse(raw)
if parsed.scheme or parsed.netloc:
return "/"
path = parsed.path or "/"
if not path.startswith("/"):
return "/"
if path == "/auth/login":
path = "/"
query = parsed.query or ""
return urlunparse(("", "", path, "", query, ""))
def _current_path_without_tokens(self, parsed):
params = parse_qs(parsed.query, keep_blank_values=True)
params.pop("token", None)
params.pop("remote_token", None)
query = urlencode(params, doseq=True)
return urlunparse(("", "", parsed.path or "/", "", query, ""))
def _remote_auth_cookie(self, token):
return (
f"{Handler.remote_token_cookie_name}={token}; "
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}"
)
def _set_remote_auth_cookie_and_redirect(self, token, location):
Handler.write_response_bytes(
self,
b"",
HTTPStatus.SEE_OTHER,
{
"Location": Handler._normalized_next_path(self, location),
"Set-Cookie": Handler._remote_auth_cookie(self, token),
},
)
def _remote_cookie_bootstrap_response(self, parsed):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return False
if not remote_access_allowed() or not remote_access_token_configured():
return False
if Handler._cookie_token(self):
return False
token = Handler._query_token(self)
if not token or not remote_access_token_matches(token):
return False
location = Handler._current_path_without_tokens(self, parsed)
Handler._set_remote_auth_cookie_and_redirect(self, token, location)
return True
def _is_browser_page_request(self, parsed):
if getattr(self, "command", "") != "GET":
return False
return not str(parsed.path or "").startswith("/api/")
def _render_remote_login_page(self, parsed, message=""):
next_path = Handler._current_path_without_tokens(self, parsed)
error_html = ""
if message:
error_html = f'<p class="error">{message}</p>'
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{APP_NAME} sign in</title>
<style>
:root {{
color-scheme: dark;
--bg: #0b0813;
--panel: rgba(25, 18, 41, 0.94);
--line: rgba(255, 255, 255, 0.08);
--text: #f6f2ff;
--muted: #b8b0cf;
--accent: #9d7bff;
--error: #ffb4c2;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(157, 123, 255, 0.18), transparent 28%),
linear-gradient(180deg, #120c1d 0%, #0b0813 56%, #07050d 100%);
color: var(--text);
font: 15px/1.5 "Segoe UI", Inter, system-ui, sans-serif;
}}
.panel {{
width: min(100%, 420px);
display: grid;
gap: 14px;
padding: 22px;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
}}
h1, p {{ margin: 0; }}
h1 {{ font-size: 28px; line-height: 1.05; }}
.muted {{ color: var(--muted); }}
.error {{
color: var(--error);
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 144, 164, 0.12);
border: 1px solid rgba(255, 144, 164, 0.18);
}}
form {{
display: grid;
gap: 12px;
}}
label {{
display: grid;
gap: 8px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}}
input {{
width: 100%;
min-height: 44px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(10, 7, 18, 0.82);
color: var(--text);
padding: 0 13px;
}}
button {{
min-height: 42px;
border: 0;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), #7b5cff);
color: #fcfaff;
font: inherit;
font-weight: 700;
cursor: pointer;
}}
code {{
font-size: 13px;
color: var(--text);
}}
</style>
</head>
<body>
<section class="panel">
<div>
<h1>{APP_NAME}</h1>
<p class="muted">Enter the remote access token for this browser. After that, the app keeps a persistent auth cookie and future visits stay signed in.</p>
</div>
{error_html}
<form method="post" action="/auth/login">
<input type="hidden" name="next" value="{next_path}">
<label>Remote access token
<input name="token" type="password" autocomplete="current-password" autofocus required>
</label>
<button type="submit">Sign in</button>
</form>
<p class="muted">API clients can still use <code>Authorization: Bearer &lt;token&gt;</code> or <code>?token=...</code>.</p>
</section>
</body>
</html>
"""
Handler.write_response_bytes(
self,
html.encode("utf-8"),
HTTPStatus.UNAUTHORIZED,
{"Content-Type": "text/html; charset=utf-8"},
)
return True
def ensure_client_access(self):
client_host = Handler._effective_client_host(self)
if client_address_is_local(client_host):
return
if not remote_access_allowed():
raise PermissionError(
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
)
if not remote_access_token_configured():
raise PermissionError(
"Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients."
)
if not remote_access_token_matches(Handler._remote_token(self)):
raise PermissionError("Remote access token missing or invalid.")
def do_GET(self):
parsed = urlparse(self.path)
try:
if Handler._remote_cookie_bootstrap_response(self, parsed):
return
if parsed.path == "/auth/login":
if Handler._cookie_token(self) and remote_access_token_matches(Handler._cookie_token(self)):
params = parse_qs(parsed.query, keep_blank_values=True)
next_path = (params.get("next") or ["/"])[0]
Handler._set_remote_auth_cookie_and_redirect(self, Handler._cookie_token(self), next_path)
return
if remote_access_allowed() and remote_access_token_configured():
Handler._render_remote_login_page(self, parsed)
return
self.ensure_client_access()
if parsed.path == "/":
self.html(Handler._context(self).index_html)
elif parsed.path == "/queue":
self.html(Handler._context(self).queue_html)
elif parsed.path == "/config":
self.html(Handler._context(self).config_html)
elif parsed.path == "/watchlist":
self.html(Handler._context(self).watchlist_html)
elif parsed.path == "/api/config":
self.json(Handler._context(self).get_config_snapshot())
elif parsed.path == "/api/version":
self.json({"name": APP_NAME, "version": VERSION})
elif parsed.path == "/api/dependencies":
self.json(dependency_status())
elif parsed.path == "/api/search":
runtime = Handler._runtime(self)
config = runtime["config"]
params = parse_qs(parsed.query)
query = (params.get("q") or [""])[0].strip()
mode = (params.get("mode") or [config["mode"]])[0].lower()
if not query:
raise ValueError("Search query is required")
if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
self.json({"results": Handler._context(self).search_anime(query, mode)})
elif parsed.path.startswith("/api/anime/") and parsed.path.endswith("/episodes"):
runtime = Handler._runtime(self)
config = runtime["config"]
show_id = unquote(parsed.path[len("/api/anime/") : -len("/episodes")])
params = parse_qs(parsed.query)
mode = (params.get("mode") or [config["mode"]])[0].lower()
if mode not in MODE_CHOICES:
raise ValueError("Mode must be sub or dub")
self.json({"episodes": Handler._context(self).episode_list(show_id, mode)})
elif parsed.path == "/api/queue":
runtime = Handler._runtime(self)
params = parse_qs(parsed.query)
page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["10"])[0]
self.json(runtime["download_queue"].list(page=page, per_page=per_page))
elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/")
if len(parts) != 3:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
_, _, job_id = parts
self.json(runtime["download_queue"].get(job_id))
elif parsed.path.startswith("/api/watchlist/thumb/"):
runtime = Handler._runtime(self)
show_id = unquote(parsed.path[len("/api/watchlist/thumb/") :]).strip()
debug_log("thumbnail.route.request", show_id=show_id, path=parsed.path, query=parsed.query)
item = runtime["watchlist"].get(show_id)
path = Handler._context(self).thumbnail_file_path(item.get("thumbnail_path"))
if not path or not path.exists():
debug_log("thumbnail.route.miss", show_id=show_id, resolved_path=path)
self.error(HTTPStatus.NOT_FOUND, "Thumbnail not found")
return
debug_log("thumbnail.route.hit", show_id=show_id, resolved_path=path)
self.file(path)
elif parsed.path in {"/api/watchlist", "/api/watchlist/get"}:
Handler._runtime(self)
params = parse_qs(parsed.query)
page = (params.get("page") or ["1"])[0]
per_page = (params.get("per_page") or ["30"])[0]
category = (params.get("category") or [""])[0].strip().lower() or None
self.json(Handler._context(self).get_watchlist(page=page, per_page=per_page, category=category))
elif parsed.path == "/api/watchlist/refresh-status":
Handler._runtime(self)
self.json(Handler._context(self).get_watchlist_refresh_status())
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
self.exception(exc)
def do_POST(self):
parsed = urlparse(self.path)
try:
if parsed.path == "/auth/login":
if not remote_access_allowed() or not remote_access_token_configured():
raise PermissionError("Remote access sign-in is not available.")
payload = Handler.body_form(self)
token = str(payload.get("token") or "").strip()
next_path = payload.get("next") or "/"
if not remote_access_token_matches(token):
Handler._render_remote_login_page(
self,
urlparse(Handler._normalized_next_path(self, next_path)),
"Remote access token missing or invalid.",
)
return
Handler._set_remote_auth_cookie_and_redirect(self, token, next_path)
return
self.ensure_client_access()
if parsed.path == "/api/config":
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
elif parsed.path == "/api/config/webhook/test":
payload = Handler.require_json_object(self, self.body_json())
self.json(Handler._context(self).test_discord_webhook_config(payload))
elif parsed.path == "/api/queue":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].add(self.body_json()), HTTPStatus.CREATED)
elif parsed.path == "/api/queue/retry-failed":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].retry_all_failed())
elif parsed.path == "/api/queue/clear-finished":
runtime = Handler._runtime(self)
self.json(runtime["download_queue"].clear_finished())
elif parsed.path.startswith("/api/queue/"):
runtime = Handler._runtime(self)
parts = parsed.path.strip("/").split("/")
if len(parts) != 4:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
_, _, job_id, action = parts
actions = {
"retry": runtime["download_queue"].retry,
"cancel": runtime["download_queue"].cancel,
"remove": runtime["download_queue"].remove,
}
if action not in actions:
self.error(HTTPStatus.NOT_FOUND, "Not found")
return
self.json(actions[action](job_id))
elif parsed.path in {"/api/watchlist", "/api/watchlist/add"}:
Handler._runtime(self)
result = Handler._context(self).add_to_watchlist(Handler.require_json_object(self, self.body_json()))
status = HTTPStatus.CREATED if result.get("created") else HTTPStatus.OK
self.json(result, status)
elif parsed.path == "/api/watchlist/ensure-thumbnails":
runtime = Handler._runtime(self)
payload = Handler.require_json_object(self, self.body_json())
self.json(
runtime["watchlist"].ensure_thumbnails(
Handler.optional_list_field(self, payload, "show_ids") or [],
limit=payload.get("limit", 6),
force=Handler.optional_bool_field(self, payload, "force", default=False),
)
)
elif parsed.path == "/api/watchlist/update-status":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_status(payload["show_id"], payload.get("mode")))
elif parsed.path == "/api/watchlist/download-all":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id", "mode")
self.json(Handler._context(self).download_watchlist_item(payload["show_id"], payload["mode"]), HTTPStatus.CREATED)
elif parsed.path == "/api/watchlist/update-category":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).update_watchlist_category(payload["show_id"], payload.get("category")))
elif parsed.path == "/api/watchlist/update-all":
Handler._runtime(self)
result = Handler._context(self).update_all_watchlist_statuses()
status = HTTPStatus.ACCEPTED if result.get("created") else HTTPStatus.OK
self.json(result, status)
elif parsed.path == "/api/watchlist/remove":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id")
self.json(Handler._context(self).remove_from_watchlist(payload["show_id"]))
elif parsed.path == "/api/watchlist/upload-thumbnail":
Handler._runtime(self)
payload = Handler.require_fields(self, self.body_json(), "show_id", "data_url")
self.json(Handler._context(self).upload_watchlist_thumbnail(payload))
else:
self.error(HTTPStatus.NOT_FOUND, "Not found")
except Exception as exc:
self.exception(exc)
def update_config(self, payload):
config = normalize_config(payload)
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
context = Handler._context(self)
context.save_runtime_config(config)
self.json(context.get_config_snapshot())
def require_fields(self, payload, *names):
payload = Handler.require_json_object(self, payload)
missing = [name for name in names if not str(payload.get(name) or "").strip()]
if missing:
if len(missing) == 1:
raise ValueError(f"Missing required field: {missing[0]}")
raise ValueError(f"Missing required fields: {', '.join(missing)}")
return payload
def require_json_object(self, payload):
if not isinstance(payload, dict):
raise ValueError("Expected a JSON object")
return payload
def optional_list_field(self, payload, name):
payload = Handler.require_json_object(self, payload)
if name not in payload or payload.get(name) is None:
return None
value = payload.get(name)
if not isinstance(value, list):
raise ValueError(f"Field '{name}' must be a JSON array")
return value
def optional_bool_field(self, payload, name, default=False):
payload = Handler.require_json_object(self, payload)
if name not in payload or payload.get(name) is None:
return default
value = payload.get(name)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ValueError(f"Field '{name}' must be a boolean")
def body_json(self):
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc:
raise ValueError("Invalid Content-Length header") from exc
if length < 0:
raise ValueError("Invalid Content-Length header")
if length > MAX_JSON_BODY_BYTES:
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
raw_bytes = self.rfile.read(length) if length else b"{}"
if len(raw_bytes) > MAX_JSON_BODY_BYTES:
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("Request body must be valid UTF-8 JSON.") from exc
try:
return json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON body") from exc
def body_form(self):
try:
length = int(self.headers.get("Content-Length", "0") or "0")
except ValueError as exc:
raise ValueError("Invalid Content-Length header") from exc
if length < 0:
raise ValueError("Invalid Content-Length header")
if length > MAX_JSON_BODY_BYTES:
raise Handler._context(self).http_error(HTTPStatus.REQUEST_ENTITY_TOO_LARGE, "Request body too large.")
raw_bytes = self.rfile.read(length) if length else b""
try:
raw = raw_bytes.decode("utf-8")
except UnicodeDecodeError as exc:
raise ValueError("Request body must be valid UTF-8 form data.") from exc
params = parse_qs(raw, keep_blank_values=True)
return {key: values[-1] if values else "" for key, values in params.items()}
def html(self, content):
data = content.encode("utf-8")
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=utf-8"})
def file(self, path):
payload = Path(path).read_bytes()
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
debug_log("file.serve", path=path, content_type=content_type, bytes=len(payload))
self.write_response_bytes(
payload,
HTTPStatus.OK,
{
"Content-Type": content_type,
"Cache-Control": "public, max-age=86400",
},
)
def json(self, payload, status=HTTPStatus.OK):
data = json.dumps(payload).encode("utf-8")
self.write_response_bytes(data, status, {"Content-Type": "application/json"})
def write_response_bytes(self, payload, status, headers):
try:
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
except CLIENT_DISCONNECT_ERRORS:
return
def error(self, status, message):
try:
self.json({"error": message}, status)
except CLIENT_DISCONNECT_ERRORS:
return
def exception(self, exc):
if isinstance(exc, CLIENT_DISCONNECT_ERRORS):
return
if isinstance(exc, Handler._context(self).http_error):
self.error(exc.status, exc.message)
elif isinstance(exc, KeyError):
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
elif isinstance(exc, PermissionError):
parsed = urlparse(getattr(self, "path", "") or "/")
if remote_access_allowed() and remote_access_token_configured() and Handler._is_browser_page_request(self, parsed):
Handler._render_remote_login_page(self, parsed, str(exc))
else:
self.error(HTTPStatus.FORBIDDEN, str(exc))
elif isinstance(exc, ValueError):
self.error(HTTPStatus.BAD_REQUEST, str(exc))
else:
if debug_enabled():
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
traceback.print_exc()
else:
debug_log("handler.exception", path=getattr(self, "path", ""), error=exc)
try:
send_discord_webhook_event(
Handler._context(self).get_config_snapshot(),
"runtime_error",
{
"source": "http_handler",
"error": str(exc),
"details": f"path={getattr(self, 'path', '')}",
},
)
except Exception as notify_exc:
debug_log("discord_webhook.runtime_error_failed", source="http_handler", error=notify_exc)
self.error(HTTPStatus.INTERNAL_SERVER_ERROR, "Internal server error.")
def log_message(self, fmt, *args):
parsed = urlparse(getattr(self, "path", "") or "")
if self.command == "GET" and parsed.path in Handler.quiet_poll_paths:
return
print(f"{self.address_string()} - {fmt % args}")
def server_host():
return os.environ.get("ANI_CLI_WEB_HOST", "127.0.0.1").strip() or "127.0.0.1"
def server_port():
raw = str(os.environ.get("ANI_CLI_WEB_PORT", "8421")).strip() or "8421"
try:
port = int(raw, 10)
except ValueError as exc:
raise ValueError("ANI_CLI_WEB_PORT must be a valid integer") from exc
if not 1 <= port <= 65535:
raise ValueError("ANI_CLI_WEB_PORT must be between 1 and 65535")
return port