Replace token auth with single-user login
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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.
|
- 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.
|
||||||
|
|||||||
@@ -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.40.0`
|
Current version: `0.41.0`
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
@@ -276,23 +276,28 @@ To allow LAN or remote access:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
ANI_CLI_WEB_ALLOW_REMOTE=1
|
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 <token>`
|
API clients can authenticate with:
|
||||||
- `X-AniCli-Web-Token: <token>`
|
|
||||||
|
|
||||||
Browser bootstrap also works:
|
- `Authorization: Basic <base64(username:password)>`
|
||||||
|
|
||||||
```text
|
The browser login stores a persistent HTTP-only session cookie, so you usually only need to sign in once per browser.
|
||||||
http://YOUR-HOST:8421/?token=your-token-here
|
|
||||||
|
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.
|
Environment variables take precedence over values saved in `config.json`.
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
@@ -343,7 +348,8 @@ Environment variables:
|
|||||||
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`
|
- `ANI_CLI_WEB_HOST`: server host, default `127.0.0.1`
|
||||||
- `ANI_CLI_WEB_PORT`: server port, default `8421`
|
- `ANI_CLI_WEB_PORT`: server port, default `8421`
|
||||||
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients
|
- `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_DEBUG`: enable debug logging
|
||||||
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory
|
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory
|
||||||
- `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup
|
- `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup
|
||||||
@@ -351,6 +357,8 @@ Environment variables:
|
|||||||
|
|
||||||
Saved config fields include:
|
Saved config fields include:
|
||||||
|
|
||||||
|
- `auth_username`
|
||||||
|
- `auth_password`
|
||||||
- `discord_webhook_url`
|
- `discord_webhook_url`
|
||||||
- `discord_webhook_events`
|
- `discord_webhook_events`
|
||||||
- `watchlist_auto_refresh_enabled`
|
- `watchlist_auto_refresh_enabled`
|
||||||
|
|||||||
@@ -2269,7 +2269,11 @@ def get_config_snapshot(fallback=None):
|
|||||||
|
|
||||||
|
|
||||||
def save_runtime_config(config):
|
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
|
scheduler = None
|
||||||
with RUNTIME_LOCK:
|
with RUNTIME_LOCK:
|
||||||
global CONFIG
|
global CONFIG
|
||||||
|
|||||||
+59
-7
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
"""Shared constants and helper utilities for ani-cli-web."""
|
"""Shared constants and helper utilities for ani-cli-web."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
@@ -19,7 +20,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.40.6"
|
VERSION = "0.41.0"
|
||||||
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"
|
||||||
@@ -82,17 +83,56 @@ def remote_access_allowed():
|
|||||||
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
|
return env_flag("ANI_CLI_WEB_ALLOW_REMOTE")
|
||||||
|
|
||||||
|
|
||||||
def remote_access_token():
|
def remote_access_username(config=None):
|
||||||
return str(os.environ.get("ANI_CLI_WEB_REMOTE_TOKEN") or "").strip()
|
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():
|
def remote_access_password(config=None):
|
||||||
return bool(remote_access_token())
|
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()
|
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))
|
return bool(expected and provided and hmac.compare_digest(provided, expected))
|
||||||
|
|
||||||
|
|
||||||
@@ -145,7 +185,10 @@ DEFAULT_CONFIG = {
|
|||||||
"auto_download_quality": "best",
|
"auto_download_quality": "best",
|
||||||
"discord_webhook_url": "",
|
"discord_webhook_url": "",
|
||||||
"discord_webhook_events": [],
|
"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():
|
def now_iso():
|
||||||
@@ -295,6 +338,8 @@ def normalize_config(data):
|
|||||||
auto_download_quality = auto_download_quality[:-1]
|
auto_download_quality = auto_download_quality[:-1]
|
||||||
webhook_url = str(config.get("discord_webhook_url") or "").strip()
|
webhook_url = str(config.get("discord_webhook_url") or "").strip()
|
||||||
webhook_events = config.get("discord_webhook_events")
|
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):
|
if isinstance(auto_refresh_enabled, str):
|
||||||
auto_refresh_enabled = auto_refresh_enabled.strip().lower() in {"1", "true", "yes", "on"}
|
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)
|
normalized_events.append(normalized_name)
|
||||||
config["discord_webhook_url"] = webhook_url
|
config["discord_webhook_url"] = webhook_url
|
||||||
config["discord_webhook_events"] = normalized_events
|
config["discord_webhook_events"] = normalized_events
|
||||||
|
config["auth_username"] = auth_username
|
||||||
|
config["auth_password"] = auth_password
|
||||||
return config
|
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):
|
def discord_webhook_enabled(config, event_name, force=False):
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
return False
|
return False
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ services:
|
|||||||
ANI_CLI_WEB_HOST: 0.0.0.0
|
ANI_CLI_WEB_HOST: 0.0.0.0
|
||||||
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
|
ANI_CLI_WEB_PORT: ${ANI_CLI_WEB_PORT:-8421}
|
||||||
ANI_CLI_WEB_ALLOW_REMOTE: ${ANI_CLI_WEB_ALLOW_REMOTE:-false}
|
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
|
ANI_CLI_DOWNLOAD_DIR: /downloads
|
||||||
volumes:
|
volumes:
|
||||||
- ./downloads:/downloads
|
- ./downloads:/downloads
|
||||||
|
|||||||
+76
-69
@@ -8,6 +8,8 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import traceback
|
import traceback
|
||||||
|
from base64 import b64decode
|
||||||
|
from binascii import Error as BinasciiError
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
@@ -28,8 +30,11 @@ from app_support import (
|
|||||||
debug_log,
|
debug_log,
|
||||||
normalize_config,
|
normalize_config,
|
||||||
remote_access_allowed,
|
remote_access_allowed,
|
||||||
remote_access_token_configured,
|
remote_access_credentials_configured,
|
||||||
remote_access_token_matches,
|
remote_access_credentials_match,
|
||||||
|
remote_access_session_matches,
|
||||||
|
remote_access_session_value,
|
||||||
|
sanitize_public_config,
|
||||||
send_discord_webhook_event,
|
send_discord_webhook_event,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,8 +97,8 @@ def installed_ani_cli_version():
|
|||||||
|
|
||||||
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_session_cookie_name = "ani_cli_web_session"
|
||||||
remote_token_cookie_max_age_seconds = 30 * 24 * 60 * 60
|
remote_session_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):
|
||||||
@@ -138,15 +143,24 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return candidate
|
return candidate
|
||||||
return peer_host
|
return peer_host
|
||||||
|
|
||||||
def _remote_token(self):
|
def _authorization_header(self):
|
||||||
return (
|
return str(self.headers.get("Authorization") or self.headers.get("authorization") or "").strip()
|
||||||
self.headers.get("X-AniCli-Web-Token")
|
|
||||||
or self.headers.get("x-ani-cli-web-token")
|
def _basic_auth_credentials(self):
|
||||||
or Handler._bearer_token(self)
|
header = Handler._authorization_header(self)
|
||||||
or Handler._cookie_token(self)
|
if not header.startswith("Basic "):
|
||||||
or Handler._query_token(self)
|
return ("", "")
|
||||||
or ""
|
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):
|
def _bearer_token(self):
|
||||||
header = str(self.headers.get("Authorization") or "").strip()
|
header = str(self.headers.get("Authorization") or "").strip()
|
||||||
@@ -154,7 +168,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
return ""
|
return ""
|
||||||
return header[len("Bearer ") :].strip()
|
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()
|
raw = str(self.headers.get("Cookie") or self.headers.get("cookie") or "").strip()
|
||||||
if not raw:
|
if not raw:
|
||||||
return ""
|
return ""
|
||||||
@@ -163,21 +177,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
cookie.load(raw)
|
cookie.load(raw)
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
morsel = cookie.get(Handler.remote_token_cookie_name)
|
morsel = cookie.get(name)
|
||||||
if not morsel:
|
if not morsel:
|
||||||
return ""
|
return ""
|
||||||
return morsel.value.strip()
|
return morsel.value.strip()
|
||||||
|
|
||||||
def _query_token(self):
|
def _session_cookie(self):
|
||||||
parsed = urlparse(self.path)
|
return Handler._cookie_value(self, Handler.remote_session_cookie_name)
|
||||||
params = parse_qs(parsed.query, keep_blank_values=True)
|
|
||||||
for key in ("token", "remote_token"):
|
def _remote_credentials_valid(self):
|
||||||
values = params.get(key) or []
|
session = Handler._session_cookie(self)
|
||||||
for value in values:
|
if session and remote_access_session_matches(session):
|
||||||
text = str(value).strip()
|
return True
|
||||||
if text:
|
username, password = Handler._basic_auth_credentials(self)
|
||||||
return text
|
return remote_access_credentials_match(username, password)
|
||||||
return ""
|
|
||||||
|
|
||||||
def _normalized_next_path(self, value):
|
def _normalized_next_path(self, value):
|
||||||
raw = str(value or "").strip()
|
raw = str(value or "").strip()
|
||||||
@@ -194,52 +207,43 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
query = parsed.query or ""
|
query = parsed.query or ""
|
||||||
return urlunparse(("", "", path, "", query, ""))
|
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 = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
params.pop("token", None)
|
params.pop("token", None)
|
||||||
params.pop("remote_token", None)
|
params.pop("remote_token", None)
|
||||||
query = urlencode(params, doseq=True)
|
query = urlencode(params, doseq=True)
|
||||||
return urlunparse(("", "", parsed.path or "/", "", query, ""))
|
return urlunparse(("", "", parsed.path or "/", "", query, ""))
|
||||||
|
|
||||||
def _remote_auth_cookie(self, token):
|
def _remote_auth_cookie(self, session_value):
|
||||||
return (
|
return (
|
||||||
f"{Handler.remote_token_cookie_name}={token}; "
|
f"{Handler.remote_session_cookie_name}={session_value}; "
|
||||||
f"Path=/; HttpOnly; SameSite=Lax; Max-Age={Handler.remote_token_cookie_max_age_seconds}"
|
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(
|
Handler.write_response_bytes(
|
||||||
self,
|
self,
|
||||||
b"",
|
b"",
|
||||||
HTTPStatus.SEE_OTHER,
|
HTTPStatus.SEE_OTHER,
|
||||||
{
|
{
|
||||||
"Location": Handler._normalized_next_path(self, location),
|
"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):
|
def _is_browser_page_request(self, parsed):
|
||||||
if getattr(self, "command", "") != "GET":
|
if getattr(self, "command", "") != "GET":
|
||||||
return False
|
return False
|
||||||
return not str(parsed.path or "").startswith("/api/")
|
return not str(parsed.path or "").startswith("/api/")
|
||||||
|
|
||||||
def _render_remote_login_page(self, parsed, message=""):
|
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 = ""
|
error_html = ""
|
||||||
if message:
|
if message:
|
||||||
error_html = f'<p class="error">{message}</p>'
|
error_html = f'<p class="error">{message}</p>'
|
||||||
@@ -334,17 +338,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div>
|
<div>
|
||||||
<h1>{APP_NAME}</h1>
|
<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>
|
<p class="muted">Sign in with the configured username and password. After that, this browser keeps a persistent auth session cookie.</p>
|
||||||
</div>
|
</div>
|
||||||
{error_html}
|
{error_html}
|
||||||
<form method="post" action="/auth/login">
|
<form method="post" action="/auth/login">
|
||||||
<input type="hidden" name="next" value="{next_path}">
|
<input type="hidden" name="next" value="{next_path}">
|
||||||
<label>Remote access token
|
<label>Username
|
||||||
<input name="token" type="password" autocomplete="current-password" autofocus required>
|
<input name="username" type="text" autocomplete="username" autofocus required>
|
||||||
|
</label>
|
||||||
|
<label>Password
|
||||||
|
<input name="password" type="password" autocomplete="current-password" required>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Sign in</button>
|
<button type="submit">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="muted">API clients can still use <code>Authorization: Bearer <token></code> or <code>?token=...</code>.</p>
|
<p class="muted">API clients can also use <code>Authorization: Basic <base64(username:password)></code>.</p>
|
||||||
</section>
|
</section>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -365,25 +372,23 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Remote access is disabled. Set ANI_CLI_WEB_ALLOW_REMOTE=1 to allow non-local clients."
|
"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(
|
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)):
|
if not Handler._remote_credentials_valid(self):
|
||||||
raise PermissionError("Remote access token missing or invalid.")
|
raise PermissionError("Remote username or password missing or invalid.")
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
try:
|
try:
|
||||||
if Handler._remote_cookie_bootstrap_response(self, parsed):
|
|
||||||
return
|
|
||||||
if parsed.path == "/auth/login":
|
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)
|
params = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
next_path = (params.get("next") or ["/"])[0]
|
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
|
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)
|
Handler._render_remote_login_page(self, parsed)
|
||||||
return
|
return
|
||||||
self.ensure_client_access()
|
self.ensure_client_access()
|
||||||
@@ -396,7 +401,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
elif parsed.path == "/watchlist":
|
elif parsed.path == "/watchlist":
|
||||||
self.html(Handler._context(self).watchlist_html)
|
self.html(Handler._context(self).watchlist_html)
|
||||||
elif parsed.path == "/api/config":
|
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":
|
elif parsed.path == "/api/version":
|
||||||
self.json({"name": APP_NAME, "version": VERSION, "ani_cli_version": installed_ani_cli_version()})
|
self.json({"name": APP_NAME, "version": VERSION, "ani_cli_version": installed_ani_cli_version()})
|
||||||
elif parsed.path == "/api/dependencies":
|
elif parsed.path == "/api/dependencies":
|
||||||
@@ -466,19 +471,20 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
try:
|
try:
|
||||||
if parsed.path == "/auth/login":
|
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.")
|
raise PermissionError("Remote access sign-in is not available.")
|
||||||
payload = Handler.body_form(self)
|
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 "/"
|
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(
|
Handler._render_remote_login_page(
|
||||||
self,
|
self,
|
||||||
urlparse(Handler._normalized_next_path(self, next_path)),
|
urlparse(Handler._normalized_next_path(self, next_path)),
|
||||||
"Remote access token missing or invalid.",
|
"Username or password missing or invalid.",
|
||||||
)
|
)
|
||||||
return
|
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
|
return
|
||||||
self.ensure_client_access()
|
self.ensure_client_access()
|
||||||
if parsed.path == "/api/config":
|
if parsed.path == "/api/config":
|
||||||
@@ -569,11 +575,12 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.exception(exc)
|
self.exception(exc)
|
||||||
|
|
||||||
def update_config(self, payload):
|
def update_config(self, payload):
|
||||||
|
payload = Handler.require_json_object(self, payload)
|
||||||
config = normalize_config(payload)
|
config = normalize_config(payload)
|
||||||
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
|
Path(config["download_dir"]).expanduser().mkdir(parents=True, exist_ok=True)
|
||||||
context = Handler._context(self)
|
context = Handler._context(self)
|
||||||
context.save_runtime_config(config)
|
context.save_runtime_config(payload)
|
||||||
self.json(context.get_config_snapshot())
|
self.json(sanitize_public_config(context.get_config_snapshot()))
|
||||||
|
|
||||||
def require_fields(self, payload, *names):
|
def require_fields(self, payload, *names):
|
||||||
payload = Handler.require_json_object(self, payload)
|
payload = Handler.require_json_object(self, payload)
|
||||||
@@ -698,7 +705,7 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
self.error(HTTPStatus.NOT_FOUND, str(exc).strip("'"))
|
||||||
elif isinstance(exc, PermissionError):
|
elif isinstance(exc, PermissionError):
|
||||||
parsed = urlparse(getattr(self, "path", "") or "/")
|
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))
|
Handler._render_remote_login_page(self, parsed, str(exc))
|
||||||
else:
|
else:
|
||||||
self.error(HTTPStatus.FORBIDDEN, str(exc))
|
self.error(HTTPStatus.FORBIDDEN, str(exc))
|
||||||
|
|||||||
+69
-45
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import base64
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import os
|
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("192.168.1.25"))
|
||||||
self.assertFalse(APP.client_address_is_local("10.0.0.8"))
|
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 = DummyHandler("/api/config")
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
handler.client_address = ("192.168.1.25", 8421)
|
||||||
|
|
||||||
@@ -109,68 +110,56 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with self.assertRaises(PermissionError) as ctx:
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
APP.Handler.ensure_client_access(handler)
|
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 = DummyHandler("/api/config")
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
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(
|
with mock.patch.dict(
|
||||||
os.environ,
|
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,
|
clear=False,
|
||||||
):
|
):
|
||||||
APP.Handler.ensure_client_access(handler)
|
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 = DummyHandler("/api/config")
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
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(
|
with mock.patch.dict(
|
||||||
os.environ,
|
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,
|
clear=False,
|
||||||
):
|
):
|
||||||
APP.Handler.ensure_client_access(handler)
|
APP.Handler.ensure_client_access(handler)
|
||||||
|
|
||||||
def test_remote_access_accepts_valid_query_token_for_non_local_clients(self):
|
def test_remote_browser_get_without_session_renders_login_page(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):
|
|
||||||
handler = DummyHandler("/")
|
handler = DummyHandler("/")
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
handler.client_address = ("192.168.1.25", 8421)
|
||||||
handler.command = "GET"
|
handler.command = "GET"
|
||||||
|
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
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,
|
clear=False,
|
||||||
):
|
):
|
||||||
APP.Handler.do_GET(handler)
|
APP.Handler.do_GET(handler)
|
||||||
@@ -178,16 +167,21 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
body = handler.wfile.getvalue().decode("utf-8")
|
body = handler.wfile.getvalue().decode("utf-8")
|
||||||
self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED)
|
self.assertEqual(handler.response_status, HTTPStatus.UNAUTHORIZED)
|
||||||
self.assertIn('<form method="post" action="/auth/login">', body)
|
self.assertIn('<form method="post" action="/auth/login">', 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):
|
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 = DummyHandler("/auth/login", body=body, content_length=len(body))
|
||||||
handler.client_address = ("192.168.1.25", 8421)
|
handler.client_address = ("192.168.1.25", 8421)
|
||||||
|
|
||||||
with mock.patch.dict(
|
with mock.patch.dict(
|
||||||
os.environ,
|
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,
|
clear=False,
|
||||||
):
|
):
|
||||||
APP.Handler.do_POST(handler)
|
APP.Handler.do_POST(handler)
|
||||||
@@ -195,9 +189,9 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
headers = dict(handler.response_headers)
|
headers = dict(handler.response_headers)
|
||||||
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
self.assertEqual(handler.response_status, HTTPStatus.SEE_OTHER)
|
||||||
self.assertEqual(headers.get("Location"), "/watchlist")
|
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 = DummyHandler("/api/config")
|
||||||
handler.client_address = ("127.0.0.1", 8421)
|
handler.client_address = ("127.0.0.1", 8421)
|
||||||
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
handler.headers["X-Forwarded-For"] = "203.0.113.10"
|
||||||
@@ -206,7 +200,7 @@ class NetworkGuardTests(unittest.TestCase):
|
|||||||
with self.assertRaises(PermissionError) as ctx:
|
with self.assertRaises(PermissionError) as ctx:
|
||||||
APP.Handler.ensure_client_access(handler)
|
APP.Handler.ensure_client_access(handler)
|
||||||
|
|
||||||
self.assertIn("REMOTE_TOKEN", str(ctx.exception))
|
self.assertIn("AUTH_USERNAME", str(ctx.exception))
|
||||||
|
|
||||||
|
|
||||||
class DownloadQueueCancelTests(unittest.TestCase):
|
class DownloadQueueCancelTests(unittest.TestCase):
|
||||||
@@ -1767,6 +1761,33 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
["runtime_error", "watchlist_refresh_failed"],
|
["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):
|
def test_config_webhook_test_route_uses_unsaved_payload(self):
|
||||||
body = b'{"discord_webhook_url":"https://discord.example/webhook","discord_webhook_events":["runtime_error"]}'
|
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))
|
handler = DummyHandler("/api/config/webhook/test", body=body, content_length=len(body))
|
||||||
@@ -1911,10 +1932,13 @@ class HandlerRouteTests(unittest.TestCase):
|
|||||||
handler = DummyHandler("/api/config")
|
handler = DummyHandler("/api/config")
|
||||||
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
with mock.patch.object(http_handler, "debug_enabled", return_value=False), mock.patch.object(
|
||||||
http_handler, "debug_log"
|
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"))
|
APP.Handler.exception(handler, RuntimeError("boom"))
|
||||||
|
|
||||||
print_exc.assert_not_called()
|
print_exc.assert_not_called()
|
||||||
|
send_webhook.assert_called_once()
|
||||||
debug_log.assert_called_once()
|
debug_log.assert_called_once()
|
||||||
self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR)
|
self.assertEqual(handler.error_status, HTTPStatus.INTERNAL_SERVER_ERROR)
|
||||||
self.assertEqual(handler.error_message, "Internal server error.")
|
self.assertEqual(handler.error_message, "Internal server error.")
|
||||||
|
|||||||
Reference in New Issue
Block a user