Files
notify-bridge/packages/server/src/notify_bridge_server/api/app_settings.py
T
alexei.dolgolyov 751097b347 feat: comprehensive code review fixes + receivers-only architecture
Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
2026-03-22 02:19:31 +03:00

124 lines
3.8 KiB
Python

"""App-level settings API (admin only)."""
import logging
import os
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/settings", tags=["settings"])
# Keys exposed to the frontend with their env-var fallbacks
_SETTING_KEYS = {
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48
}
_DEFAULTS = {
"external_url": "",
"telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48",
}
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
row = await session.get(AppSetting, key)
if row and row.value:
return row.value
env_key = _SETTING_KEYS.get(key)
if env_key:
env_val = os.environ.get(env_key, "")
if env_val:
return env_val
return _DEFAULTS.get(key, "")
class SettingsUpdate(BaseModel):
external_url: str | None = None
telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None
@router.get("")
async def get_settings(
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return all app settings."""
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
return result
@router.put("")
async def update_settings(
body: SettingsUpdate,
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
old_base_url = await get_setting(session, "external_url")
old_secret = await get_setting(session, "telegram_webhook_secret")
for key in _SETTING_KEYS:
value = getattr(body, key, None)
if value is None:
continue
row = await session.get(AppSetting, key)
if row:
row.value = value
else:
row = AppSetting(key=key, value=value)
session.add(row)
await session.commit()
new_base_url = await get_setting(session, "external_url")
new_secret = await get_setting(session, "telegram_webhook_secret")
# Update webhook secret in the webhook handler module
if new_secret != old_secret:
from ..commands.webhook import set_webhook_secret
set_webhook_secret(new_secret or None)
# Re-register webhooks for all bots in webhook mode if URL or secret changed
if new_base_url and (new_base_url != old_base_url or new_secret != old_secret):
await _reregister_webhooks(session, new_base_url, new_secret)
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
return result
async def _reregister_webhooks(
session: AsyncSession, base_url: str, secret: str
) -> None:
"""Re-register webhooks for all bots in webhook mode."""
from ..commands.webhook import register_webhook
result = await session.exec(
select(TelegramBot).where(TelegramBot.update_mode == "webhook")
)
bots = result.all()
for bot in bots:
webhook_url = f"{base_url.rstrip('/')}/api/telegram/webhook/{bot.webhook_path_id}"
res = await register_webhook(bot.token, webhook_url, secret or None)
if res.get("success"):
_LOGGER.info("Re-registered webhook for bot %d (%s)", bot.id, bot.name)
else:
_LOGGER.warning(
"Failed to re-register webhook for bot %d: %s",
bot.id, res.get("error"),
)