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"
|
||||
|
||||
|
||||
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:
|
||||
payload = safe_decode_token(token)
|
||||
except TokenError as 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")
|
||||
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]:
|
||||
"""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
|
||||
stream_query_token = None
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.lower().startswith("bearer "):
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
token = request.query_params.get("access_token")
|
||||
if not token:
|
||||
stream_query_token = request.query_params.get("stream_token")
|
||||
if not token and not stream_query_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]:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -11,6 +11,16 @@ class Settings(BaseSettings):
|
||||
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_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_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
|
||||
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)
|
||||
|
||||
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(
|
||||
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import settings
|
||||
@@ -24,7 +24,12 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||
from .logging_config import configure_logging
|
||||
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(
|
||||
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")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -90,6 +90,14 @@ logger = logging.getLogger(__name__)
|
||||
SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id"
|
||||
|
||||
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",
|
||||
"jellyfin_api_key",
|
||||
"sonarr_api_key",
|
||||
@@ -99,6 +107,11 @@ SENSITIVE_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",
|
||||
"jellyfin_base_url",
|
||||
"jellyfin_public_url",
|
||||
@@ -109,6 +122,44 @@ URL_SETTING_KEYS = {
|
||||
}
|
||||
|
||||
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_api_key",
|
||||
"jellyfin_base_url",
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from collections import defaultdict, deque
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from threading import Lock
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
|
||||
from ..db import (
|
||||
@@ -34,7 +37,9 @@ from ..runtime import get_runtime_settings
|
||||
from ..clients.jellyfin import JellyfinClient
|
||||
from ..clients.jellyseerr import JellyseerrClient
|
||||
from ..security import create_access_token, verify_password
|
||||
from ..security import create_stream_token
|
||||
from ..auth import get_current_user
|
||||
from ..config import settings
|
||||
from ..services.user_cache import (
|
||||
build_jellyseerr_candidate_map,
|
||||
get_cached_jellyseerr_users,
|
||||
@@ -44,6 +49,85 @@ from ..services.user_cache import (
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
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:
|
||||
@@ -361,9 +445,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
|
||||
|
||||
|
||||
@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)
|
||||
if not user:
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
if user.get("auth_provider") != "local":
|
||||
raise HTTPException(
|
||||
@@ -372,6 +458,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
)
|
||||
_assert_user_can_login(user)
|
||||
token = create_access_token(user["username"], user["role"])
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(user["username"])
|
||||
return {
|
||||
"access_token": token,
|
||||
@@ -381,7 +468,8 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
|
||||
|
||||
|
||||
@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()
|
||||
client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
|
||||
if not client.configured():
|
||||
@@ -394,6 +482,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
|
||||
_assert_user_can_login(user)
|
||||
if user and _has_valid_jellyfin_cache(user, password):
|
||||
token = create_access_token(username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
||||
try:
|
||||
@@ -401,6 +490,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
|
||||
except Exception as 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"):
|
||||
_record_login_failure(request, username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
|
||||
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
|
||||
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:
|
||||
set_user_jellyseerr_id(username, matched_id)
|
||||
token = create_access_token(username, "user")
|
||||
_clear_login_failures(request, username)
|
||||
set_last_login(username)
|
||||
return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}}
|
||||
|
||||
|
||||
@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()
|
||||
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if not client.configured():
|
||||
@@ -439,6 +531,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
||||
if not isinstance(response, dict):
|
||||
_record_login_failure(request, form_data.username)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
|
||||
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
|
||||
create_user_if_missing(
|
||||
@@ -453,6 +546,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
|
||||
if jellyseerr_user_id is not None:
|
||||
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
|
||||
token = create_access_token(form_data.username, "user")
|
||||
_clear_login_failures(request, form_data.username)
|
||||
set_last_login(form_data.username)
|
||||
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
|
||||
|
||||
|
||||
@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}")
|
||||
async def invite_details(code: str) -> dict:
|
||||
invite = get_signup_invite_by_code(code.strip())
|
||||
|
||||
@@ -11,7 +11,10 @@ router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(
|
||||
@router.post("")
|
||||
async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(get_current_user)) -> dict:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Discord webhook not configured")
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ from .config import settings
|
||||
from .db import get_settings_overrides
|
||||
|
||||
_INT_FIELDS = {
|
||||
"magent_application_port",
|
||||
"magent_api_port",
|
||||
"sonarr_quality_profile_id",
|
||||
"radarr_quality_profile_id",
|
||||
"jwt_exp_minutes",
|
||||
@@ -9,8 +11,20 @@ _INT_FIELDS = {
|
||||
"requests_poll_interval_seconds",
|
||||
"requests_delta_sync_interval_minutes",
|
||||
"requests_cleanup_days",
|
||||
"magent_notify_email_smtp_port",
|
||||
}
|
||||
_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",
|
||||
"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)
|
||||
|
||||
|
||||
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:
|
||||
minutes = expires_minutes or settings.jwt_exp_minutes
|
||||
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
|
||||
payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
|
||||
return _create_token(subject, role, expires_at=expires, token_type="access")
|
||||
|
||||
|
||||
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]:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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'
|
||||
|
||||
type AdminSetting = {
|
||||
@@ -19,6 +19,7 @@ type ServiceOptions = {
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
magent: 'Magent',
|
||||
jellyseerr: 'Jellyseerr',
|
||||
jellyfin: 'Jellyfin',
|
||||
artwork: 'Artwork cache',
|
||||
@@ -32,9 +33,34 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
site: 'Site',
|
||||
}
|
||||
|
||||
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
|
||||
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
|
||||
const BOOL_SETTINGS = new Set([
|
||||
'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([
|
||||
'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',
|
||||
'jellyfin_base_url',
|
||||
'jellyfin_public_url',
|
||||
@@ -43,9 +69,20 @@ const URL_SETTINGS = new Set([
|
||||
'prowlarr_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 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.',
|
||||
jellyfin: 'Control Jellyfin login and availability checks.',
|
||||
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> = {
|
||||
magent: 'magent',
|
||||
jellyseerr: 'jellyseerr',
|
||||
jellyfin: 'jellyfin',
|
||||
artwork: null,
|
||||
@@ -74,7 +112,151 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
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) =>
|
||||
SETTING_LABEL_OVERRIDES[key] ??
|
||||
key
|
||||
.replaceAll('_', ' ')
|
||||
.replace('base url', 'URL')
|
||||
@@ -115,6 +297,13 @@ type SettingsPageProps = {
|
||||
section: string
|
||||
}
|
||||
|
||||
type SettingsSectionGroup = {
|
||||
key: string
|
||||
title: string
|
||||
items: AdminSetting[]
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const router = useRouter()
|
||||
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 artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
||||
const settingsSections = isCacheSection
|
||||
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||||
? [
|
||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||||
]
|
||||
: 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)
|
||||
: 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 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 showMaintenance = section === 'maintenance'
|
||||
const showRequestsExtras = section === 'requests'
|
||||
@@ -350,6 +569,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [artworkPrefetch])
|
||||
|
||||
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:
|
||||
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
|
||||
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> = {
|
||||
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',
|
||||
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
|
||||
jellyfin_public_url: 'https://jelly.example.com',
|
||||
@@ -599,83 +900,101 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}
|
||||
|
||||
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
|
||||
const source = new EventSource(streamUrl)
|
||||
let source: EventSource | null = null
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
const connect = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || payload.type !== 'admin_live_state') {
|
||||
return
|
||||
const streamToken = await getEventStreamToken()
|
||||
if (closed) 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 =
|
||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||
? payload.requestsSync
|
||||
: null
|
||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||
const prevSync = requestsSyncRef.current
|
||||
requestsSyncRef.current = nextSync
|
||||
setRequestsSync(nextSync)
|
||||
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
|
||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||
}
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || payload.type !== 'admin_live_state') {
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
const rawSync =
|
||||
payload.requestsSync && typeof payload.requestsSync === 'object'
|
||||
? payload.requestsSync
|
||||
: null
|
||||
const nextSync = rawSync?.status === 'idle' ? null : rawSync
|
||||
const prevSync = requestsSyncRef.current
|
||||
requestsSyncRef.current = nextSync
|
||||
setRequestsSync(nextSync)
|
||||
if (
|
||||
prevSync?.status === 'running' &&
|
||||
nextSync?.status &&
|
||||
nextSync.status !== 'running'
|
||||
) {
|
||||
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
|
||||
}
|
||||
|
||||
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') {
|
||||
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)
|
||||
}
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed) return
|
||||
console.error(err)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
source?.close()
|
||||
}
|
||||
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
|
||||
|
||||
@@ -1000,8 +1319,17 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
|
||||
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
|
||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||||
(!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 && (
|
||||
<div className="error-banner">{sonarrError}</div>
|
||||
@@ -1339,6 +1667,35 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</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 (
|
||||
setting.key === 'requests_full_sync_time' ||
|
||||
setting.key === 'requests_cleanup_time'
|
||||
@@ -1365,10 +1722,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (
|
||||
setting.key === 'requests_delta_sync_interval_minutes' ||
|
||||
setting.key === 'requests_cleanup_days'
|
||||
) {
|
||||
if (NUMBER_SETTINGS.has(setting.key)) {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
@@ -1381,6 +1735,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
name={setting.key}
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
@@ -1420,8 +1775,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
)
|
||||
}
|
||||
if (TEXTAREA_SETTINGS.has(setting.key)) {
|
||||
const isPemField =
|
||||
setting.key === 'magent_ssl_certificate_pem' ||
|
||||
setting.key === 'magent_ssl_private_key_pem'
|
||||
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>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
@@ -1431,11 +1793,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</span>
|
||||
<textarea
|
||||
name={setting.key}
|
||||
rows={setting.key === 'site_changelog' ? 6 : 3}
|
||||
rows={setting.key === 'site_changelog' ? 6 : isPemField ? 8 : 3}
|
||||
placeholder={
|
||||
setting.key === 'site_changelog'
|
||||
? 'One update per line.'
|
||||
: ''
|
||||
: settingPlaceholders[setting.key] ?? ''
|
||||
}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
|
||||
@@ -13,6 +13,7 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
'magent',
|
||||
'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) */
|
||||
.page,
|
||||
.header,
|
||||
|
||||
@@ -23,3 +23,18 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
}
|
||||
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 { 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[]) =>
|
||||
items
|
||||
@@ -210,64 +210,77 @@ export default function HomePage() {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const token = getToken()
|
||||
if (!token) {
|
||||
if (!getToken()) {
|
||||
setLiveStreamConnected(false)
|
||||
return
|
||||
}
|
||||
const baseUrl = getApiBase()
|
||||
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
|
||||
let closed = false
|
||||
const source = new EventSource(streamUrl)
|
||||
let source: EventSource | null = null
|
||||
|
||||
source.onopen = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
const connect = async () => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return
|
||||
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 = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
}
|
||||
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)
|
||||
|
||||
source.onmessage = (event) => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(true)
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
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') {
|
||||
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)
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
} catch (error) {
|
||||
if (closed) return
|
||||
console.error(error)
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
if (closed) return
|
||||
setLiveStreamConnected(false)
|
||||
}
|
||||
void connect()
|
||||
|
||||
return () => {
|
||||
closed = true
|
||||
setLiveStreamConnected(false)
|
||||
source.close()
|
||||
source?.close()
|
||||
}
|
||||
}, [authReady, recentDays])
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Admin',
|
||||
items: [
|
||||
{ href: '/admin/magent', label: 'Magent' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
|
||||
Reference in New Issue
Block a user