From 1487257bed9b066853f9335d04f7847ad04d7d2c Mon Sep 17 00:00:00 2001 From: Dymas Date: Mon, 25 May 2026 16:30:01 +0200 Subject: [PATCH] Replace token auth with single-user login --- CHANGELOG.md | 5 ++ README.md | 32 ++++++---- VERSION | 2 +- app.py | 6 +- app_support.py | 66 +++++++++++++++++--- docker-compose.yaml | 3 +- http_handler.py | 145 +++++++++++++++++++++++--------------------- test_app.py | 114 ++++++++++++++++++++-------------- 8 files changed, 237 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1d99a..0b5902e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.41.0 - 2026-05-25 + +- Replaced the shared remote-access token flow with a classic single-user username/password login, backed by a persistent signed session cookie for browser sessions and HTTP Basic auth for API clients. +- Added `ANI_CLI_WEB_AUTH_USERNAME` and `ANI_CLI_WEB_AUTH_PASSWORD` support plus matching `config.json` fields, while keeping those private auth fields out of the public config API so the web UI does not leak or overwrite them. + ## 0.40.6 - 2026-05-25 - Fixed the Config page `Dependencies` panel so it stretches across the full settings width, centered its dependency badges, and added the installed `ani-cli` version to the runtime info block. diff --git a/README.md b/README.md index 6439a6d..d7e8e2a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Small local web UI for a system-wide `ani-cli` install. -Current version: `0.40.0` +Current version: `0.41.0` ## Project Layout @@ -276,23 +276,28 @@ To allow LAN or remote access: ```sh ANI_CLI_WEB_ALLOW_REMOTE=1 -ANI_CLI_WEB_REMOTE_TOKEN=choose-a-long-random-token +ANI_CLI_WEB_AUTH_USERNAME=admin +ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password ``` -Non-local clients must send one of: +Non-local browser visits are redirected to a sign-in form automatically when no valid session cookie is present. -- `Authorization: Bearer ` -- `X-AniCli-Web-Token: ` +API clients can authenticate with: -Browser bootstrap also works: +- `Authorization: Basic ` -```text -http://YOUR-HOST:8421/?token=your-token-here +The browser login stores a persistent HTTP-only session cookie, so you usually only need to sign in once per browser. + +You can also set the same credentials directly in `.ani-cli-web/config.json`: + +```json +{ + "auth_username": "admin", + "auth_password": "choose-a-long-random-password" +} ``` -For normal browser visits, the app now shows a sign-in page automatically when the token cookie is missing. - -The app stores the token in an HTTP-only cookie and redirects to the clean URL. The cookie is persistent, so you usually only need to sign in once per browser. +Environment variables take precedence over values saved in `config.json`. ## Download Queue @@ -343,7 +348,8 @@ Environment variables: - `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1` - `ANI_CLI_WEB_PORT`: server port, default `8421` - `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients -- `ANI_CLI_WEB_REMOTE_TOKEN`: shared secret for remote access +- `ANI_CLI_WEB_AUTH_USERNAME`: single remote-login username +- `ANI_CLI_WEB_AUTH_PASSWORD`: single remote-login password - `ANI_CLI_WEB_DEBUG`: enable debug logging - `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory - `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup @@ -351,6 +357,8 @@ Environment variables: Saved config fields include: +- `auth_username` +- `auth_password` - `discord_webhook_url` - `discord_webhook_events` - `watchlist_auto_refresh_enabled` diff --git a/VERSION b/VERSION index 78deb9e..72a8a63 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.40.6 +0.41.0 diff --git a/app.py b/app.py index 10f1273..c512368 100644 --- a/app.py +++ b/app.py @@ -2269,7 +2269,11 @@ def get_config_snapshot(fallback=None): def save_runtime_config(config): - normalized = normalize_config(config) + current = get_config_snapshot(fallback=DEFAULT_CONFIG) + merged = dict(current) + if isinstance(config, dict): + merged.update(config) + normalized = normalize_config(merged) scheduler = None with RUNTIME_LOCK: global CONFIG diff --git a/app_support.py b/app_support.py index 0d44e33..16b1cd9 100644 --- a/app_support.py +++ b/app_support.py @@ -2,6 +2,7 @@ """Shared constants and helper utilities for ani-cli-web.""" +import hashlib import hmac import ipaddress import json @@ -19,7 +20,7 @@ from uuid import uuid4 PROJECT_ROOT = Path(__file__).resolve().parent ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli" APP_NAME = "ani-cli-web" -VERSION = "0.40.6" +VERSION = "0.41.0" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" @@ -82,17 +83,56 @@ def remote_access_allowed(): return env_flag("ANI_CLI_WEB_ALLOW_REMOTE") -def remote_access_token(): - return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip() +def remote_access_username(config=None): + configured = str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip() + if configured: + return configured + if isinstance(config, dict): + return str(config.get("auth_username") or "").strip() + return "" -def remote_access_token_configured(): - return bool(remote_access_token()) +def remote_access_password(config=None): + configured = str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip() + if configured: + return configured + if isinstance(config, dict): + return str(config.get("auth_password") or "").strip() + return "" -def remote_access_token_matches(value): +def remote_access_credentials_configured(config=None): + return bool(remote_access_username(config) and remote_access_password(config)) + + +def remote_access_credentials_match(username, password, config=None): + expected_username = remote_access_username(config) + expected_password = remote_access_password(config) + provided_username = str(username or "").strip() + provided_password = str(password or "").strip() + return bool( + expected_username + and expected_password + and hmac.compare_digest(provided_username, expected_username) + and hmac.compare_digest(provided_password, expected_password) + ) + + +def remote_access_session_value(config=None): + username = remote_access_username(config) + password = remote_access_password(config) + if not username or not password: + return "" + return hmac.new( + f"{username}\0{password}".encode("utf-8"), + b"ani-cli-web-session", + hashlib.sha256, + ).hexdigest() + + +def remote_access_session_matches(value, config=None): provided = str(value or "").strip() - expected = remote_access_token() + expected = remote_access_session_value(config) return bool(expected and provided and hmac.compare_digest(provided, expected)) @@ -145,7 +185,10 @@ DEFAULT_CONFIG = { "auto_download_quality": "best", "discord_webhook_url": "", "discord_webhook_events": [], + "auth_username": str(os.environ.get("ANI_CLI_WEB_AUTH_USERNAME") or "").strip(), + "auth_password": str(os.environ.get("ANI_CLI_WEB_AUTH_PASSWORD") or "").strip(), } +PRIVATE_CONFIG_KEYS = {"auth_username", "auth_password"} def now_iso(): @@ -295,6 +338,8 @@ def normalize_config(data): auto_download_quality = auto_download_quality[:-1] webhook_url = str(config.get("discord_webhook_url") or "").strip() webhook_events = config.get("discord_webhook_events") + auth_username = str(config.get("auth_username") or "").strip() + auth_password = str(config.get("auth_password") or "").strip() if isinstance(auto_refresh_enabled, str): auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"} @@ -344,9 +389,16 @@ def normalize_config(data): normalized_events.append(normalized_name) config["discord_webhook_url"] = webhook_url config["discord_webhook_events"] = normalized_events + config["auth_username"] = auth_username + config["auth_password"] = auth_password return config +def sanitize_public_config(config): + source = normalize_config(config or {}) + return {key: value for key, value in source.items() if key not in PRIVATE_CONFIG_KEYS} + + def discord_webhook_enabled(config, event_name, force=False): if not isinstance(config, dict): return False diff --git a/docker-compose.yaml b/docker-compose.yaml index 6a1a4db..2497973 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,7 +19,8 @@ services: ANI_CLI_WEB_HOST: 0.0.0.0 ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421} ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false} - ANI_CLI_WEB_REMOTE_TOKEN: ${ANI_CLI_WEB_REMOTE_TOKEN:-} + ANI_CLI_WEB_AUTH_USERNAME: ${ANI_CLI_WEB_AUTH_USERNAME:-} + ANI_CLI_WEB_AUTH_PASSWORD: ${ANI_CLI_WEB_AUTH_PASSWORD:-} ANI_CLI_DOWNLOAD_DIR: /downloads volumes: - ./downloads:/downloads diff --git a/http_handler.py b/http_handler.py index 611156e..4b6c810 100644 --- a/http_handler.py +++ b/http_handler.py @@ -8,6 +8,8 @@ import os import shutil import subprocess import traceback +from base64 import b64decode +from binascii import Error as BinasciiError from functools import lru_cache from dataclasses import dataclass from http.cookies import SimpleCookie @@ -28,8 +30,11 @@ from app_support import ( debug_log, normalize_config, remote_access_allowed, - remote_access_token_configured, - remote_access_token_matches, + remote_access_credentials_configured, + remote_access_credentials_match, + remote_access_session_matches, + remote_access_session_value, + sanitize_public_config, send_discord_webhook_event, ) @@ -92,8 +97,8 @@ def installed_ani_cli_version(): 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 + 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): @@ -138,15 +143,24 @@ class Handler(BaseHTTPRequestHandler): 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 _authorization_header(self): + return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip() + + 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() @@ -154,7 +168,7 @@ class Handler(BaseHTTPRequestHandler): return "" return header[len("Bearer ") :].strip() - def _cookie_token(self): + def _cookie_value(self, name): raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip() if not raw: return "" @@ -163,21 +177,20 @@ class Handler(BaseHTTPRequestHandler): cookie.load(raw) except Exception: return "" - morsel = cookie.get(Handler.remote_token_cookie_name) + morsel = cookie.get(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 _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() @@ -194,52 +207,43 @@ class Handler(BaseHTTPRequestHandler): query = parsed.query or "" return urlunparse(("", "", path, "", query, "")) - def _current_path_without_tokens(self, parsed): + 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, token): + def _remote_auth_cookie(self, session_value): return ( - f"{Handler.remote_token_cookie_name}={token}; " - f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}" + f"{Handler.remote_session_cookie_name}={session_value}; " + f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_session_cookie_max_age_seconds}" ) - def _set_remote_auth_cookie_and_redirect(self, token, location): + def _clear_remote_auth_cookie(self): + return ( + f"{Handler.remote_session_cookie_name}=; " + "Path=/; HttpOnly; SameSite=Lax; Max-Age=0" + ) + + 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, token), + "Set-Cookie": Handler._remote_auth_cookie(self, session_value), }, ) - 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) + next_path = Handler._current_path_without_auth_query(self, parsed) error_html = "" if message: error_html = f'

