diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7756a..96a2bbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.30.4 - 2026-05-17 + +- Added browser-friendly remote auth bootstrap: remote users can open the app once with `?token=...`, the server stores the token in an HTTP-only cookie, and then strips the token back out of the URL with a redirect. +- Kept existing remote header auth support for API clients while extending regression coverage for cookie, query-token, and bootstrap redirect access. + ## 0.30.3 - 2026-05-17 - Fixed container startup under `docker-compose` and custom `USER_UID`/`USER_GID` mappings by launching the web app with `python3 /app/app.py` directly instead of relying on the `./ani-cli-web` launcher file being executable inside the container. diff --git a/README.md b/README.md index 6873d1b..5cbef38 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A local web UI for a system-wide `ani-cli` install. -Current version: `0.30.3` +Current version: `0.30.4` ## Project Layout @@ -223,6 +223,14 @@ Non-local clients must then send either: - `Authorization: Bearer ` - `X-AniCli-Web-Token: ` +For browser access from another machine, you can also open the app once with: + +```text +http://YOUR-HOST:8421/?token=your-token-here +``` + +The server will set an HTTP-only cookie and redirect to the same page without the token in the URL, so normal browser navigation and API polling continue to work afterward. + When the app is placed behind a loopback reverse proxy, forwarded external client IP headers such as `X-Forwarded-For` are also treated as non-local, so proxied internet or LAN traffic still needs the token. This keeps the queue, config, and watchlist APIs safer when the app is intentionally exposed beyond localhost. diff --git a/VERSION b/VERSION index e8262eb..db287d4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.30.3 +0.30.4 diff --git a/app_support.py b/app_support.py index 71847c3..9df2d96 100644 --- a/app_support.py +++ b/app_support.py @@ -16,7 +16,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.30.3" +VERSION = "0.30.4" ALLANIME_BASE = "allanime.day" ALLANIME_API = f"https://api.{ALLANIME_BASE}" ALLANIME_REFERER = "https://allmanga.to" diff --git a/http_handler.py b/http_handler.py index 6fb359c..fa063ef 100644 --- a/http_handler.py +++ b/http_handler.py @@ -8,10 +8,11 @@ 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, urlparse +from urllib.parse import parse_qs, unquote, urlencode, urlparse, urlunparse from app_support import ( ANI_CLI, @@ -68,6 +69,7 @@ def dependency_status(): class Handler(BaseHTTPRequestHandler): server_version = "AniCliWeb/1.0" + remote_token_cookie_name = "ani_cli_web_token" def _context(self): context = getattr(self, "handler_context", None) or getattr(type(self), "handler_context", None) @@ -116,6 +118,8 @@ class Handler(BaseHTTPRequestHandler): 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 "" ) @@ -125,6 +129,62 @@ class Handler(BaseHTTPRequestHandler): 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 _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 + params = parse_qs(parsed.query, keep_blank_values=True) + params.pop("token", None) + params.pop("remote_token", None) + query = urlencode(params, doseq=True) + location = urlunparse(("", "", parsed.path or "/", "", query, "")) + cookie = ( + f"{Handler.remote_token_cookie_name}={token}; " + "Path=/; HttpOnly; SameSite=Lax" + ) + Handler.write_response_bytes( + self, + b"", + HTTPStatus.SEE_OTHER, + { + "Location": location, + "Set-Cookie": cookie, + }, + ) + return True + def ensure_client_access(self): client_host = Handler._effective_client_host(self) if client_address_is_local(client_host): @@ -143,6 +203,8 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): parsed = urlparse(self.path) try: + if Handler._remote_cookie_bootstrap_response(self, parsed): + return self.ensure_client_access() if parsed.path == "/": self.html(Handler._context(self).index_html) diff --git a/test_app.py b/test_app.py index acec41c..7d15b8b 100644 --- a/test_app.py +++ b/test_app.py @@ -51,6 +51,9 @@ class DummyHandler: self.error_status = None self.error_message = None self.file_path = None + self.response_status = None + self.response_headers = [] + self.wfile = io.BytesIO() def ensure_client_access(self): return APP.Handler.ensure_client_access(self) @@ -75,6 +78,15 @@ class DummyHandler: def exception(self, exc): return APP.Handler.exception(self, exc) + def send_response(self, status): + self.response_status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + return None + class NetworkGuardTests(unittest.TestCase): def test_runtime_bootstrap_is_deferred_until_initialize(self): @@ -111,6 +123,45 @@ class NetworkGuardTests(unittest.TestCase): ): APP.Handler.ensure_client_access(handler) + def test_remote_access_accepts_valid_cookie_token_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" + + 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_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", "")) + def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self): handler = DummyHandler("/api/config") handler.client_address = ("127.0.0.1", 8421)