From c6ba7fdbe28dd600cfd16cf4e9ceb2bafd697097 Mon Sep 17 00:00:00 2001 From: Dymas Date: Sat, 23 May 2026 14:37:48 +0200 Subject: [PATCH] Improve remote auth bootstrap flow --- CHANGELOG.md | 4 + README.md | 6 +- VERSION | 2 +- app_support.py | 2 +- http_handler.py | 225 ++++++++++++++++++++++++++++++++++++++++++++---- test_app.py | 35 ++++++++ 6 files changed, 254 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6c0743..965902b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.36.1 - 2026-05-23 + +- Replaced the first-visit remote-access 403 dead end with a browser sign-in page and persistent auth cookie, so remote users no longer need to know the `?token=...` bootstrap URL format up front. + ## 0.36.0 - 2026-05-23 - Replaced the watchlist card `Open` action with `Download all`, which prompts for `sub` or `dub` and then queues all currently available episodes directly from the watchlist. diff --git a/README.md b/README.md index 61adafe..e356ed8 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.36.0` +Current version: `0.36.1` ## Project Layout @@ -273,7 +273,9 @@ Browser bootstrap also works: http://YOUR-HOST:8421/?token=your-token-here ``` -The app stores the token in an HTTP-only cookie and redirects to the clean URL. +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. ## Download Queue diff --git a/VERSION b/VERSION index 93d4c1e..19199bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.36.0 +0.36.1 diff --git a/app_support.py b/app_support.py index 3815b84..2b37da2 100644 --- a/app_support.py +++ b/app_support.py @@ -19,7 +19,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.35.0" +VERSION = "0.36.1" 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 2da5d93..d600c88 100644 --- a/http_handler.py +++ b/http_handler.py @@ -74,6 +74,7 @@ def dependency_status(): 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): @@ -159,6 +160,45 @@ class Handler(BaseHTTPRequestHandler): 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): @@ -170,23 +210,131 @@ class Handler(BaseHTTPRequestHandler): 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" - ) + 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'

{message}

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

{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.

+
+ {error_html} +
+ + + +
+

API clients can still use Authorization: Bearer <token> or ?token=....

+
+ + +""" Handler.write_response_bytes( self, - b"", - HTTPStatus.SEE_OTHER, - { - "Location": location, - "Set-Cookie": cookie, - }, + html.encode("utf-8"), + HTTPStatus.UNAUTHORIZED, + {"Content-Type": "text/html; charset=utf-8"}, ) return True @@ -210,6 +358,15 @@ class Handler(BaseHTTPRequestHandler): 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) @@ -281,6 +438,21 @@ class Handler(BaseHTTPRequestHandler): 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())) @@ -423,6 +595,23 @@ class Handler(BaseHTTPRequestHandler): 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"}) @@ -469,7 +658,11 @@ class Handler(BaseHTTPRequestHandler): elif isinstance(exc, KeyError): self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'")) elif isinstance(exc, PermissionError): - self.error(HTTPStatus.FORBIDDEN, str(exc)) + 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: diff --git a/test_app.py b/test_app.py index 37e0dde..e9baa23 100644 --- a/test_app.py +++ b/test_app.py @@ -161,6 +161,41 @@ class NetworkGuardTests(unittest.TestCase): 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): + 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"}, + clear=False, + ): + APP.Handler.do_GET(handler) + + body = handler.wfile.getvalue().decode("utf-8") + self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED) + self.assertIn('
', body) + self.assertIn("Remote access token", body) + + def test_remote_login_post_sets_cookie_and_redirects(self): + body = b"token=secret-token&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"}, + clear=False, + ): + APP.Handler.do_POST(handler) + + 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", "")) def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self): handler = DummyHandler("/api/config")