Build 2602262030: add magent settings and hardening

This commit is contained in:
2026-02-26 20:31:26 +13:00
parent b215e8030c
commit 0b73d9f4ee
16 changed files with 897 additions and 140 deletions

View File

@@ -1 +1 @@
2602261731 2602262030

View File

@@ -38,11 +38,18 @@ def _extract_client_ip(request: Request) -> str:
return "unknown" return "unknown"
def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]: def _load_current_user_from_token(
token: str,
request: Optional[Request] = None,
allowed_token_types: Optional[set[str]] = None,
) -> Dict[str, Any]:
try: try:
payload = safe_decode_token(token) payload = safe_decode_token(token)
except TokenError as exc: except TokenError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
token_type = str(payload.get("typ") or "access").strip().lower()
if allowed_token_types and token_type not in allowed_token_types:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token type")
username = payload.get("sub") username = payload.get("sub")
if not username: if not username:
@@ -79,16 +86,24 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
def get_current_user_event_stream(request: Request) -> Dict[str, Any]: def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a query token here only.""" """EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
token = None token = None
stream_query_token = None
auth_header = request.headers.get("authorization", "") auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "): if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip() token = auth_header.split(" ", 1)[1].strip()
if not token: if not token:
token = request.query_params.get("access_token") stream_query_token = request.query_params.get("stream_token")
if not token: if not token and not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None) return _load_current_user_from_token(token, None)
return _load_current_user_from_token(
str(stream_query_token),
None,
allowed_token_types={"sse"},
)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:

View File

@@ -1,2 +1,2 @@
BUILD_NUMBER = "2602261731" BUILD_NUMBER = "2602262030"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -11,6 +11,16 @@ class Settings(BaseSettings):
sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH")) sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH"))
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
default=60, validation_alias=AliasChoices("AUTH_RATE_LIMIT_WINDOW_SECONDS")
)
auth_rate_limit_max_attempts_ip: int = Field(
default=15, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_IP")
)
auth_rate_limit_max_attempts_user: int = Field(
default=5, validation_alias=AliasChoices("AUTH_RATE_LIMIT_MAX_ATTEMPTS_USER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
@@ -51,6 +61,126 @@ class Settings(BaseSettings):
) )
site_changelog: Optional[str] = Field(default=CHANGELOG) site_changelog: Optional[str] = Field(default=CHANGELOG)
magent_application_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_APPLICATION_URL")
)
magent_application_port: int = Field(
default=3000, validation_alias=AliasChoices("MAGENT_APPLICATION_PORT")
)
magent_api_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_API_URL")
)
magent_api_port: int = Field(
default=8000, validation_alias=AliasChoices("MAGENT_API_PORT")
)
magent_bind_host: str = Field(
default="0.0.0.0", validation_alias=AliasChoices("MAGENT_BIND_HOST")
)
magent_proxy_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_PROXY_ENABLED")
)
magent_proxy_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_BASE_URL")
)
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
magent_ssl_bind_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_SSL_BIND_ENABLED")
)
magent_ssl_certificate_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PATH")
)
magent_ssl_private_key_path: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PATH")
)
magent_ssl_certificate_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_CERTIFICATE_PEM")
)
magent_ssl_private_key_pem: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_SSL_PRIVATE_KEY_PEM")
)
magent_notify_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_ENABLED")
)
magent_notify_email_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_ENABLED")
)
magent_notify_email_smtp_host: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_HOST")
)
magent_notify_email_smtp_port: int = Field(
default=587, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PORT")
)
magent_notify_email_smtp_username: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_USERNAME")
)
magent_notify_email_smtp_password: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_SMTP_PASSWORD")
)
magent_notify_email_from_address: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_ADDRESS")
)
magent_notify_email_from_name: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_FROM_NAME")
)
magent_notify_email_use_tls: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_TLS")
)
magent_notify_email_use_ssl: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_EMAIL_USE_SSL")
)
magent_notify_discord_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_ENABLED")
)
magent_notify_discord_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_DISCORD_WEBHOOK_URL")
)
magent_notify_telegram_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_ENABLED")
)
magent_notify_telegram_bot_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_BOT_TOKEN")
)
magent_notify_telegram_chat_id: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_TELEGRAM_CHAT_ID")
)
magent_notify_push_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_ENABLED")
)
magent_notify_push_provider: Optional[str] = Field(
default="ntfy", validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_PROVIDER")
)
magent_notify_push_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_BASE_URL")
)
magent_notify_push_topic: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOPIC")
)
magent_notify_push_token: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_TOKEN")
)
magent_notify_push_user_key: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_USER_KEY")
)
magent_notify_push_device: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_PUSH_DEVICE")
)
magent_notify_webhook_enabled: bool = Field(
default=False, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_ENABLED")
)
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
jellyseerr_base_url: Optional[str] = Field( jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
) )

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from .config import settings from .config import settings
@@ -24,7 +24,12 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging from .logging_config import configure_logging
from .runtime import get_runtime_settings from .runtime import get_runtime_settings
app = FastAPI(title=settings.app_name) app = FastAPI(
title=settings.app_name,
docs_url="/docs" if settings.api_docs_enabled else None,
redoc_url=None,
openapi_url="/openapi.json" if settings.api_docs_enabled else None,
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -35,6 +40,22 @@ app.add_middleware(
) )
@app.middleware("http")
async def add_security_headers(request: Request, call_next):
response = await call_next(request)
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "no-referrer")
response.headers.setdefault("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
# Keep API responses non-executable and non-embeddable by default.
if request.url.path not in {"/docs", "/redoc"} and not request.url.path.startswith("/openapi"):
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'none'; frame-ancestors 'none'; base-uri 'none'",
)
return response
@app.get("/health") @app.get("/health")
async def health() -> dict: async def health() -> dict:
return {"status": "ok"} return {"status": "ok"}

