"""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 get_current_user, 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, # URL cache TTL; 0 disables TTL "telegram_asset_cache_max_entries": None, # LRU cap for both caches "supported_locales": None, # comma-separated locale codes "timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC } _DEFAULTS = { "external_url": "", "telegram_webhook_secret": "", # 720h = 30d. URL cache only; asset cache uses thumbhash validation # (content-addressable) and ignores TTL entirely. "telegram_cache_ttl_hours": "720", "telegram_asset_cache_max_entries": "5000", "supported_locales": "en,ru", "timezone": "UTC", } # Settings whose changes require dropping in-memory Telegram caches so the # next dispatch rebuilds them with the new parameters. Files are preserved. _CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"} 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): # Numeric fields declared as int|str so clients can send either form. # Svelte's bind:value on coerces to a JS number, # so the frontend sends ints for these; older/manual clients may send # strings. We normalize to str before persisting. external_url: str | None = None telegram_webhook_secret: str | None = None telegram_cache_ttl_hours: int | str | None = None telegram_asset_cache_max_entries: int | str | None = None supported_locales: str | None = None timezone: 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: value = await get_setting(session, key) if key == "telegram_webhook_secret" and value: result[key] = f"***{value[-4:]}" if len(value) > 4 else "***" else: result[key] = value 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") old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS} for key in _SETTING_KEYS: value = getattr(body, key, None) if value is None: continue value_str = str(value) # GET masks the webhook secret as "***" so the real value is # never exposed to the frontend. If the client sends the mask back # (which happens on every save, since bind:value holds whatever GET # returned), treat it as "unchanged" — otherwise we'd overwrite the # real secret with its mask, silently breaking webhook HMAC. if key == "telegram_webhook_secret" and value_str.startswith("***"): continue row = await session.get(AppSetting, key) if row: row.value = value_str else: row = AppSetting(key=key, value=value_str) session.add(row) await session.commit() # Drop in-memory caches if any cache-tuning setting actually changed, so # the next dispatch rebuilds them with the new parameters. Files survive. cache_changed = False for key in _CACHE_SETTING_KEYS: if await get_setting(session, key) != old_cache_values[key]: cache_changed = True break if cache_changed: from ..services.watcher import reset_telegram_caches_in_memory await reset_telegram_caches_in_memory() 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 @router.get("/telegram-cache/stats") async def telegram_cache_stats( user: User = Depends(require_admin), ): """Return counts and sizes for the Telegram file_id caches.""" from ..services.watcher import get_telegram_cache_stats return await get_telegram_cache_stats() @router.post("/telegram-cache/clear") async def clear_telegram_cache( user: User = Depends(require_admin), ): """Clear the Telegram file_id cache (URL and asset) from disk and memory.""" from ..services.watcher import clear_telegram_caches result = await clear_telegram_caches() return result @router.get("/locales") async def get_supported_locales( user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): """Return list of supported template locales (available to all users).""" raw = await get_setting(session, "supported_locales") locales = [loc.strip() for loc in raw.split(",") if loc.strip()] return locales or ["en"] 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"), )