#!/usr/bin/env python3 """HTTP handler and route wiring for ani-cli-web.""" import json import mimetypes import os import shutil import subprocess import traceback from base64 import b64decode from binascii import Error as BinasciiError from dataclasses import dataclass from functools import lru_cache from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler from html import escape as html_escape from pathlib import Path from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from app_support import ( ANI_CLI, APP_NAME, CHANGELOG_FILE, CLIENT_DISCONNECT_ERRORS, MAX_JSON_BODY_BYTES, MODE_CHOICES, VERSION, client_address_is_local, configured_remote_path_roots, debug_enabled, debug_log, load_project_text_file, normalize_config, path_is_within_roots, remote_access_allowed, remote_access_credentials_configured, remote_access_credentials_match, remote_access_session_matches, remote_access_session_value, sanitize_public_config, send_discord_webhook_event, validate_discord_webhook_url, ) @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 run_jellyfin_sync: object runtime_state: object save_runtime_config: object search_anime: object resolve_search_thumbnail: object test_discord_webhook_config: object thumbnail_file_path: object update_all_watchlist_statuses: object update_watchlist_auto_download: object update_watchlist_category: object update_watchlist_media_type: 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 def browse_filesystem(path_value="", mode="dir", allowed_roots=None): raw_mode = str(mode or "dir").strip().lower() browse_mode = raw_mode if raw_mode in {"dir", "file"} else "dir" raw_path = str(path_value or "").strip() if raw_path: base_path = Path(raw_path).expanduser() elif allowed_roots: base_path = allowed_roots[0] else: base_path = Path.home() try: resolved = base_path.resolve() except OSError as exc: raise ValueError(f"Could not access path: {exc}") from exc if not resolved.exists(): raise ValueError("Selected path does not exist.") if resolved.is_file(): resolved = resolved.parent if not resolved.is_dir(): raise ValueError("Selected path is not a directory.") if allowed_roots and not path_is_within_roots(resolved, allowed_roots): raise PermissionError("That path is outside the allowed remote browse roots.") entries = [] try: for child in resolved.iterdir(): try: is_dir = child.is_dir() except OSError: continue if allowed_roots and not path_is_within_roots(child, allowed_roots): continue if browse_mode == "dir" and not is_dir: continue entries.append( { "name": child.name, "path": str(child), "is_dir": is_dir, } ) except OSError as exc: raise ValueError(f"Could not list directory: {exc}") from exc entries.sort(key=lambda item: (not item["is_dir"], item["name"].lower(), item["name"])) parent_path = str(resolved.parent if resolved.parent != resolved else resolved) if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): parent_path = str(resolved) root_paths = [str(Path(root)) for root in (allowed_roots or [])] return { "path": str(resolved), "parent_path": parent_path, "home_path": str(allowed_roots[0] if allowed_roots else Path.home()), "root_paths": root_paths, "mode": browse_mode, "entries": entries, } @lru_cache(maxsize=1) def installed_ani_cli_version(): try: completed = subprocess.run( [ANI_CLI, "--version"], check=True, capture_output=True, text=True, timeout=6, ) except (OSError, subprocess.SubprocessError): return "Unavailable" version = str(completed.stdout or completed.stderr or "").strip() return version or "Unavailable" class Handler(BaseHTTPRequestHandler): server_version = "AniCliWeb/1.0" remote_session_cookie_name = "ani_cli_web_session" remote_session_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 _authorization_header(self): return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip() def _host_header(self): host = str(self.headers.get("X-Forwarded-Host") or self.headers.get("x-forwarded-host") or "").strip() if host: return host.split(",", 1)[0].strip() return str(self.headers.get("Host") or self.headers.get("host") or "").strip() def _origin(self): return str(self.headers.get("Origin") or self.headers.get("origin") or "").strip() def _content_type(self): return str(self.headers.get("Content-Type") or self.headers.get("content-type") or "").strip() def _request_is_secure(self): forwarded_proto = str(self.headers.get("X-Forwarded-Proto") or self.headers.get("x-forwarded-proto") or "").strip() if forwarded_proto: return forwarded_proto.split(",", 1)[0].strip().lower() == "https" forwarded = str(self.headers.get("Forwarded") or self.headers.get("forwarded") or "").strip() if forwarded: for part in forwarded.split(";"): key, _, value = part.partition("=") if key.strip().lower() == "proto": return value.strip().strip('"').lower() == "https" return False def _origin_matches_host(self): origin = Handler._origin(self) if not origin: return False parsed = urlparse(origin) if parsed.scheme not in {"http", "https"} or not parsed.netloc: return False return parsed.netloc.lower() == Handler._host_header(self).lower() def _basic_auth_credentials(self): header = Handler._authorization_header(self) if not header.startswith("Basic "): return ("", "") encoded = header[len("Basic ") :].strip() if not encoded: return ("", "") try: decoded = b64decode(encoded.encode("ascii"), validate=True).decode("utf-8") except (BinasciiError, UnicodeDecodeError, ValueError): return ("", "") username, separator, password = decoded.partition(":") if not separator: return ("", "") return (username.strip(), password.strip()) 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_value(self, name): 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(name) if not morsel: return "" return morsel.value.strip() def _session_cookie(self): return Handler._cookie_value(self, Handler.remote_session_cookie_name) def _remote_credentials_valid(self): session = Handler._session_cookie(self) if session and remote_access_session_matches(session): return True username, password = Handler._basic_auth_credentials(self) return remote_access_credentials_match(username, password) 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_auth_query(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, session_value): attributes = ( f"{Handler.remote_session_cookie_name}={session_value}; " f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}" ) if Handler._request_is_secure(self): attributes += "; Secure" return attributes def _clear_remote_auth_cookie(self): attributes = ( f"{Handler.remote_session_cookie_name}=; " "Path=/; HttpOnly; SameSite=Lax; Max-Age=0" ) if Handler._request_is_secure(self): attributes += "; Secure" return attributes def _set_remote_auth_cookie_and_redirect(self, session_value, location): Handler.write_response_bytes( self, b"", HTTPStatus.SEE_OTHER, { "Location": Handler._normalized_next_path(self, location), "Set-Cookie": Handler._remote_auth_cookie(self, session_value), }, ) 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_auth_query(self, parsed) error_html = "" if message: error_html = f'

