feat: customize site banners and login notices
This commit is contained in:
@@ -1,9 +1,20 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from .build_info import BUILD_NUMBER, CHANGELOG
|
||||
|
||||
|
||||
_BANNER_COLOR_PATTERN = re.compile(r"^#[0-9a-f]{6}$")
|
||||
|
||||
|
||||
def normalize_banner_color(value: object) -> Optional[str]:
|
||||
color = str(value or "").strip().lower()
|
||||
return color if _BANNER_COLOR_PATTERN.fullmatch(color) else None
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_prefix="")
|
||||
app_name: str = "Magent"
|
||||
@@ -108,6 +119,15 @@ class Settings(BaseSettings):
|
||||
site_banner_tone: str = Field(
|
||||
default="info", validation_alias=AliasChoices("SITE_BANNER_TONE")
|
||||
)
|
||||
site_banner_background_color: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BANNER_BACKGROUND_COLOR")
|
||||
)
|
||||
site_banner_border_color: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_BANNER_BORDER_COLOR")
|
||||
)
|
||||
site_login_message: Optional[str] = Field(
|
||||
default=None, validation_alias=AliasChoices("SITE_LOGIN_MESSAGE")
|
||||
)
|
||||
site_login_show_jellyfin_login: bool = Field(
|
||||
default=True, validation_alias=AliasChoices("SITE_LOGIN_SHOW_JELLYFIN_LOGIN")
|
||||
)
|
||||
|
||||
@@ -20,7 +20,7 @@ from ..auth import (
|
||||
normalize_user_auth_provider,
|
||||
resolve_user_auth_provider,
|
||||
)
|
||||
from ..config import settings as env_settings
|
||||
from ..config import normalize_banner_color, settings as env_settings
|
||||
from ..network_security import validate_notification_target_url
|
||||
from ..db import (
|
||||
delete_setting,
|
||||
@@ -174,6 +174,11 @@ NOTIFICATION_URL_SETTING_KEYS = {
|
||||
"magent_notify_webhook_url",
|
||||
}
|
||||
|
||||
BANNER_COLOR_SETTING_KEYS = {
|
||||
"site_banner_background_color",
|
||||
"site_banner_border_color",
|
||||
}
|
||||
|
||||
SETTING_KEYS: List[str] = [
|
||||
"jellystat_base_url",
|
||||
"jellystat_api_key",
|
||||
@@ -260,6 +265,9 @@ SETTING_KEYS: List[str] = [
|
||||
"site_banner_enabled",
|
||||
"site_banner_message",
|
||||
"site_banner_tone",
|
||||
"site_banner_background_color",
|
||||
"site_banner_border_color",
|
||||
"site_login_message",
|
||||
"site_login_show_jellyfin_login",
|
||||
"site_login_show_local_login",
|
||||
"site_login_show_forgot_password",
|
||||
@@ -712,6 +720,11 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
value_to_store = value_to_store.lower()
|
||||
if value_to_store not in {"days", "weeks", "months"}:
|
||||
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
|
||||
if key in BANNER_COLOR_SETTING_KEYS:
|
||||
normalized_color = normalize_banner_color(value_to_store)
|
||||
if not normalized_color:
|
||||
raise HTTPException(status_code=400, detail=f"{key.replace('_', ' ')} must be a six-digit hex colour such as #ffc857")
|
||||
value_to_store = normalized_color
|
||||
if key in URL_SETTING_KEYS and value_to_store:
|
||||
try:
|
||||
value_to_store = _normalize_service_url(value_to_store)
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..build_info import BUILD_NUMBER, CHANGELOG
|
||||
from ..config import normalize_banner_color
|
||||
from ..runtime import get_runtime_settings
|
||||
|
||||
router = APIRouter(prefix="/site", tags=["site"])
|
||||
@@ -15,6 +16,7 @@ _BANNER_TONES = {"info", "warning", "error", "maintenance"}
|
||||
def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
runtime = get_runtime_settings()
|
||||
banner_message = (runtime.site_banner_message or "").strip()
|
||||
login_message = (runtime.site_login_message or "").strip()
|
||||
tone = (runtime.site_banner_tone or "info").strip().lower()
|
||||
if tone not in _BANNER_TONES:
|
||||
tone = "info"
|
||||
@@ -24,8 +26,11 @@ def _build_site_info(include_changelog: bool) -> Dict[str, Any]:
|
||||
"enabled": bool(runtime.site_banner_enabled and banner_message),
|
||||
"message": banner_message,
|
||||
"tone": tone,
|
||||
"backgroundColor": normalize_banner_color(runtime.site_banner_background_color),
|
||||
"borderColor": normalize_banner_color(runtime.site_banner_border_color),
|
||||
},
|
||||
"login": {
|
||||
"message": login_message,
|
||||
"showJellyfinLogin": bool(runtime.site_login_show_jellyfin_login),
|
||||
"showLocalLogin": bool(runtime.site_login_show_local_login),
|
||||
"showForgotPassword": bool(runtime.site_login_show_forgot_password),
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
from types import SimpleNamespace
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
@@ -298,6 +298,9 @@ class SiteInfoTests(unittest.TestCase):
|
||||
site_banner_enabled=False,
|
||||
site_banner_message="",
|
||||
site_banner_tone="info",
|
||||
site_banner_background_color=None,
|
||||
site_banner_border_color=None,
|
||||
site_login_message="",
|
||||
site_login_show_jellyfin_login=True,
|
||||
site_login_show_local_login=True,
|
||||
site_login_show_forgot_password=True,
|
||||
@@ -310,6 +313,49 @@ class SiteInfoTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(info["navigation"], {"showRequests": False})
|
||||
|
||||
def test_site_public_exposes_safe_banner_colours_and_login_message(self) -> None:
|
||||
runtime = settings.model_copy(update={
|
||||
"site_banner_enabled": True,
|
||||
"site_banner_message": "Planned maintenance",
|
||||
"site_banner_tone": "warning",
|
||||
"site_banner_background_color": "#123ABC",
|
||||
"site_banner_border_color": "red",
|
||||
"site_login_message": "Use your Grizzlyflix account to sign in.",
|
||||
})
|
||||
|
||||
with patch.object(site_router, "get_runtime_settings", return_value=runtime):
|
||||
info = site_router._build_site_info(False)
|
||||
|
||||
self.assertEqual(info["banner"]["backgroundColor"], "#123abc")
|
||||
self.assertIsNone(info["banner"]["borderColor"])
|
||||
self.assertEqual(info["login"]["message"], "Use your Grizzlyflix account to sign in.")
|
||||
|
||||
|
||||
class SiteSettingValidationTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_banner_colours_are_normalized_before_saving(self) -> None:
|
||||
with patch.object(admin_router, "set_setting") as save:
|
||||
result = await admin_router.update_settings({
|
||||
"site_banner_background_color": "#A1B2C3",
|
||||
"site_banner_border_color": "#010203",
|
||||
})
|
||||
|
||||
self.assertEqual(result, {"status": "ok", "updated": 2})
|
||||
self.assertEqual(
|
||||
save.call_args_list,
|
||||
[
|
||||
call("site_banner_background_color", "#a1b2c3"),
|
||||
call("site_banner_border_color", "#010203"),
|
||||
],
|
||||
)
|
||||
|
||||
async def test_banner_colours_reject_unsafe_css_values(self) -> None:
|
||||
with self.assertRaises(HTTPException) as raised:
|
||||
await admin_router.update_settings({
|
||||
"site_banner_border_color": "red; background: url(example)",
|
||||
})
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 400)
|
||||
|
||||
|
||||
class RequestCacheTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user