Improve remote auth bootstrap flow
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Small local web UI for a system-wide `ani-cli` install.
|
Small local web UI for a system-wide `ani-cli` install.
|
||||||
|
|
||||||
Current version: `0.36.0`
|
Current version: `0.36.1`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -273,7 +273,9 @@ Browser bootstrap also works:
|
|||||||
http://YOUR-HOST:8421/?token=your-token-here
|
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
|
## Download Queue
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@ from uuid import uuid4
|
|||||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
ANI_CLI = os.environ.get("ANI_CLI_BIN") or shutil.which("ani-cli") or "ani-cli"
|
||||||
APP_NAME = "ani-cli-web"
|
APP_NAME = "ani-cli-web"
|
||||||
VERSION = "0.35.0"
|
VERSION = "0.36.1"
|
||||||
ALLANIME_BASE = "allanime.day"
|
ALLANIME_BASE = "allanime.day"
|
||||||
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
ALLANIME_API = f"https://api.{ALLANIME_BASE}"
|
||||||
ALLANIME_REFERER = "https://allmanga.to"
|
ALLANIME_REFERER = "https://allmanga.to"
|
||||||
|
|||||||
+209
-16
@@ -74,6 +74,7 @@ def dependency_status():
|
|||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
server_version = "AniCliWeb/1.0"
|
server_version = "AniCliWeb/1.0"
|
||||||
remote_token_cookie_name = "ani_cli_web_token"
|
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"}
|
quiet_poll_paths = {"/api/queue", "/api/watchlist/refresh-status"}
|
||||||
|
|
||||||
def _context(self):
|
def _context(self):
|
||||||
@@ -159,6 +160,45 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return text
|
return text
|
||||||
return ""
|
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):
|
def _remote_cookie_bootstrap_response(self, parsed):
|
||||||
client_host = Handler._effective_client_host(self)
|
client_host = Handler._effective_client_host(self)
|
||||||
if client_address_is_local(client_host):
|
if client_address_is_local(client_host):
|
||||||
@@ -170,23 +210,131 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
token = Handler._query_token(self)
|
token = Handler._query_token(self)
|
||||||
if not token or not remote_access_token_matches(token):
|
if not token or not remote_access_token_matches(token):
|
||||||
return False
|
return False
|
||||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
location = Handler._current_path_without_tokens(self, parsed)
|
||||||
params.pop("token", None)
|
Handler._set_remote_auth_cookie_and_redirect(self, token, location)
|
||||||
params.pop("remote_token", None)
|
return True
|
||||||
query = urlencode(params, doseq=True)
|
|
||||||
location = urlunparse(("", "", parsed.path or "/", "", query, ""))
|
def _is_browser_page_request(self, parsed):
|
||||||
cookie = (
|
if getattr(self, "command", "") != "GET":
|
||||||
f"{Handler.remote_token_cookie_name}={token}; "
|
return False
|
||||||
"Path=/; HttpOnly; SameSite=Lax"
|
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 <token></code> or <code>?token=...</code>.</p>
|
||||||
|
</section>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
Handler.write_response_bytes(
|
Handler.write_response_bytes(
|
||||||
self,
|
self,
|
||||||
b"",
|
html.encode("utf-8"),
|
||||||
HTTPStatus.SEE_OTHER,
|
HTTPStatus.UNAUTHORIZED,
|
||||||
{
|
{"Content-Type": "text/html; charset=utf-8"},
|
||||||
"Location": location,
|
|
||||||
"Set-Cookie": cookie,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -210,6 +358,15 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
try:
|
try:
|
||||||
if Handler._remote_cookie_bootstrap_response(self, parsed):
|
if Handler._remote_cookie_bootstrap_response(self, parsed):
|
||||||
return
|
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()
|
self.ensure_client_access()
|
||||||
if parsed.path == "/":
|
if parsed.path == "/":
|
||||||
self.html(Handler._context(self).index_html)
|
self.html(Handler._context(self).index_html)
|
||||||
@@ -281,6 +438,21 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
try:
|
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()
|
self.ensure_client_access()
|
||||||
if parsed.path == "/api/config":
|
if parsed.path == "/api/config":
|
||||||
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
|
Handler.update_config(self, Handler.require_json_object(self, self.body_json()))
|
||||||
@@ -423,6 +595,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise ValueError("Invalid JSON body") from 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):
|
def html(self, content):
|
||||||
data = content.encode("utf-8")
|
data = content.encode("utf-8")
|
||||||
self.write_response_bytes(data, HTTPStatus.OK, {"Content-Type": "text/html; charset=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):
|
elif isinstance(exc, KeyError):
|
||||||
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
||||||
elif isinstance(exc, PermissionError):
|
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):
|
elif isinstance(exc, ValueError):
|
||||||
self.error(HTTPStatus.BAD_REQUEST, str(exc))
|
self.error(HTTPStatus.BAD_REQUEST, str(exc))
|
||||||
else:
|
else:
|
||||||
|
|||||||
+35
@@ -161,6 +161,41 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
||||||
self.assertEqual(headers.get("Location"), "/")
|
self.assertEqual(headers.get("Location"), "/")
|
||||||
self.assertIn("ani_cli_web_token=secret-token", headers.get("Set-Cookie", ""))
|
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('<form method="post" action="/auth/login">', 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):
|
def test_loopback_proxy_with_forwarded_remote_ip_requires_token(self):
|
||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
|
|||||||
Reference in New Issue
Block a user