{message}

' @@ -334,17 +338,20 @@ class Handler(BaseHTTPRequestHandler):

{APP_NAME}

-

Enter the remote access token for this browser. After that, the app keeps a persistent auth cookie and future visits stay signed in.

+

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

{error_html}
-
@@ -365,25 +372,23 @@ class Handler(BaseHTTPRequestHandler): raise PermissionError( "Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients." ) - if not remote_access_token_configured(): + if not remote_access_credentials_configured(): raise PermissionError( - "Remote access requires ANI_CLI_WEB_REMOTE_TOKEN for non-local clients." + "Remote access requires ANI_CLI_WEB_AUTH_USERNAME and ANI_CLI_WEB_AUTH_PASSWORD for non-local clients." ) - if not remote_access_token_matches(Handler._remote_token(self)): - raise PermissionError("Remote access token missing or invalid.") + if not Handler._remote_credentials_valid(self): + raise PermissionError("Remote username or password 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)): + 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._cookie_token(self), next_path) + Handler._set_remote_auth_cookie_and_redirect(self, Handler._session_cookie(self), next_path) return - if remote_access_allowed() and remote_access_token_configured(): + if remote_access_allowed() and remote_access_credentials_configured(): Handler._render_remote_login_page(self, parsed) return self.ensure_client_access() @@ -396,7 +401,7 @@ class Handler(BaseHTTPRequestHandler): elif parsed.path == "/watchlist": self.html(Handler._context(self).watchlist_html) elif parsed.path == "/api/config": - self.json(Handler._context(self).get_config_snapshot()) + 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/dependencies": @@ -466,19 +471,20 @@ class Handler(BaseHTTPRequestHandler): parsed = urlparse(self.path) try: if parsed.path == "/auth/login": - if not remote_access_allowed() or not remote_access_token_configured(): + 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) - token = str(payload.get("token") or "").strip() + username = str(payload.get("username") or "").strip() + password = str(payload.get("password") or "").strip() next_path = payload.get("next") or "/" - if not remote_access_token_matches(token): + if not remote_access_credentials_match(username, password): Handler._render_remote_login_page( self, urlparse(Handler._normalized_next_path(self, next_path)), - "Remote access token missing or invalid.", + "Username or password missing or invalid.", ) return - Handler._set_remote_auth_cookie_and_redirect(self, token, next_path) + Handler._set_remote_auth_cookie_and_redirect(self, remote_access_session_value(), next_path) return self.ensure_client_access() if parsed.path == "/api/config": @@ -569,11 +575,12 @@ class Handler(BaseHTTPRequestHandler): self.exception(exc) def update_config(self, payload): + payload = Handler.require_json_object(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()) + 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) @@ -698,7 +705,7 @@ class Handler(BaseHTTPRequestHandler): 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): + 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)) diff --git a/test_app.py b/test_app.py index d6a887e..091fd98 100644 --- a/test_app.py +++ b/test_app.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import base64 import importlib.util import io import os @@ -101,7 +102,7 @@ class NetworkGuardTests(unittest.TestCase): self.assertFalse(APP.client_address_is_local("192.168.1.25")) self.assertFalse(APP.client_address_is_local("10.0.0.8")) - def test_remote_access_requires_token_for_non_local_clients(self): + def test_remote_access_requires_credentials_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) @@ -109,68 +110,56 @@ class NetworkGuardTests(unittest.TestCase): with self.assertRaises(PermissionError) as ctx: APP.Handler.ensure_client_access(handler) - self.assertIn("REMOTE_TOKEN", str(ctx.exception)) + self.assertIn("AUTH_USERNAME", str(ctx.exception)) - def test_remote_access_accepts_valid_token_for_non_local_clients(self): + def test_remote_access_accepts_valid_basic_auth_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) - handler.headers["Authorization"] = "Bearer secret-token" + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler.headers["Authorization"] = f"Basic {encoded}" with mock.patch.dict( os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, clear=False, ): APP.Handler.ensure_client_access(handler) - def test_remote_access_accepts_valid_cookie_token_for_non_local_clients(self): + def test_remote_access_accepts_valid_session_cookie_for_non_local_clients(self): handler = DummyHandler("/api/config") handler.client_address = ("192.168.1.25", 8421) - handler.headers["Cookie"] = "ani_cli_web_token=secret-token" + session_value = http_handler.remote_access_session_value( + {"auth_username": "demo", "auth_password": "secret-pass"} + ) + handler.headers["Cookie"] = f"ani_cli_web_session={session_value}" with mock.patch.dict( os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, clear=False, ): APP.Handler.ensure_client_access(handler) - def test_remote_access_accepts_valid_query_token_for_non_local_clients(self): - handler = DummyHandler("/api/config?token=secret-token") - handler.client_address = ("192.168.1.25", 8421) - - with mock.patch.dict( - os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, - clear=False, - ): - APP.Handler.ensure_client_access(handler) - - def test_remote_get_bootstraps_cookie_and_redirects_when_query_token_valid(self): - handler = DummyHandler("/?token=secret-token") - handler.client_address = ("192.168.1.25", 8421) - - with mock.patch.dict( - os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, - clear=False, - ): - APP.Handler.do_GET(handler) - - headers = dict(handler.response_headers) - self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER) - self.assertEqual(headers.get("Location"), "/") - self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", "")) - self.assertIn("Max-Age=", headers.get("Set-Cookie", "")) - - def test_remote_browser_get_without_token_renders_login_page(self): + def test_remote_browser_get_without_session_renders_login_page(self): handler = DummyHandler("/") handler.client_address = ("192.168.1.25", 8421) handler.command = "GET" with mock.patch.dict( os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, clear=False, ): APP.Handler.do_GET(handler) @@ -178,16 +167,21 @@ class NetworkGuardTests(unittest.TestCase): body = handler.wfile.getvalue().decode("utf-8") self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED) self.assertIn('
', body) - self.assertIn("Remote access token", body) + self.assertIn("Username", body) + self.assertIn("Password", body) def test_remote_login_post_sets_cookie_and_redirects(self): - body = b"token=secret-token&next=%2Fwatchlist" + body = b"username=demo&password=secret-pass&next=%2Fwatchlist" handler = DummyHandler("/auth/login", body=body, content_length=len(body)) handler.client_address = ("192.168.1.25", 8421) with mock.patch.dict( os.environ, - {"ANI_CLI_WEB_ALLOW_REMOTE": "1", "ANI_CLI_WEB_REMOTE_TOKEN": "secret-token"}, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + }, clear=False, ): APP.Handler.do_POST(handler) @@ -195,9 +189,9 @@ class NetworkGuardTests(unittest.TestCase): headers = dict(handler.response_headers) self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER) self.assertEqual(headers.get("Location"), "/watchlist") - self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", "")) + self.assertIn("ani_cli_web_session=", headers.get("Set-Cookie", "")) - def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self): + def test_loopback_proxy_with_forwarded_remote_ip_requires_credentials(self): handler = DummyHandler("/api/config") handler.client_address = ("127.0.0.1", 8421) handler.headers["X-Forwarded-For"] = "203.0.113.10" @@ -206,7 +200,7 @@ class NetworkGuardTests(unittest.TestCase): with self.assertRaises(PermissionError) as ctx: APP.Handler.ensure_client_access(handler) - self.assertIn("REMOTE_TOKEN", str(ctx.exception)) + self.assertIn("AUTH_USERNAME", str(ctx.exception)) class DownloadQueueCancelTests(unittest.TestCase): @@ -1767,6 +1761,33 @@ class HandlerRouteTests(unittest.TestCase): ["runtime_error", "watchlist_refresh_failed"], ) + def test_config_routes_hide_auth_fields_and_preserve_saved_credentials(self): + APP.save_runtime_config( + { + "download_dir": "/tmp/example", + "mode": "sub", + "quality": "best", + "auth_username": "demo", + "auth_password": "secret-pass", + } + ) + + post_body = b'{"download_dir":"/tmp/updated","mode":"dub","quality":"720"}' + post_handler = DummyHandler("/api/config", body=post_body, content_length=len(post_body)) + APP.Handler.do_POST(post_handler) + + self.assertEqual(post_handler.json_status, HTTPStatus.OK) + self.assertNotIn("auth_username", post_handler.json_payload) + self.assertNotIn("auth_password", post_handler.json_payload) + self.assertEqual(APP.get_config_snapshot()["auth_username"], "demo") + self.assertEqual(APP.get_config_snapshot()["auth_password"], "secret-pass") + + get_handler = DummyHandler("/api/config") + APP.Handler.do_GET(get_handler) + self.assertEqual(get_handler.json_status, HTTPStatus.OK) + self.assertNotIn("auth_username", get_handler.json_payload) + self.assertNotIn("auth_password", get_handler.json_payload) + def test_config_webhook_test_route_uses_unsaved_payload(self): body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}' handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body)) @@ -1911,10 +1932,13 @@ class HandlerRouteTests(unittest.TestCase): handler = DummyHandler("/api/config") with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object( http_handler, "debug_log" - ) as debug_log, mock.patch.object(http_handler.traceback, "print_exc") as print_exc: + ) as debug_log, mock.patch.object(http_handler, "send_discord_webhook_event") as send_webhook, mock.patch.object( + http_handler.traceback, "print_exc" + ) as print_exc: APP.Handler.exception(handler, RuntimeError("boom")) print_exc.assert_not_called() + send_webhook.assert_called_once() debug_log.assert_called_once() self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertEqual(handler.error_message, "Internal server error.")