Build 2602262030: add magent settings and hardening
This commit is contained in:
@@ -1 +1 @@
|
|||||||
2602261731
|
2602262030
|
||||||
|
|||||||
@@ -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")
|
||||||
return _load_current_user_from_token(token, None)
|
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(
|
||||||
|
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]:
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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,26 +497,56 @@ 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 },
|
||||||
]
|
]
|
||||||
: visibleSections.map((sectionKey) => ({
|
: section === 'magent'
|
||||||
key: sectionKey,
|
? (() => {
|
||||||
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
const magentItems = groupedSettings.magent ?? []
|
||||||
items: (() => {
|
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
||||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
const used = new Set<string>()
|
||||||
const filtered =
|
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.map((group) => {
|
||||||
sectionKey === 'requests' || sectionKey === 'artwork'
|
const items = group.keys
|
||||||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
.map((key) => byKey.get(key))
|
||||||
: sectionItems
|
.filter((item): item is AdminSetting => Boolean(item))
|
||||||
if (sectionKey === 'requests') {
|
for (const item of items) {
|
||||||
return sortByOrder(filtered, requestSettingOrder)
|
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 filtered
|
return groups
|
||||||
})(),
|
})()
|
||||||
}))
|
: visibleSections.map((sectionKey) => ({
|
||||||
|
key: sectionKey,
|
||||||
|
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
||||||
|
items: (() => {
|
||||||
|
const sectionItems = groupedSettings[sectionKey] ?? []
|
||||||
|
const filtered =
|
||||||
|
sectionKey === 'requests' || sectionKey === 'artwork'
|
||||||
|
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||||||
|
: sectionItems
|
||||||
|
if (sectionKey === 'requests') {
|
||||||
|
return sortByOrder(filtered, requestSettingOrder)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
})(),
|
||||||
|
}))
|
||||||
const showLogs = section === 'logs'
|
const showLogs = section === 'logs'
|
||||||
const showMaintenance = section === 'maintenance'
|
const showMaintenance = section === 'maintenance'
|
||||||
const showRequestsExtras = section === 'requests'
|
const showRequestsExtras = section === 'requests'
|
||||||
@@ -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,83 +900,101 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('access_token', token)
|
|
||||||
if (showLogs) {
|
|
||||||
params.set('include_logs', '1')
|
|
||||||
params.set('log_lines', String(logsCount))
|
|
||||||
}
|
|
||||||
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const source = new EventSource(streamUrl)
|
let source: EventSource | null = null
|
||||||
|
|
||||||
source.onopen = () => {
|
const connect = async () => {
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const streamToken = await getEventStreamToken()
|
||||||
if (!payload || payload.type !== 'admin_live_state') {
|
if (closed) return
|
||||||
return
|
const params = new URLSearchParams()
|
||||||
|
params.set('stream_token', streamToken)
|
||||||
|
if (showLogs) {
|
||||||
|
params.set('include_logs', '1')
|
||||||
|
params.set('log_lines', String(logsCount))
|
||||||
|
}
|
||||||
|
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawSync =
|
source.onmessage = (event) => {
|
||||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
if (closed) return
|
||||||
? payload.requestsSync
|
setLiveStreamConnected(true)
|
||||||
: null
|
try {
|
||||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
const payload = JSON.parse(event.data)
|
||||||
const prevSync = requestsSyncRef.current
|
if (!payload || payload.type !== 'admin_live_state') {
|
||||||
requestsSyncRef.current = nextSync
|
return
|
||||||
setRequestsSync(nextSync)
|
}
|
||||||
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
|
||||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawArtwork =
|
const rawSync =
|
||||||
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||||
? payload.artworkPrefetch
|
? payload.requestsSync
|
||||||
: null
|
: null
|
||||||
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||||
const prevArtwork = artworkPrefetchRef.current
|
const prevSync = requestsSyncRef.current
|
||||||
artworkPrefetchRef.current = nextArtwork
|
requestsSyncRef.current = nextSync
|
||||||
setArtworkPrefetch(nextArtwork)
|
setRequestsSync(nextSync)
|
||||||
if (
|
if (
|
||||||
prevArtwork?.status === 'running' &&
|
prevSync?.status === 'running' &&
|
||||||
nextArtwork?.status &&
|
nextSync?.status &&
|
||||||
nextArtwork.status !== 'running'
|
nextSync.status !== 'running'
|
||||||
) {
|
) {
|
||||||
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||||
if (showArtworkExtras) {
|
}
|
||||||
void loadArtworkSummary()
|
|
||||||
|
const rawArtwork =
|
||||||
|
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
|
||||||
|
? payload.artworkPrefetch
|
||||||
|
: null
|
||||||
|
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
|
||||||
|
const prevArtwork = artworkPrefetchRef.current
|
||||||
|
artworkPrefetchRef.current = nextArtwork
|
||||||
|
setArtworkPrefetch(nextArtwork)
|
||||||
|
if (
|
||||||
|
prevArtwork?.status === 'running' &&
|
||||||
|
nextArtwork?.status &&
|
||||||
|
nextArtwork.status !== 'running'
|
||||||
|
) {
|
||||||
|
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
|
||||||
|
if (showArtworkExtras) {
|
||||||
|
void loadArtworkSummary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.logs && typeof payload.logs === 'object') {
|
||||||
|
if (Array.isArray(payload.logs.lines)) {
|
||||||
|
setLogsLines(payload.logs.lines)
|
||||||
|
setLogsStatus(null)
|
||||||
|
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
||||||
|
setLogsStatus(payload.logs.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.logs && typeof payload.logs === 'object') {
|
source.onerror = () => {
|
||||||
if (Array.isArray(payload.logs.lines)) {
|
if (closed) return
|
||||||
setLogsLines(payload.logs.lines)
|
setLiveStreamConnected(false)
|
||||||
setLogsStatus(null)
|
|
||||||
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
|
|
||||||
setLogsStatus(payload.logs.error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (closed) return
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
setLiveStreamConnected(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.onerror = () => {
|
void connect()
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
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) =>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
|
|||||||
'cache',
|
'cache',
|
||||||
'logs',
|
'logs',
|
||||||
'maintenance',
|
'maintenance',
|
||||||
|
'magent',
|
||||||
'site',
|
'site',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,64 +210,77 @@ 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
|
||||||
|
|
||||||
source.onopen = () => {
|
const connect = async () => {
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(true)
|
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const streamToken = await getEventStreamToken()
|
||||||
if (!payload || typeof payload !== 'object') {
|
if (closed) return
|
||||||
return
|
const streamUrl = `${baseUrl}/events/stream?stream_token=${encodeURIComponent(streamToken)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||||
|
source = new EventSource(streamUrl)
|
||||||
|
|
||||||
|
source.onopen = () => {
|
||||||
|
if (closed) return
|
||||||
|
setLiveStreamConnected(true)
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_recent') {
|
|
||||||
if (Array.isArray(payload.results)) {
|
source.onmessage = (event) => {
|
||||||
setRecent(normalizeRecentResults(payload.results))
|
if (closed) return
|
||||||
setRecentError(null)
|
setLiveStreamConnected(true)
|
||||||
setRecentLoading(false)
|
try {
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
const payload = JSON.parse(event.data)
|
||||||
setRecentError('Recent requests are not available right now.')
|
if (!payload || typeof payload !== 'object') {
|
||||||
setRecentLoading(false)
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_recent') {
|
||||||
|
if (Array.isArray(payload.results)) {
|
||||||
|
setRecent(normalizeRecentResults(payload.results))
|
||||||
|
setRecentError(null)
|
||||||
|
setRecentLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setRecentError('Recent requests are not available right now.')
|
||||||
|
setRecentLoading(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (payload.type === 'home_services') {
|
||||||
|
if (payload.status && typeof payload.status === 'object') {
|
||||||
|
setServicesStatus(payload.status)
|
||||||
|
setServicesError(null)
|
||||||
|
setServicesLoading(false)
|
||||||
|
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
||||||
|
setServicesError('Service status is not available right now.')
|
||||||
|
setServicesLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if (payload.type === 'home_services') {
|
|
||||||
if (payload.status && typeof payload.status === 'object') {
|
source.onerror = () => {
|
||||||
setServicesStatus(payload.status)
|
if (closed) return
|
||||||
setServicesError(null)
|
setLiveStreamConnected(false)
|
||||||
setServicesLoading(false)
|
|
||||||
} else if (typeof payload.error === 'string' && payload.error.trim()) {
|
|
||||||
setServicesError('Service status is not available right now.')
|
|
||||||
setServicesLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (closed) return
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
setLiveStreamConnected(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
source.onerror = () => {
|
void connect()
|
||||||
if (closed) return
|
|
||||||
setLiveStreamConnected(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
closed = true
|
closed = true
|
||||||
setLiveStreamConnected(false)
|
setLiveStreamConnected(false)
|
||||||
source.close()
|
source?.close()
|
||||||
}
|
}
|
||||||
}, [authReady, recentDays])
|
}, [authReady, recentDays])
|
||||||
|
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user