Finalize diagnostics, logging controls, and email test support

This commit is contained in:
2026-03-01 22:34:07 +13:00
parent 12d3777e76
commit d1c9acbb8d
19 changed files with 2578 additions and 99 deletions

View File

@@ -84,9 +84,11 @@ from ..services.invite_email import (
get_invite_email_templates,
reset_invite_email_template,
save_invite_email_template,
send_test_email,
send_templated_email,
smtp_email_config_ready,
)
from ..services.diagnostics import get_diagnostics_catalog, run_diagnostics
import logging
from ..logging_config import configure_logging
from ..routers import requests as requests_router
@@ -192,6 +194,10 @@ SETTING_KEYS: List[str] = [
"qbittorrent_password",
"log_level",
"log_file",
"log_file_max_bytes",
"log_file_backup_count",
"log_http_client_level",
"log_background_sync_level",
"requests_sync_ttl_minutes",
"requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes",
@@ -609,6 +615,7 @@ async def list_settings() -> Dict[str, Any]:
async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
updates = 0
touched_logging = False
changed_keys: List[str] = []
for key, value in payload.items():
if key not in SETTING_KEYS:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
@@ -617,6 +624,7 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
if isinstance(value, str) and value.strip() == "":
delete_setting(key)
updates += 1
changed_keys.append(key)
continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key in URL_SETTING_KEYS and value_to_store:
@@ -627,14 +635,79 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
if key in {"log_level", "log_file"}:
changed_keys.append(key)
if key in {"log_level", "log_file", "log_file_max_bytes", "log_file_backup_count", "log_http_client_level", "log_background_sync_level"}:
touched_logging = True
if touched_logging:
runtime = get_runtime_settings()
configure_logging(runtime.log_level, runtime.log_file)
configure_logging(
runtime.log_level,
runtime.log_file,
log_file_max_bytes=runtime.log_file_max_bytes,
log_file_backup_count=runtime.log_file_backup_count,
log_http_client_level=runtime.log_http_client_level,
log_background_sync_level=runtime.log_background_sync_level,
)
logger.info("Admin updated settings: count=%s keys=%s", updates, changed_keys)
return {"status": "ok", "updated": updates}
@router.post("/settings/test/email")
async def test_email_settings(request: Request) -> Dict[str, Any]:
recipient_email = None
content_type = (request.headers.get("content-type") or "").split(";", 1)[0].strip().lower()
try:
if content_type == "application/json":
payload = await request.json()
if isinstance(payload, dict) and isinstance(payload.get("recipient_email"), str):
recipient_email = payload["recipient_email"]
elif content_type in {
"application/x-www-form-urlencoded",
"multipart/form-data",
}:
form = await request.form()
candidate = form.get("recipient_email")
if isinstance(candidate, str):
recipient_email = candidate
except Exception:
recipient_email = None
try:
result = await send_test_email(recipient_email=recipient_email)
except RuntimeError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info("Admin triggered SMTP test: recipient=%s", result.get("recipient_email"))
return {"status": "ok", **result}
@router.get("/diagnostics")
async def diagnostics_catalog() -> Dict[str, Any]:
return {"status": "ok", **get_diagnostics_catalog()}
@router.post("/diagnostics/run")
async def diagnostics_run(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
keys: Optional[List[str]] = None
recipient_email: Optional[str] = None
if payload is not None:
raw_keys = payload.get("keys")
if raw_keys is not None:
if not isinstance(raw_keys, list):
raise HTTPException(status_code=400, detail="keys must be an array of diagnostic keys")
keys = []
for raw_key in raw_keys:
if not isinstance(raw_key, str):
raise HTTPException(status_code=400, detail="Each diagnostic key must be a string")
normalized = raw_key.strip()
if normalized:
keys.append(normalized)
raw_recipient_email = payload.get("recipient_email")
if raw_recipient_email is not None:
if not isinstance(raw_recipient_email, str):
raise HTTPException(status_code=400, detail="recipient_email must be a string")
recipient_email = raw_recipient_email.strip() or None
return {"status": "ok", **(await run_diagnostics(keys, recipient_email=recipient_email))}
@router.get("/sonarr/options")
async def sonarr_options() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -1061,12 +1134,14 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
@router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, True)
logger.warning("Admin blocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": True}
@router.post("/users/{username}/unblock")
async def unblock_user(username: str) -> Dict[str, Any]:
set_user_blocked(username, False)
logger.info("Admin unblocked user: username=%s", username)
return {"status": "ok", "username": username, "blocked": False}
@@ -1173,6 +1248,17 @@ async def user_system_action(username: str, payload: Dict[str, Any]) -> Dict[str
for system in (result.get("jellyfin"), result.get("jellyseerr"), result.get("email"))
):
result["status"] = "partial"
logger.info(
"Admin system action completed: username=%s action=%s overall=%s local=%s jellyfin=%s jellyseerr=%s invites=%s email=%s",
username,
action,
result.get("status"),
result.get("local", {}).get("status"),
result.get("jellyfin", {}).get("status"),
result.get("jellyseerr", {}).get("status"),
result.get("invites", {}).get("status"),
result.get("email", {}).get("status"),
)
return result
@@ -1450,6 +1536,15 @@ async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
logger.info(
"Admin created profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
profile.get("id"),
profile.get("name"),
profile.get("role"),
profile.get("is_active"),
profile.get("auto_search_enabled"),
profile.get("account_expires_days"),
)
return {"status": "ok", "profile": profile}
@@ -1487,6 +1582,15 @@ async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, An
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
logger.info(
"Admin updated profile: profile_id=%s name=%s role=%s active=%s auto_search=%s expires_days=%s",
profile.get("id"),
profile.get("name"),
profile.get("role"),
profile.get("is_active"),
profile.get("auto_search_enabled"),
profile.get("account_expires_days"),
)
return {"status": "ok", "profile": profile}
@@ -1498,6 +1602,7 @@ async def remove_profile(profile_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
logger.warning("Admin deleted profile: profile_id=%s", profile_id)
return {"status": "ok", "deleted": True, "profile_id": profile_id}
@@ -1561,6 +1666,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
master_invite_value = payload.get("master_invite_id")
if master_invite_value in (None, "", 0, "0"):
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None)
logger.info("Admin cleared invite policy master invite")
return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}}
try:
master_invite_id = int(master_invite_value)
@@ -1572,6 +1678,7 @@ async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]:
if not invite:
raise HTTPException(status_code=404, detail="Master invite not found")
set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id))
logger.info("Admin updated invite policy: master_invite_id=%s", master_invite_id)
return {
"status": "ok",
"policy": {
@@ -1613,6 +1720,7 @@ async def update_invite_email_template_settings(template_key: str, payload: Dict
body_text=body_text or "",
body_html=body_html or "",
)
logger.info("Admin updated invite email template: template=%s", template_key)
return {"status": "ok", "template": template}
@@ -1621,6 +1729,7 @@ async def reset_invite_email_template_settings(template_key: str) -> Dict[str, A
if template_key not in INVITE_EMAIL_TEMPLATE_KEYS:
raise HTTPException(status_code=404, detail="Email template not found")
template = reset_invite_email_template(template_key)
logger.info("Admin reset invite email template: template=%s", template_key)
return {"status": "ok", "template": template}
@@ -1666,6 +1775,13 @@ async def send_invite_email(payload: Dict[str, Any]) -> Dict[str, Any]:
)
except Exception as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
logger.info(
"Admin sent invite email template: template=%s recipient=%s invite_id=%s username=%s",
template_key,
result.get("recipient_email"),
invite.get("id") if invite else None,
user.get("username") if user else None,
)
return {
"status": "ok",
@@ -1725,6 +1841,18 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
)
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin created invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
send_email,
)
return {
"status": "partial" if email_error else "ok",
"invite": invite,
@@ -1785,6 +1913,18 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
)
except Exception as exc:
email_error = str(exc)
logger.info(
"Admin updated invite: invite_id=%s code=%s label=%s profile_id=%s role=%s max_uses=%s enabled=%s recipient_email=%s send_email=%s",
invite.get("id"),
invite.get("code"),
invite.get("label"),
invite.get("profile_id"),
invite.get("role"),
invite.get("max_uses"),
invite.get("enabled"),
invite.get("recipient_email"),
send_email,
)
return {
"status": "partial" if email_error else "ok",
"invite": invite,
@@ -1803,4 +1943,5 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": invite_id}

View File

@@ -115,6 +115,7 @@ def _record_login_failure(request: Request, username: str) -> None:
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
logger.warning("login failure recorded username=%s client=%s", user_key, ip_key)
def _clear_login_failures(request: Request, username: str) -> None:
@@ -148,6 +149,12 @@ def _enforce_login_rate_limit(request: Request, username: str) -> None:
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded:
logger.warning(
"login rate limit exceeded username=%s client=%s retry_after=%s",
user_key,
ip_key,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again shortly.",
@@ -474,6 +481,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=local username=%s client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
# Provider placeholder passwords must never be accepted by the local-login endpoint.
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
_record_login_failure(request, form_data.username)
@@ -483,6 +495,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
)
if has_external_match:
logger.warning(
"login rejected provider=local username=%s reason=external-account client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
@@ -492,6 +509,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local":
logger.warning(
"login rejected provider=local username=%s reason=wrong-provider client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
@@ -500,6 +522,12 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
token = create_access_token(user["username"], user["role"])
_clear_login_failures(request, form_data.username)
set_last_login(user["username"])
logger.info(
"login success provider=local username=%s role=%s client=%s",
user["username"],
user["role"],
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
@@ -510,6 +538,11 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
@router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=jellyfin username=%s client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured():
@@ -527,6 +560,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
"login success provider=jellyfin username=%s source=cache client=%s",
canonical_username,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
@@ -535,6 +573,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
try:
response = await client.authenticate_by_name(username, password)
except Exception as exc:
logger.exception(
"login upstream error provider=jellyfin username=%s client=%s",
_login_rate_key_user(username),
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
_record_login_failure(request, username)
@@ -564,6 +607,12 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, username)
set_last_login(canonical_username)
logger.info(
"login success provider=jellyfin username=%s linked_seerr_id=%s client=%s",
canonical_username,
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
@@ -575,6 +624,11 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
@router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=seerr username=%s client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
@@ -582,6 +636,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
try:
response = await client.login_local(form_data.username, form_data.password)
except Exception as exc:
logger.exception(
"login upstream error provider=seerr username=%s client=%s",
_login_rate_key_user(form_data.username),
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
_record_login_failure(request, form_data.username)
@@ -605,6 +664,12 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
token = create_access_token(canonical_username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(canonical_username)
logger.info(
"login success provider=seerr username=%s seerr_user_id=%s client=%s",
canonical_username,
jellyseerr_user_id,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
@@ -663,6 +728,11 @@ async def signup(payload: dict) -> dict:
)
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
logger.info(
"signup attempt username=%s invite_code=%s",
username,
invite_code,
)
invite = get_signup_invite_by_code(invite_code)
if not invite:
@@ -709,6 +779,7 @@ async def signup(payload: dict) -> dict:
jellyfin_client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if jellyfin_client.configured():
logger.info("signup provisioning jellyfin username=%s", username)
auth_provider = "jellyfin"
local_password_value = "jellyfin-user"
try:
@@ -788,6 +859,14 @@ async def signup(payload: dict) -> dict:
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
logger.info(
"signup success username=%s role=%s auth_provider=%s profile_id=%s invite_code=%s",
username,
role,
created_user.get("auth_provider") if created_user else auth_provider,
created_user.get("profile_id") if created_user else None,
invite.get("code"),
)
return {
"access_token": token,
"token_type": "bearer",

View File

@@ -596,7 +596,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
skip += take
_sync_state["skip"] = skip
_sync_state["message"] = f"Synced {stored} requests"
logger.info("Seerr sync progress: stored=%s skip=%s", stored, skip)
logger.debug("Seerr sync progress: stored=%s skip=%s", stored, skip)
_sync_state.update(
{
"status": "completed",
@@ -719,7 +719,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
skip += take
_sync_state["skip"] = skip
_sync_state["message"] = f"Delta synced {stored} requests"
logger.info("Seerr delta sync progress: stored=%s skip=%s", stored, skip)
logger.debug("Seerr delta sync progress: stored=%s skip=%s", stored, skip)
deduped = prune_duplicate_requests_cache()
if deduped:
logger.info("Seerr delta sync removed duplicate rows: %s", deduped)