Files
notify-bridge/packages/server/src/notify_bridge_server/api/command_trackers.py
T
alexei.dolgolyov b803d004e1 refactor: comprehensive codebase review — security, performance, quality, UX
Security:
- Fix NUT protocol command injection (validate names against safe regex)
- Enable Jinja2 autoescape=True to prevent HTML injection via external data
- Add WebhookProviderConfig validation model

Performance:
- Shared aiohttp.ClientSession singleton (replaces 40+ per-request sessions)
- Fix 4 N+1 queries with batch IN loads (poller, scheduler, memory, broadcast)
- asyncio.gather for Gitea commands and notification dispatcher
- Add DB indexes on NotificationTrackerState.tracker_id, CommandTrackerListener
- LRU cache for compiled Jinja2 templates
- Daily EventLog cleanup job (90-day retention)
- 30s HTTP timeout on all external calls
- GROUP BY for target type counts (replaces 7 sequential queries)

Code quality:
- Extract get_owned_entity() helper (replaces 11 duplicate functions)
- Extract slot_helpers.py (load_slots, save_slots, render_template_preview)
- Extract command_utils.py (tracker lookup, last event, collection IDs)
- Extract http_session.py (shared session lifecycle)
- Provider connection validation dedup (3x → 1 helper)
- Command dispatch tables replacing if/elif chains
- Album+links fetch helper (fetch_albums_with_links)
- Provider dispatch polymorphism (list_provider_collections)
- Immutable _enrich_assets (no longer mutates in-place)
- Fix _format_assets return type + handler unpacking

Frontend:
- Fix 18+ hardcoded English strings → t() with new i18n keys (en + ru)
- Mobile "More" nav panel with provider filter and search
- Shared Button.svelte component (4 variants, 2 sizes)
- Shared ErrorBanner.svelte component (8 pages updated)
- SvelteKit goto() replacing window.location.href
- Dashboard grid fixed for 4 cards, paginator opacity consistency

Functionality:
- max_instances=1 on scheduler jobs (prevents duplicate events)
- Webhook provider in watcher (prevents error spam)
- Fix stale SQLModel reference in poller
- Gitea get_repo() direct API call
2026-03-28 13:22:26 +03:00

409 lines
13 KiB
Python

