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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user