Files
notify-bridge/packages/server/src/notify_bridge_server/api/app_settings.py
T
alexei.dolgolyov 85311684d9 fix(settings): don't clobber webhook secret with its mask on save
GET /settings returns the Telegram webhook secret masked as "***<last4>".
The frontend binds that masked value into its state, and any Save ships it
back — the PUT handler then persisted the mask as the new secret, silently
invalidating HMAC for every webhook-mode bot. The next GET re-masks the
mask to itself, so the UI showed no corruption.

Treat incoming values that begin with "***" as "unchanged" for the
webhook-secret field. Empty strings still pass through (explicit clear).
2026-04-22 16:10:34 +03:00

197 lines
7.1 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 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 <input type="number"> 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 "***<last4>" 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"),
)