{html_escape(message)}

' html = f""" {APP_NAME} sign in

{APP_NAME}

Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.

{error_html}

API clients can also use Authorization: Basic <base64(username:password)>.

""" 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_credentials_configured(): raise PermissionError( "Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients." ) if not Handler._remote_credentials_valid(self): raise PermissionError("Remote username or password missing or invalid.") def enforce_same_origin(self): origin = Handler._origin(self) if not origin: if Handler._session_cookie(self): raise PermissionError("Browser API requests must include a same-origin Origin header.") return if not Handler._origin_matches_host(self): raise PermissionError("Cross-origin browser requests are not allowed.") def _remote_path_roots(self): return configured_remote_path_roots(Handler._context(self).get_config_snapshot()) def validate_remote_path(self, path_value, label): client_host = Handler._effective_client_host(self) if client_address_is_local(client_host): return roots = Handler._remote_path_roots(self) if not roots: raise PermissionError(f"Remote {label} changes are disabled until a server-side path root is configured.") if not path_is_within_roots(path_value, roots): raise PermissionError(f"Remote {label} must stay inside the configured remote path roots.") def do_GET(self): parsed = urlparse(self.path) try: if parsed.path == "/auth/login": if Handler._session_cookie(self) and remote_access_session_matches(Handler._session_cookie(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._session_cookie(self), next_path) return if remote_access_allowed() and remote_access_credentials_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(sanitize_public_config(Handler._context(self).get_config_snapshot())) elif parsed.path == "/api/version": self.json({"name": APP_NAME, "version": VERSION, "ani_cli_version": installed_ani_cli_version()}) elif parsed.path == "/api/changelog": self.json({"content": load_project_text_file(CHANGELOG_FILE, default="Changelog unavailable.")}) elif parsed.path == "/api/dependencies": self.json(dependency_status()) elif parsed.path == "/api/fs/browse": params = parse_qs(parsed.query) client_host = Handler._effective_client_host(self) allowed_roots = None if client_address_is_local(client_host) else Handler._remote_path_roots(self) self.json( browse_filesystem( (params.get("path") or [""])[0], (params.get("mode") or ["dir"])[0], allowed_roots=allowed_roots, ) ) 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 == "/api/search/thumb": Handler._runtime(self) params = parse_qs(parsed.query) title = (params.get("title") or [""])[0].strip() if not title: raise ValueError("Title is required") path = Handler._context(self).resolve_search_thumbnail(title) self.file(path) 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_credentials_configured(): raise PermissionError("Remote access sign-in is not available.") payload = Handler.body_form(self) username = str(payload.get("username") or "").strip() password = str(payload.get("password") or "").strip() next_path = payload.get("next") or "/" if not remote_access_credentials_match(username, password): Handler._render_remote_login_page( self, urlparse(Handler._normalized_next_path(self, next_path)), "Username or password missing or invalid.", ) return Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path) return self.ensure_client_access() Handler.enforce_same_origin(self) if parsed.path == "/api/config": Handler.update_config(self, Handler.require_json_object(self, self.body_json())) elif parsed.path == "/api/config/jellyfin/sync": payload = Handler.require_json_object(self, self.body_json()) current = Handler._context(self).get_config_snapshot() merged = dict(current) merged.update(payload) for key, label in ( ("download_dir", "download directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"), ("jellyfin_movie_dir", "Jellyfin movie directory"), ): if str(merged.get(key) or "").strip(): Handler.validate_remote_path(self, merged[key], label) self.json(Handler._context(self).run_jellyfin_sync(merged)) elif parsed.path == "/api/config/webhook/test": payload = Handler.require_json_object(self, self.body_json()) validate_discord_webhook_url(payload.get("discord_webhook_url")) self.json(Handler._context(self).test_discord_webhook_config(payload)) elif parsed.path == "/api/queue": runtime = Handler._runtime(self) payload = Handler.require_json_object(self, self.body_json()) if str(payload.get("download_dir") or "").strip(): Handler.validate_remote_path(self, payload["download_dir"], "download directory") self.json(runtime["download_queue"].add(payload), 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-media-type": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json(Handler._context(self).update_watchlist_media_type(payload["show_id"], payload.get("media_type"))) elif parsed.path == "/api/watchlist/update-auto-download": Handler._runtime(self) payload = Handler.require_fields(self, self.body_json(), "show_id") self.json( Handler._context(self).update_watchlist_auto_download( payload["show_id"], payload.get("mode"), payload.get("quality"), payload.get("name"), payload.get("series"), payload.get("episode_offset"), ) ) 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): payload = Handler.require_json_object(self, payload) current = Handler._context(self).get_config_snapshot() merged = dict(current) merged.update(payload) config = normalize_config(merged) for key, label in ( ("download_dir", "download directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"), ("jellyfin_movie_dir", "Jellyfin movie directory"), ): if str(config.get(key) or "").strip(): Handler.validate_remote_path(self, config[key], label) if "discord_webhook_url" in payload and str(config.get("discord_webhook_url") or "").strip(): validate_discord_webhook_url(config["discord_webhook_url"]) Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True) context = Handler._context(self) context.save_runtime_config(payload) self.json(sanitize_public_config(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): content_type = Handler._content_type(self).split(";", 1)[0].strip().lower() if content_type != "application/json": raise ValueError("Request body must use Content-Type: application/json.") 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_credentials_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