85311684d9
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).
197 lines
7.1 KiB
Python
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"),
|
|
)
|