View File

@@ -90,6 +90,14 @@ logger = logging.getLogger(__name__)
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
SENSITIVE_KEYS = { SENSITIVE_KEYS = {
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
"magent_notify_email_smtp_password",
"magent_notify_discord_webhook_url",
"magent_notify_telegram_bot_token",
"magent_notify_push_token",
"magent_notify_push_user_key",
"magent_notify_webhook_url",
"jellyseerr_api_key", "jellyseerr_api_key",
"jellyfin_api_key", "jellyfin_api_key",
"sonarr_api_key", "sonarr_api_key",
@@ -99,6 +107,11 @@ SENSITIVE_KEYS = {
} }
URL_SETTING_KEYS = { URL_SETTING_KEYS = {
"magent_application_url",
"magent_api_url",
"magent_proxy_base_url",
"magent_notify_discord_webhook_url",
"magent_notify_push_base_url",
"jellyseerr_base_url", "jellyseerr_base_url",
"jellyfin_base_url", "jellyfin_base_url",
"jellyfin_public_url", "jellyfin_public_url",
@@ -109,6 +122,44 @@ URL_SETTING_KEYS = {
} }
SETTING_KEYS: List[str] = [ SETTING_KEYS: List[str] = [
"magent_application_url",
"magent_application_port",
"magent_api_url",
"magent_api_port",
"magent_bind_host",
"magent_proxy_enabled",
"magent_proxy_base_url",
"magent_proxy_trust_forwarded_headers",
"magent_proxy_forwarded_prefix",
"magent_ssl_bind_enabled",
"magent_ssl_certificate_path",
"magent_ssl_private_key_path",
"magent_ssl_certificate_pem",
"magent_ssl_private_key_pem",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_smtp_host",
"magent_notify_email_smtp_port",
"magent_notify_email_smtp_username",
"magent_notify_email_smtp_password",
"magent_notify_email_from_address",
"magent_notify_email_from_name",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_discord_webhook_url",
"magent_notify_telegram_enabled",
"magent_notify_telegram_bot_token",
"magent_notify_telegram_chat_id",
"magent_notify_push_enabled",
"magent_notify_push_provider",
"magent_notify_push_base_url",
"magent_notify_push_topic",
"magent_notify_push_token",
"magent_notify_push_user_key",
"magent_notify_push_device",
"magent_notify_webhook_enabled",
"magent_notify_webhook_url",
"jellyseerr_base_url", "jellyseerr_base_url",
"jellyseerr_api_key", "jellyseerr_api_key",
"jellyfin_base_url", "jellyfin_base_url",

View File

@@ -1,9 +1,12 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import secrets import secrets
import string import string
import time
from threading import Lock
import httpx import httpx
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from ..db import ( from ..db import (
@@ -34,7 +37,9 @@ from ..runtime import get_runtime_settings
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..security import create_access_token, verify_password from ..security import create_access_token, verify_password
from ..security import create_stream_token
from ..auth import get_current_user from ..auth import get_current_user
from ..config import settings
from ..services.user_cache import ( from ..services.user_cache import (
build_jellyseerr_candidate_map, build_jellyseerr_candidate_map,
get_cached_jellyseerr_users, get_cached_jellyseerr_users,
@@ -44,6 +49,85 @@ from ..services.user_cache import (
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
STREAM_TOKEN_TTL_SECONDS = 120
_LOGIN_RATE_LOCK = Lock()
_LOGIN_ATTEMPTS_BY_IP: dict[str, deque[float]] = defaultdict(deque)
_LOGIN_ATTEMPTS_BY_USER: dict[str, deque[float]] = defaultdict(deque)
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
def _login_rate_key_user(username: str) -> str:
return (username or "").strip().lower()[:256] or "<empty>"
def _prune_attempts(bucket: deque[float], now: float, window_seconds: int) -> None:
cutoff = now - window_seconds
while bucket and bucket[0] < cutoff:
bucket.popleft()
def _record_login_failure(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
ip_bucket.append(now)
user_bucket.append(now)
def _clear_login_failures(request: Request, username: str) -> None:
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
_LOGIN_ATTEMPTS_BY_IP.pop(ip_key, None)
_LOGIN_ATTEMPTS_BY_USER.pop(user_key, None)
def _enforce_login_rate_limit(request: Request, username: str) -> None:
now = time.monotonic()
window = max(int(settings.auth_rate_limit_window_seconds or 60), 1)
max_ip = max(int(settings.auth_rate_limit_max_attempts_ip or 20), 1)
max_user = max(int(settings.auth_rate_limit_max_attempts_user or 10), 1)
ip_key = _auth_client_ip(request)
user_key = _login_rate_key_user(username)
with _LOGIN_RATE_LOCK:
ip_bucket = _LOGIN_ATTEMPTS_BY_IP[ip_key]
user_bucket = _LOGIN_ATTEMPTS_BY_USER[user_key]
_prune_attempts(ip_bucket, now, window)
_prune_attempts(user_bucket, now, window)
exceeded = len(ip_bucket) >= max_ip or len(user_bucket) >= max_user
retry_after = 1
if exceeded:
retry_candidates = []
if ip_bucket:
retry_candidates.append(max(1, int(window - (now - ip_bucket[0]))))
if user_bucket:
retry_candidates.append(max(1, int(window - (now - user_bucket[0]))))
if retry_candidates:
retry_after = max(retry_candidates)
if exceeded:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again shortly.",
headers={"Retry-After": str(retry_after)},
)
def _normalize_username(value: str) -> str: def _normalize_username(value: str) -> str:
@@ -361,9 +445,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login") @router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
if not user: if not user:
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("auth_provider") != "local": if user.get("auth_provider") != "local":
raise HTTPException( raise HTTPException(
@@ -372,6 +458,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
) )
_assert_user_can_login(user) _assert_user_can_login(user)
token = create_access_token(user["username"], user["role"]) token = create_access_token(user["username"], user["role"])
_clear_login_failures(request, form_data.username)
set_last_login(user["username"]) set_last_login(user["username"])
return { return {
"access_token": token, "access_token": token,
@@ -381,7 +468,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@router.post("/jellyfin/login") @router.post("/jellyfin/login")
async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not client.configured(): if not client.configured():
@@ -394,6 +482,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
_assert_user_can_login(user) _assert_user_can_login(user)
if user and _has_valid_jellyfin_cache(user, password): if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user") token = create_access_token(username, "user")
_clear_login_failures(request, username)
set_last_login(username) set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
try: try:
@@ -401,6 +490,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"): if not isinstance(response, dict) or not response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username) user = get_user_by_username(username)
@@ -423,12 +513,14 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
if matched_id is not None: if matched_id is not None:
set_user_jellyseerr_id(username, matched_id) set_user_jellyseerr_id(username, matched_id)
token = create_access_token(username, "user") token = create_access_token(username, "user")
_clear_login_failures(request, username)
set_last_login(username) set_last_login(username)
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
@router.post("/jellyseerr/login") @router.post("/jellyseerr/login")
async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
_enforce_login_rate_limit(request, form_data.username)
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured(): if not client.configured():
@@ -439,6 +531,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
except Exception as exc: except Exception as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict): if not isinstance(response, dict):
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response) jellyseerr_user_id = _extract_jellyseerr_user_id(response)
create_user_if_missing( create_user_if_missing(
@@ -453,6 +546,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
if jellyseerr_user_id is not None: if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user") token = create_access_token(form_data.username, "user")
_clear_login_failures(request, form_data.username)
set_last_login(form_data.username) set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
@@ -462,6 +556,20 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user return current_user
@router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token(
current_user["username"],
current_user["role"],
expires_seconds=STREAM_TOKEN_TTL_SECONDS,
)
return {
"stream_token": token,
"token_type": "bearer",
"expires_in": STREAM_TOKEN_TTL_SECONDS,
}
@router.get("/invites/{code}") @router.get("/invites/{code}")
async def invite_details(code: str) -> dict: async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip()) invite = get_signup_invite_by_code(code.strip())

View File

@@ -11,7 +11,10 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
@router.post("") @router.post("")
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict: async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
runtime = get_runtime_settings() runtime = get_runtime_settings()
webhook_url = runtime.discord_webhook_url webhook_url = (
getattr(runtime, "magent_notify_discord_webhook_url", None)
or runtime.discord_webhook_url
)
if not webhook_url: if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured") raise HTTPException(status_code=400, detail="Discord webhook not configured")

View File

@@ -2,6 +2,8 @@ from .config import settings
from .db import get_settings_overrides from .db import get_settings_overrides
_INT_FIELDS = { _INT_FIELDS = {
"magent_application_port",
"magent_api_port",
"sonarr_quality_profile_id", "sonarr_quality_profile_id",
"radarr_quality_profile_id", "radarr_quality_profile_id",
"jwt_exp_minutes", "jwt_exp_minutes",
@@ -9,8 +11,20 @@ _INT_FIELDS = {
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_cleanup_days", "requests_cleanup_days",
"magent_notify_email_smtp_port",
} }
_BOOL_FIELDS = { _BOOL_FIELDS = {
"magent_proxy_enabled",
"magent_proxy_trust_forwarded_headers",
"magent_ssl_bind_enabled",
"magent_notify_enabled",
"magent_notify_email_enabled",
"magent_notify_email_use_tls",
"magent_notify_email_use_ssl",
"magent_notify_discord_enabled",
"magent_notify_telegram_enabled",
"magent_notify_push_enabled",
"magent_notify_webhook_enabled",
"jellyfin_sync_to_arr", "jellyfin_sync_to_arr",
"site_banner_enabled", "site_banner_enabled",
} }

View File

@@ -18,11 +18,30 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
return _pwd_context.verify(plain_password, hashed_password) return _pwd_context.verify(plain_password, hashed_password)
def _create_token(
subject: str,
role: str,
*,
expires_at: datetime,
token_type: str = "access",
) -> str:
payload: Dict[str, Any] = {
"sub": subject,
"role": role,
"typ": token_type,
"exp": expires_at,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
minutes = expires_minutes or settings.jwt_exp_minutes minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires} return _create_token(subject, role, expires_at=expires, token_type="access")
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_stream_token(subject: str, role: str, expires_seconds: int = 120) -> str:
expires = datetime.now(timezone.utc) + timedelta(seconds=max(30, int(expires_seconds or 120)))
return _create_token(subject, role, expires_at=expires, token_type="sse")
def decode_token(token: str) -> Dict[str, Any]: def decode_token(token: str) -> Dict[str, Any]:

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type AdminSetting = { type AdminSetting = {
@@ -19,6 +19,7 @@ type ServiceOptions = {
} }
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
jellyseerr: 'Jellyseerr', jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
artwork: 'Artwork cache', artwork: 'Artwork cache',
@@ -32,9 +33,34 @@ const SECTION_LABELS: Record<string, string> = {
site: 'Site', site: 'Site',
} }
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) const BOOL_SETTINGS = new Set([
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) 'jellyfin_sync_to_arr',
'site_banner_enabled',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
'magent_notify_enabled',
'magent_notify_email_enabled',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
'magent_notify_discord_enabled',
'magent_notify_telegram_enabled',
'magent_notify_push_enabled',
'magent_notify_webhook_enabled',
])
const TEXTAREA_SETTINGS = new Set([
'site_banner_message',
'site_changelog',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
])
const URL_SETTINGS = new Set([ const URL_SETTINGS = new Set([
'magent_application_url',
'magent_api_url',
'magent_proxy_base_url',
'magent_notify_discord_webhook_url',
'magent_notify_push_base_url',
'magent_notify_webhook_url',
'jellyseerr_base_url', 'jellyseerr_base_url',
'jellyfin_base_url', 'jellyfin_base_url',
'jellyfin_public_url', 'jellyfin_public_url',
@@ -43,9 +69,20 @@ const URL_SETTINGS = new Set([
'prowlarr_base_url', 'prowlarr_base_url',
'qbittorrent_base_url', 'qbittorrent_base_url',
]) ])
const NUMBER_SETTINGS = new Set([
'magent_application_port',
'magent_api_port',
'magent_notify_email_smtp_port',
'requests_sync_ttl_minutes',
'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_cleanup_days',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
magent:
'Application-level Magent settings including proxy, binding, TLS, and notification channels.',
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.', jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.', artwork: 'Cache posters/backdrops and review artwork coverage.',
@@ -60,6 +97,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
} }
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent',
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
artwork: null, artwork: null,
@@ -74,7 +112,151 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
site: 'site', site: 'site',
} }
const MAGENT_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'magent-runtime',
title: 'Application',
description:
'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
keys: [
'magent_application_url',
'magent_application_port',
'magent_api_url',
'magent_api_port',
'magent_bind_host',
],
},
{
key: 'magent-proxy',
title: 'Proxy',
description:
'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
keys: [
'magent_proxy_enabled',
'magent_proxy_base_url',
'magent_proxy_trust_forwarded_headers',
'magent_proxy_forwarded_prefix',
],
},
{
key: 'magent-ssl',
title: 'Manual SSL Bind',
description:
'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
keys: [
'magent_ssl_bind_enabled',
'magent_ssl_certificate_path',
'magent_ssl_private_key_path',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
],
},
{
key: 'magent-notify-core',
title: 'Notifications',
description:
'Global notification controls and provider-independent defaults used by Magent messaging features.',
keys: ['magent_notify_enabled'],
},
{
key: 'magent-notify-email',
title: 'Email',
description: 'SMTP configuration for email notifications.',
keys: [
'magent_notify_email_enabled',
'magent_notify_email_smtp_host',
'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username',
'magent_notify_email_smtp_password',
'magent_notify_email_from_address',
'magent_notify_email_from_name',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
],
},
{
key: 'magent-notify-discord',
title: 'Discord',
description: 'Webhook settings for Discord notifications and feedback routing.',
keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
},
{
key: 'magent-notify-telegram',
title: 'Telegram',
description: 'Bot token and chat target for Telegram notifications.',
keys: [
'magent_notify_telegram_enabled',
'magent_notify_telegram_bot_token',
'magent_notify_telegram_chat_id',
],
},
{
key: 'magent-notify-push',
title: 'Push / Mobile',
description:
'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
keys: [
'magent_notify_push_enabled',
'magent_notify_push_provider',
'magent_notify_push_base_url',
'magent_notify_push_topic',
'magent_notify_push_token',
'magent_notify_push_user_key',
'magent_notify_push_device',
'magent_notify_webhook_enabled',
'magent_notify_webhook_url',
],
},
]
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
magent_application_url: 'Application URL',
magent_application_port: 'Application port',
magent_api_url: 'API URL',
magent_api_port: 'API port',
magent_bind_host: 'Bind host',
magent_proxy_enabled: 'Proxy support enabled',
magent_proxy_base_url: 'Proxy base URL',
magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
magent_proxy_forwarded_prefix: 'Forwarded path prefix',
magent_ssl_bind_enabled: 'Manual SSL bind enabled',
magent_ssl_certificate_path: 'Certificate path',
magent_ssl_private_key_path: 'Private key path',
magent_ssl_certificate_pem: 'Certificate (PEM)',
magent_ssl_private_key_pem: 'Private key (PEM)',
magent_notify_enabled: 'Notifications enabled',
magent_notify_email_enabled: 'Email notifications enabled',
magent_notify_email_smtp_host: 'SMTP host',
magent_notify_email_smtp_port: 'SMTP port',
magent_notify_email_smtp_username: 'SMTP username',
magent_notify_email_smtp_password: 'SMTP password',
magent_notify_email_from_address: 'From email address',
magent_notify_email_from_name: 'From display name',
magent_notify_email_use_tls: 'Use STARTTLS',
magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
magent_notify_discord_enabled: 'Discord notifications enabled',
magent_notify_discord_webhook_url: 'Discord webhook URL',
magent_notify_telegram_enabled: 'Telegram notifications enabled',
magent_notify_telegram_bot_token: 'Telegram bot token',
magent_notify_telegram_chat_id: 'Telegram chat ID',
magent_notify_push_enabled: 'Push notifications enabled',
magent_notify_push_provider: 'Push provider',
magent_notify_push_base_url: 'Push provider/base URL',
magent_notify_push_topic: 'Topic / channel',
magent_notify_push_token: 'API token / password',
magent_notify_push_user_key: 'User key / recipient key',
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
}
const labelFromKey = (key: string) => const labelFromKey = (key: string) =>
SETTING_LABEL_OVERRIDES[key] ??
key key
.replaceAll('_', ' ') .replaceAll('_', ' ')
.replace('base url', 'URL') .replace('base url', 'URL')
@@ -115,6 +297,13 @@ type SettingsPageProps = {
section: string section: string
} }
type SettingsSectionGroup = {
key: string
title: string
items: AdminSetting[]
description?: string
}
export default function SettingsPage({ section }: SettingsPageProps) { export default function SettingsPage({ section }: SettingsPageProps) {
const router = useRouter() const router = useRouter()
const [settings, setSettings] = useState<AdminSetting[]>([]) const [settings, setSettings] = useState<AdminSetting[]>([])
@@ -308,11 +497,41 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key)) const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection const settingsSections: SettingsSectionGroup[] = isCacheSection
? [ ? [
{ key: 'cache', title: 'Cache control', items: cacheSettings }, { key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings }, { key: 'artwork', title: 'Artwork cache', items: artworkSettings },
] ]
: section === 'magent'
? (() => {
const magentItems = groupedSettings.magent ?? []
const byKey = new Map(magentItems.map((item) => [item.key, item]))
const used = new Set<string>()
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => {
const items = group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item))
for (const item of items) {
used.add(item.key)
}
return {
key: group.key,
title: group.title,
description: group.description,
items,
}
})
const remaining = magentItems.filter((item) => !used.has(item.key))
if (remaining.length) {
groups.push({
key: 'magent-other',
title: 'Additional Magent settings',
description: 'Uncategorized Magent settings.',
items: remaining,
})
}
return groups
})()
: visibleSections.map((sectionKey) => ({ : visibleSections.map((sectionKey) => ({
key: sectionKey, key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey,
@@ -350,6 +569,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch]) }, [artworkPrefetch])
const settingDescriptions: Record<string, string> = { const settingDescriptions: Record<string, string> = {
magent_application_url:
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
magent_application_port:
'Preferred frontend/UI port for local or direct-hosted deployments.',
magent_api_url:
'Canonical public URL for the Magent API when it differs from the app URL.',
magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
magent_bind_host:
'Host/IP to bind the application services to when running without an external process manager.',
magent_proxy_enabled:
'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
magent_proxy_base_url:
'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
magent_proxy_trust_forwarded_headers:
'Trust X-Forwarded-* headers from your reverse proxy.',
magent_proxy_forwarded_prefix:
'Optional path prefix added by your proxy (example: /magent).',
magent_ssl_bind_enabled:
'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
magent_ssl_certificate_path:
'Path to the TLS certificate file on disk (PEM).',
magent_ssl_private_key_path:
'Path to the TLS private key file on disk (PEM).',
magent_ssl_certificate_pem:
'Paste the TLS certificate PEM if you want Magent to store it directly.',
magent_ssl_private_key_pem:
'Paste the TLS private key PEM if you want Magent to store it directly.',
magent_notify_enabled:
'Master switch for Magent notifications. Individual provider toggles still apply.',
magent_notify_email_enabled: 'Enable SMTP email notifications.',
magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
magent_notify_email_smtp_username: 'SMTP account username.',
magent_notify_email_smtp_password: 'SMTP account password or app password.',
magent_notify_email_from_address: 'Sender email address used by Magent.',
magent_notify_email_from_name: 'Sender display name shown to recipients.',
magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
magent_notify_discord_webhook_url:
'Discord channel webhook URL used for notifications and optional feedback routing.',
magent_notify_telegram_enabled: 'Enable Telegram notifications.',
magent_notify_telegram_bot_token: 'Bot token from BotFather.',
magent_notify_telegram_chat_id:
'Default Telegram chat/group/user ID for notifications.',
magent_notify_push_enabled: 'Enable generic push notifications.',
magent_notify_push_provider:
'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
magent_notify_push_base_url:
'Base URL for your push provider (for example ntfy/gotify server URL).',
magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
magent_notify_push_token: 'Provider token/API key/password.',
magent_notify_push_user_key:
'Provider recipient key/user key (for example Pushover user key).',
magent_notify_push_device:
'Optional device or target override, depending on provider.',
magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
magent_notify_webhook_url:
'Generic webhook endpoint for custom integrations or automation flows.',
jellyseerr_base_url: jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.', 'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.', jellyseerr_api_key: 'API key used to read requests and status.',
@@ -397,6 +675,29 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const settingPlaceholders: Record<string, string> = { const settingPlaceholders: Record<string, string> = {
magent_application_url: 'https://magent.example.com',
magent_application_port: '3000',
magent_api_url: 'https://api.example.com or https://magent.example.com/api',
magent_api_port: '8000',
magent_bind_host: '0.0.0.0',
magent_proxy_base_url: 'https://proxy.example.com/magent',
magent_proxy_forwarded_prefix: '/magent',
magent_ssl_certificate_path: '/certs/fullchain.pem',
magent_ssl_private_key_path: '/certs/privkey.pem',
magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
magent_notify_email_smtp_host: 'smtp.office365.com',
magent_notify_email_smtp_port: '587',
magent_notify_email_smtp_username: 'notifications@example.com',
magent_notify_email_from_address: 'notifications@example.com',
magent_notify_email_from_name: 'Magent',
magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
magent_notify_telegram_bot_token: '123456789:AA...',
magent_notify_telegram_chat_id: '-1001234567890',
magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
magent_notify_push_topic: 'magent-alerts',
magent_notify_push_device: 'iphone-zak',
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055', jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096', jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com', jellyfin_public_url: 'https://jelly.example.com',
@@ -599,15 +900,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
const baseUrl = getApiBase() const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('access_token', token) params.set('stream_token', streamToken)
if (showLogs) { if (showLogs) {
params.set('include_logs', '1') params.set('include_logs', '1')
params.set('log_lines', String(logsCount)) params.set('log_lines', String(logsCount))
} }
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}` const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false source = new EventSource(streamUrl)
const source = new EventSource(streamUrl)
source.onopen = () => { source.onopen = () => {
if (closed) return if (closed) return
@@ -631,7 +938,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const prevSync = requestsSyncRef.current const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync requestsSyncRef.current = nextSync
setRequestsSync(nextSync) setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') { if (
prevSync?.status === 'running' &&
nextSync?.status &&
nextSync.status !== 'running'
) {
setRequestsSyncStatus(nextSync.message || 'Sync complete.') setRequestsSyncStatus(nextSync.message || 'Sync complete.')
} }
@@ -671,11 +982,19 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (closed) return if (closed) return
setLiveStreamConnected(false) setLiveStreamConnected(false)
} }
} catch (err) {
if (closed) return
console.error(err)
setLiveStreamConnected(false)
}
}
void connect()
return () => { return () => {
closed = true closed = true
setLiveStreamConnected(false) setLiveStreamConnected(false)
source.close() source?.close()
} }
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras]) }, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
@@ -1000,8 +1319,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</div> </div>
)} )}
</div> </div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && ( {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p> (!settingsSection || section === 'magent') && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
)}
{section === 'magent' && sectionGroup.key === 'magent-runtime' && (
<div className="status-banner">
Runtime host/port and SSL values are configuration settings. Container/process
restarts may still be required before bind/port changes take effect.
</div>
)} )}
{sectionGroup.key === 'sonarr' && sonarrError && ( {sectionGroup.key === 'sonarr' && sonarrError && (
<div className="error-banner">{sonarrError}</div> <div className="error-banner">{sonarrError}</div>
@@ -1339,6 +1667,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if (setting.key === 'magent_notify_push_provider') {
return (
<label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row">
<span>{labelFromKey(setting.key)}</span>
<span className="meta">
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
</span>
</span>
<select
name={setting.key}
value={value || 'ntfy'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="ntfy">ntfy</option>
<option value="gotify">Gotify</option>
<option value="pushover">Pushover</option>
<option value="webhook">Webhook</option>
<option value="telegram">Telegram relay</option>
<option value="discord">Discord relay</option>
</select>
</label>
)
}
if ( if (
setting.key === 'requests_full_sync_time' || setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time' setting.key === 'requests_cleanup_time'
@@ -1365,10 +1722,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </label>
) )
} }
if ( if (NUMBER_SETTINGS.has(setting.key)) {
setting.key === 'requests_delta_sync_interval_minutes' ||
setting.key === 'requests_cleanup_days'
) {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
<span className="label-row"> <span className="label-row">
@@ -1381,6 +1735,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
name={setting.key} name={setting.key}
type="number" type="number"
min={1} min={1}
step={1}
value={value} value={value}
onChange={(event) => onChange={(event) =>
setFormValues((current) => ({ setFormValues((current) => ({
@@ -1420,8 +1775,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) )
} }
if (TEXTAREA_SETTINGS.has(setting.key)) { if (TEXTAREA_SETTINGS.has(setting.key)) {
const isPemField =
setting.key === 'magent_ssl_certificate_pem' ||
setting.key === 'magent_ssl_private_key_pem'
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label
key={setting.key}
data-helper={helperText || undefined}
className={isPemField ? 'field-span-full' : undefined}
>
<span className="label-row"> <span className="label-row">
<span>{labelFromKey(setting.key)}</span> <span>{labelFromKey(setting.key)}</span>
<span className="meta"> <span className="meta">
@@ -1431,11 +1793,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</span> </span>
<textarea <textarea
name={setting.key} name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3} rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
placeholder={ placeholder={
setting.key === 'site_changelog' setting.key === 'site_changelog'
? 'One update per line.' ? 'One update per line.'
: '' : settingPlaceholders[setting.key] ?? ''
} }
value={value} value={value}
onChange={(event) => onChange={(event) =>

View File

@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
'cache', 'cache',
'logs', 'logs',
'maintenance', 'maintenance',
'magent',
'site', 'site',
]) ])

View File

@@ -4975,6 +4975,10 @@ textarea {
} }
} }
.admin-grid label.field-span-full {
grid-column: 1 / -1;
}
/* Final header account menu stacking override (must be last) */ /* Final header account menu stacking override (must be last) */
.page, .page,
.header, .header,

View File

@@ -23,3 +23,18 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
} }
return fetch(input, { ...init, headers }) return fetch(input, { ...init, headers })
} }
export const getEventStreamToken = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/stream-token`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || `Stream token request failed: ${response.status}`)
}
const data = await response.json()
const token = typeof data?.stream_token === 'string' ? data.stream_token : ''
if (!token) {
throw new Error('Stream token not returned')
}
return token
}

View File

@@ -2,7 +2,7 @@
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth' import { authFetch, getApiBase, getToken, clearToken, getEventStreamToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) => const normalizeRecentResults = (items: any[]) =>
items items
@@ -210,15 +210,20 @@ export default function HomePage() {
setLiveStreamConnected(false) setLiveStreamConnected(false)
return return
} }
const token = getToken() if (!getToken()) {
if (!token) {
setLiveStreamConnected(false) setLiveStreamConnected(false)
return return
} }
const baseUrl = getApiBase() const baseUrl = getApiBase()
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
let closed = false let closed = false
const source = new EventSource(streamUrl) let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
source = new EventSource(streamUrl)
source.onopen = () => { source.onopen = () => {
if (closed) return if (closed) return
@@ -263,11 +268,19 @@ export default function HomePage() {
if (closed) return if (closed) return
setLiveStreamConnected(false) setLiveStreamConnected(false)
} }
} catch (error) {
if (closed) return
console.error(error)
setLiveStreamConnected(false)
}
}
void connect()
return () => { return () => {
closed = true closed = true
setLiveStreamConnected(false) setLiveStreamConnected(false)
source.close() source?.close()
} }
}, [authReady, recentDays]) }, [authReady, recentDays])

View File

@@ -25,6 +25,7 @@ const NAV_GROUPS = [
{ {
title: 'Admin', title: 'Admin',
items: [ items: [
{ href: '/admin/magent', label: 'Magent' },
{ href: '/admin/site', label: 'Site' }, { href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' }, { href: '/admin/invites', label: 'Invite management' },