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
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
@@ -8,7 +8,7 @@ from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..auth.dependencies import require_admin
from ..database.engine import get_session
from ..database.models import AppSetting, TelegramBot, User
@@ -51,7 +51,7 @@ class SettingsUpdate(BaseModel):
@router.get("")
async def get_settings(
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Return all app settings."""
@@ -64,7 +64,7 @@ async def get_settings(
@router.put("")
async def update_settings(
body: SettingsUpdate,
user: User = Depends(get_current_user),
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
):
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
@@ -173,7 +173,11 @@ async def _find_system_default_template(
)
)
templates = result.all()
# Match by locale suffix in name, e.g. "(EN)" or "(RU)"
# Match by locale column first, fall back to name suffix
locale_lower = locale_upper.lower()
for tpl in templates:
if tpl.locale == locale_lower:
return tpl
for tpl in templates:
if f"({locale_upper})" in tpl.name:
return tpl
@@ -47,12 +47,12 @@ async def check_telegram_bot(session: AsyncSession, bot_id: int) -> list[str]:
"""Check if a TelegramBot is used by any targets or command listeners."""
consumers = []
# Check notification targets with this bot in config
result = await session.exec(select(NotificationTarget))
result = await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "telegram")
)
for t in result.all():
if t.config.get("bot_id") == bot_id or t.config.get("bot_token"):
# Need to verify it's actually this bot
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
if t.config.get("bot_id") == bot_id:
consumers.append(f"Target: {t.name}")
# Check command tracker listeners
result = await session.exec(
select(CommandTrackerListener).where(
@@ -111,9 +111,7 @@ async def delete_notification_tracker(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from .delete_protection import check_notification_tracker, raise_if_used
tracker = await _get_user_tracker(session, tracker_id, user.id)
raise_if_used(await check_notification_tracker(session, tracker.id), tracker.name)
# Delete associated tracker-target links
result = await session.exec(
select(NotificationTrackerTarget).where(NotificationTrackerTarget.tracker_id == tracker_id)
@@ -3,7 +3,7 @@
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -11,6 +11,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, TargetReceiver, User
from ..services.notifier import send_to_receiver
_LOGGER = logging.getLogger(__name__)
@@ -117,6 +118,25 @@ async def update_receiver(
return _response(receiver)
@router.post("/{receiver_id}/test")
async def test_receiver(
target_id: int,
receiver_id: int,
locale: str = Query("en"),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Send a test notification to a single receiver."""
target = await _get_user_target(session, target_id, user.id)
receiver = await session.get(TargetReceiver, receiver_id)
if not receiver or receiver.target_id != target_id:
raise HTTPException(status_code=404, detail="Receiver not found")
from ..services.notifier import _get_test_message
message = _get_test_message(locale, target.type)
return await send_to_receiver(target, dict(receiver.config), message)
@router.delete("/{receiver_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_receiver(
target_id: int,
@@ -12,11 +12,46 @@ from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import NotificationTarget, NotificationTrackerTarget, TargetReceiver, TelegramBot, TelegramChat, User
from ..services.notifier import send_test_notification
from .target_receivers import _receiver_key
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/targets", tags=["targets"])
# Delivery fields that belong in TargetReceiver, NOT in target.config
_DELIVERY_FIELDS: dict[str, str] = {
"telegram": "chat_id",
"webhook": "url",
"email": "email",
"discord": "webhook_url",
"slack": "webhook_url",
"ntfy": "topic",
"matrix": "room_id",
}
def _extract_delivery_fields(target_type: str, config: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Split config into (clean_config, receiver_config).
Returns the target config with delivery fields removed,
and a receiver config dict (empty if no delivery field found).
"""
field = _DELIVERY_FIELDS.get(target_type)
if not field:
return dict(config), {}
clean = dict(config)
receiver_cfg: dict[str, Any] = {}
value = clean.pop(field, None)
if value:
receiver_cfg[field] = value
# For webhook, also move headers to receiver config
if target_type == "webhook" and "headers" in clean:
receiver_cfg["headers"] = clean.pop("headers")
return clean, receiver_cfg
class TargetCreate(BaseModel):
type: str # "telegram" or "webhook"
@@ -44,32 +79,38 @@ async def list_targets(
)
targets = result.all()
# Resolve chat names for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram" and tgt.config.get("chat_id"):
bot_id = tgt.config.get("bot_id")
chat_id = str(tgt.config["chat_id"])
if bot_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
# Load receiver counts
receiver_counts: dict[int, int] = {}
# Load receivers for each target
target_receivers: dict[int, list[TargetReceiver]] = {}
for tgt in targets:
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == tgt.id)
)
receiver_counts[tgt.id] = len(recv_result.all())
target_receivers[tgt.id] = list(recv_result.all())
return [_target_response(t, chat_names, receiver_counts.get(t.id, 0)) for t in targets]
# Resolve chat names from receivers for telegram targets
chat_names: dict[str, str] = {}
for tgt in targets:
if tgt.type == "telegram":
bot_id = tgt.config.get("bot_id")
if not bot_id:
continue
for recv in target_receivers.get(tgt.id, []):
chat_id = str(recv.config.get("chat_id", ""))
if chat_id:
chat_result = await session.exec(
select(TelegramChat).where(
TelegramChat.bot_id == bot_id,
TelegramChat.chat_id == chat_id,
)
)
chat = chat_result.first()
if chat:
chat_names[f"{bot_id}_{chat_id}"] = chat.title or chat.username or ""
return [
_target_response(t, chat_names, target_receivers.get(t.id, []))
for t in targets
]
@router.post("", status_code=status.HTTP_201_CREATED)
@@ -85,15 +126,33 @@ async def create_target(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Type must be one of: {', '.join(valid_types)}",
)
# Extract delivery fields from config — they go into a TargetReceiver
clean_config, receiver_cfg = _extract_delivery_fields(body.type, body.config)
target = NotificationTarget(
user_id=user.id,
type=body.type,
name=body.name,
icon=body.icon,
config=body.config,
config=clean_config,
chat_action=body.chat_action,
)
session.add(target)
await session.flush() # get target.id
# Auto-create a receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(body.type, receiver_cfg)
receiver = TargetReceiver(
target_id=target.id,
name=body.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
await session.commit()
await session.refresh(target)
return {"id": target.id, "type": target.type, "name": target.name}
@@ -107,7 +166,11 @@ async def get_target(
):
"""Get a specific notification target."""
target = await _get_user_target(session, target_id, user.id)
return _target_response(target)
recv_result = await session.exec(
select(TargetReceiver).where(TargetReceiver.target_id == target.id)
)
receivers = list(recv_result.all())
return _target_response(target, receivers=receivers)
@router.put("/{target_id}")
@@ -119,8 +182,38 @@ async def update_target(
):
"""Update a notification target."""
target = await _get_user_target(session, target_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(target, field, value)
updates = body.model_dump(exclude_unset=True)
# If config is being updated, extract any delivery fields
if "config" in updates and updates["config"] is not None:
clean_config, receiver_cfg = _extract_delivery_fields(target.type, updates["config"])
updates["config"] = clean_config
# Update or create receiver if delivery fields were present
if receiver_cfg:
key = _receiver_key(target.type, receiver_cfg)
existing_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.receiver_key == key,
)
)
existing_recv = existing_result.first()
if existing_recv:
existing_recv.config = receiver_cfg
session.add(existing_recv)
else:
receiver = TargetReceiver(
target_id=target.id,
name=target.name,
config=receiver_cfg,
receiver_key=key,
enabled=True,
)
session.add(receiver)
for field_name, value in updates.items():
setattr(target, field_name, value)
session.add(target)
await session.commit()
await session.refresh(target)
@@ -160,7 +253,12 @@ async def test_target(
return result
def _target_response(target: NotificationTarget, chat_names: dict[str, str] | None = None, receiver_count: int = 0) -> dict:
def _target_response(
target: NotificationTarget,
chat_names: dict[str, str] | None = None,
receivers: list[TargetReceiver] | None = None,
) -> dict:
recv_list = receivers or []
resp = {
"id": target.id,
"type": target.type,
@@ -168,16 +266,27 @@ def _target_response(target: NotificationTarget, chat_names: dict[str, str] | No
"icon": target.icon,
"config": _safe_config(target),
"chat_action": target.chat_action,
"receiver_count": receiver_count,
"receiver_count": len(recv_list),
"receivers": [
{
"id": r.id,
"name": r.name,
"config": dict(r.config),
"receiver_key": r.receiver_key,
"enabled": r.enabled,
}
for r in recv_list
],
"created_at": target.created_at.isoformat(),
}
# Attach resolved chat name for telegram targets
# Attach resolved chat names from receivers for telegram targets
if target.type == "telegram" and chat_names:
bot_id = target.config.get("bot_id")
chat_id = str(target.config.get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
resp["chat_name"] = chat_names[key]
for recv_resp in resp["receivers"]:
chat_id = str(recv_resp["config"].get("chat_id", ""))
key = f"{bot_id}_{chat_id}"
if key in chat_names:
recv_resp["chat_name"] = chat_names[key]
return resp