"""Command tracker and listener management API routes."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import func, select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import (
CommandConfig,
CommandTracker,
CommandTrackerListener,
ServiceProvider,
TelegramBot,
User,
)
from .helpers import get_owned_entity
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/command-trackers", tags=["command-trackers"])
class CommandTrackerCreate(BaseModel):
provider_id: int
command_config_id: int
name: str
icon: str = ""
enabled: bool = True
class CommandTrackerUpdate(BaseModel):
name: str | None = None
icon: str | None = None
enabled: bool | None = None
command_config_id: int | None = None
class ListenerCreate(BaseModel):
listener_type: str
listener_id: int
# --- Command Tracker CRUD ---
@router.get("")
async def list_command_trackers(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all command trackers for the current user, with listener counts."""
result = await session.exec(
select(CommandTracker).where(CommandTracker.user_id == user.id)
)
trackers = result.all()
return [await _tracker_response(session, t) for t in trackers]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_command_tracker(
body: CommandTrackerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new command tracker."""
# Validate provider exists and user owns it
provider = await session.get(ServiceProvider, body.provider_id)
if not provider or provider.user_id != user.id:
raise HTTPException(status_code=404, detail="Provider not found")
# Validate command config exists and user owns it
config = await session.get(CommandConfig, body.command_config_id)
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Command config not found")
# Validate provider_type matches
if config.provider_type != provider.type:
raise HTTPException(
status_code=400,
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
)
tracker = CommandTracker(user_id=user.id, **body.model_dump())
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@router.get("/{tracker_id}")
async def get_command_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a single command tracker with its listeners."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
return await _tracker_response(session, tracker, include_listeners=True)
@router.put("/{tracker_id}")
async def update_command_tracker(
tracker_id: int,
body: CommandTrackerUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a command tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
updates = body.model_dump(exclude_unset=True)
# If changing command_config_id, validate ownership and provider_type match
if "command_config_id" in updates and updates["command_config_id"] is not None:
config = await session.get(CommandConfig, updates["command_config_id"])
if not config or config.user_id != user.id:
raise HTTPException(status_code=404, detail="Command config not found")
provider = await session.get(ServiceProvider, tracker.provider_id)
if provider and config.provider_type != provider.type:
raise HTTPException(
status_code=400,
detail=f"Provider type mismatch: provider is '{provider.type}' but command config is for '{config.provider_type}'",
)
for field, value in updates.items():
setattr(tracker, field, value)
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
return await _tracker_response(session, tracker)
@router.delete("/{tracker_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_command_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a command tracker and cascade delete its listeners."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
# Mark affected bots dirty before deleting (chain breaks after deletion)
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Delete associated listeners, collecting bot IDs for polling cleanup
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == tracker_id
)
)
bot_ids_to_check: set[int] = set()
for listener in result.all():
if listener.listener_type == "telegram_bot":
bot_ids_to_check.add(listener.listener_id)
await session.delete(listener)
await session.delete(tracker)
await session.commit()
# Stop polling for bots that may no longer be needed
if bot_ids_to_check:
from ..services.telegram_poller import stop_bot_if_unused
for bot_id in bot_ids_to_check:
await stop_bot_if_unused(bot_id)
@router.post("/{tracker_id}/enable")
async def enable_command_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Enable a command tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
tracker.enabled = True
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Start polling for any telegram bot listeners
lr = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == tracker_id,
CommandTrackerListener.listener_type == "telegram_bot",
)
)
from ..services.telegram_poller import start_bot_if_needed
for listener in lr.all():
await start_bot_if_needed(listener.listener_id)
return await _tracker_response(session, tracker)
@router.post("/{tracker_id}/disable")
async def disable_command_tracker(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Disable a command tracker."""
tracker = await _get_user_tracker(session, tracker_id, user.id)
tracker.enabled = False
session.add(tracker)
await session.commit()
await session.refresh(tracker)
# Mark affected bots dirty for debounced auto-sync
from ..services.command_sync import mark_dirty_for_tracker
await mark_dirty_for_tracker(tracker.id)
# Stop polling for any telegram bot listeners that are no longer needed
lr = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == tracker_id,
CommandTrackerListener.listener_type == "telegram_bot",
)
)
from ..services.telegram_poller import stop_bot_if_unused
for listener in lr.all():
await stop_bot_if_unused(listener.listener_id)
return await _tracker_response(session, tracker)
# --- Listener Management ---
@router.get("/{tracker_id}/listeners")
async def list_listeners(
tracker_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all listeners for a command tracker."""
await _get_user_tracker(session, tracker_id, user.id)
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == tracker_id
)
)
return [await _listener_response(session, l) for l in result.all()]
@router.post("/{tracker_id}/listeners", status_code=status.HTTP_201_CREATED)
async def add_listener(
tracker_id: int,
body: ListenerCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Add a listener to a command tracker."""
await _get_user_tracker(session, tracker_id, user.id)
# Validate listener exists and user owns it
if body.listener_type == "telegram_bot":
bot = await session.get(TelegramBot, body.listener_id)
if not bot or bot.user_id != user.id:
raise HTTPException(status_code=404, detail="Telegram bot not found")
else:
raise HTTPException(
status_code=400,
detail=f"Unsupported listener type: {body.listener_type}",
)
# Check for duplicate
result = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == tracker_id,
CommandTrackerListener.listener_type == body.listener_type,
CommandTrackerListener.listener_id == body.listener_id,
)
)
if result.first():
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Listener is already linked to this command tracker",
)
listener = CommandTrackerListener(
command_tracker_id=tracker_id,
listener_type=body.listener_type,
listener_id=body.listener_id,
)
session.add(listener)
await session.commit()
await session.refresh(listener)
# Start polling for this bot if needed
if body.listener_type == "telegram_bot":
from ..services.telegram_poller import start_bot_if_needed
await start_bot_if_needed(body.listener_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(body.listener_id)
return await _listener_response(session, listener)
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_listener(
tracker_id: int,
listener_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Remove a listener from a command tracker."""
await _get_user_tracker(session, tracker_id, user.id)
listener = await session.get(CommandTrackerListener, listener_id)
if not listener or listener.command_tracker_id != tracker_id:
raise HTTPException(status_code=404, detail="Listener not found")
removed_type = listener.listener_type
removed_id = listener.listener_id
await session.delete(listener)
await session.commit()
# Stop polling for this bot if no longer needed
if removed_type == "telegram_bot":
from ..services.telegram_poller import stop_bot_if_unused
await stop_bot_if_unused(removed_id)
# Mark bot dirty for debounced auto-sync
from ..services.command_sync import mark_bot_dirty
mark_bot_dirty(removed_id)
# --- Helpers ---
async def _tracker_response(
session: AsyncSession, t: CommandTracker, include_listeners: bool = False
) -> dict:
"""Build command tracker response."""
# Get listener count
result = await session.exec(
select(func.count()).select_from(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == t.id
)
)
listeners_count = result.one()
resp = {
"id": t.id,
"user_id": t.user_id,
"provider_id": t.provider_id,
"command_config_id": t.command_config_id,
"name": t.name,
"icon": t.icon,
"enabled": t.enabled,
"listeners_count": listeners_count,
"created_at": t.created_at.isoformat(),
}
if include_listeners:
lr = await session.exec(
select(CommandTrackerListener).where(
CommandTrackerListener.command_tracker_id == t.id
)
)
resp["listeners"] = [await _listener_response(session, l) for l in lr.all()]
return resp
async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -> dict:
name = ""
if l.listener_type == "telegram_bot":
bot = await session.get(TelegramBot, l.listener_id)
if bot:
name = bot.name
return {
"id": l.id,
"command_tracker_id": l.command_tracker_id,
"listener_type": l.listener_type,
"listener_id": l.listener_id,
"name": name,
"created_at": l.created_at.isoformat(),
}
async def _get_user_tracker(
session: AsyncSession, tracker_id: int, user_id: int
) -> CommandTracker:
return await get_owned_entity(
session, CommandTracker, tracker_id, user_id,
not_found_msg="Command tracker not found",
)