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
This commit is contained in:
2026-03-28 13:22:26 +03:00
parent 616b221c92
commit b803d004e1
65 changed files with 1934 additions and 1498 deletions
@@ -3,26 +3,47 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Coroutine
from typing import Any
import aiohttp
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import (
CommandConfig, CommandTracker, EventLog,
NotificationTracker, ServiceProvider, TelegramBot,
CommandConfig, CommandTracker, ServiceProvider, TelegramBot,
)
from ..services import make_planka_provider
from .base import ProviderCommandHandler
from .handler import _render_cmd_template, _get_notification_trackers_for_providers
from ..services.http_session import get_http_session
from .base import CommandResponse, ProviderCommandHandler
from .command_utils import get_last_event_str, get_tracked_collection_ids, get_trackers_for_provider
from .handler import _render_cmd_template
_LOGGER = logging.getLogger(__name__)
_PLANKA_COMMANDS = {"status", "boards", "cards", "lists"}
def _get_tracked_board_ids(
provider: ServiceProvider,
trackers: list,
) -> list[str]:
"""Get board IDs from tracked collection_ids for this provider."""
if not provider.config.get("api_key"):
return []
return get_tracked_collection_ids(provider, trackers)
# ---------------------------------------------------------------------------
# Command dispatch table
# ---------------------------------------------------------------------------
_TEXT_COMMANDS: dict[str, Callable[..., Coroutine[Any, Any, dict[str, Any]]]] = {}
def _text_cmd(fn: Callable[..., Coroutine[Any, Any, dict[str, Any]]]) -> Callable[..., Coroutine[Any, Any, dict[str, Any]]]:
"""Register a function in the text command dispatch table."""
name = fn.__name__.removeprefix("_cmd_")
_TEXT_COMMANDS[name] = fn
return fn
class PlankaCommandHandler(ProviderCommandHandler):
"""Handles Planka-specific bot commands."""
@@ -43,69 +64,26 @@ class PlankaCommandHandler(ProviderCommandHandler):
count: int,
locale: str,
response_mode: str,
providers_map: dict[int, ServiceProvider],
provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
bot: TelegramBot,
ctx_tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> str | list[dict[str, Any]] | None:
if cmd == "status":
ctx = await _cmd_status(providers_map)
return _render_cmd_template(cmd_templates, "status", locale, ctx)
if cmd == "boards":
ctx = await _cmd_boards(providers_map)
return _render_cmd_template(cmd_templates, "boards", locale, ctx)
if cmd == "cards":
ctx = await _cmd_cards(providers_map, count)
return _render_cmd_template(cmd_templates, "cards", locale, ctx)
if cmd == "lists":
ctx = await _cmd_lists(providers_map)
return _render_cmd_template(cmd_templates, "lists", locale, ctx)
return None
tracker: CommandTracker,
config: CommandConfig,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
if fn is None:
return None
ctx = await fn(provider, count)
return CommandResponse(text=_render_cmd_template(cmd_templates, cmd, locale, ctx))
def _get_tracked_board_ids(
providers_map: dict[int, ServiceProvider],
trackers: list[NotificationTracker],
) -> list[tuple[ServiceProvider, str]]:
"""Get (provider, board_id) tuples from tracked collection_ids."""
boards: list[tuple[ServiceProvider, str]] = []
for tracker in trackers:
provider = providers_map.get(tracker.provider_id)
if not provider or provider.type != "planka":
continue
if not provider.config.get("api_key"):
continue
for board_id in (tracker.collection_ids or []):
entry = (provider, board_id)
if entry not in boards:
boards.append(entry)
# Also check filters.collections
for board_id in (tracker.filters or {}).get("collections", []):
entry = (provider, board_id)
if entry not in boards:
boards.append(entry)
return boards[:20]
@_text_cmd
async def _cmd_status(provider: ServiceProvider, count: int) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracked_boards = _get_tracked_board_ids(provider, trackers)
async def _cmd_status(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracked_boards = _get_tracked_board_ids(providers_map, trackers)
# Last event
engine = get_engine()
async with AsyncSession(engine) as session:
tracker_ids = [t.id for t in trackers]
if tracker_ids:
result = await session.exec(
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc()).limit(1)
)
last_event = result.first()
else:
last_event = None
last_str = last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
tracker_ids = [t.id for t in trackers]
last_str = await get_last_event_str(tracker_ids)
return {
"boards_count": len(tracked_boards),
@@ -113,81 +91,69 @@ async def _cmd_status(providers_map: dict[int, ServiceProvider]) -> dict[str, An
}
async def _cmd_boards(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracked_boards = _get_tracked_board_ids(providers_map, trackers)
@_text_cmd
async def _cmd_boards(provider: ServiceProvider, count: int) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracked_boards = _get_tracked_board_ids(provider, trackers)
boards_data: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as http:
for provider, board_id in tracked_boards:
planka = make_planka_provider(http, provider)
all_boards = await planka.client.get_boards()
for b in all_boards:
if str(b.get("id", "")) == board_id:
boards_data.append({"name": b.get("name", board_id)})
break
else:
boards_data.append({"name": board_id})
http = await get_http_session()
planka = make_planka_provider(http, provider)
all_boards = await planka.client.get_boards()
board_names = {str(b.get("id", "")): b.get("name", "") for b in all_boards}
for board_id in tracked_boards:
boards_data.append({"name": board_names.get(board_id, board_id)})
return {"boards": boards_data}
async def _cmd_cards(
providers_map: dict[int, ServiceProvider], count: int,
) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracked_boards = _get_tracked_board_ids(providers_map, trackers)
@_text_cmd
async def _cmd_cards(provider: ServiceProvider, count: int) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracked_boards = _get_tracked_board_ids(provider, trackers)
all_cards: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as http:
for provider, board_id in tracked_boards:
planka = make_planka_provider(http, provider)
cards = await planka.client.get_board_cards(board_id, limit=count)
lists = await planka.client.get_board_lists(board_id)
lists_by_id = {str(lst.get("id", "")): lst.get("name", "") for lst in lists}
http = await get_http_session()
planka = make_planka_provider(http, provider)
boards = await planka.client.get_boards()
board_names = {str(b.get("id", "")): b.get("name", "") for b in boards}
boards = await planka.client.get_boards()
board_name = board_id
for b in boards:
if str(b.get("id", "")) == board_id:
board_name = b.get("name", board_id)
break
for board_id in tracked_boards:
cards = await planka.client.get_board_cards(board_id, limit=count)
lists = await planka.client.get_board_lists(board_id)
lists_by_id = {str(lst.get("id", "")): lst.get("name", "") for lst in lists}
board_name = board_names.get(board_id, board_id)
for card in cards:
list_id = str(card.get("listId", ""))
all_cards.append({
"name": card.get("name", ""),
"list_name": lists_by_id.get(list_id, ""),
"board_name": board_name,
})
for card in cards:
list_id = str(card.get("listId", ""))
all_cards.append({
"name": card.get("name", ""),
"list_name": lists_by_id.get(list_id, ""),
"board_name": board_name,
})
return {"cards": all_cards[:count]}
async def _cmd_lists(providers_map: dict[int, ServiceProvider]) -> dict[str, Any]:
provider_ids = set(providers_map.keys())
trackers = await _get_notification_trackers_for_providers(provider_ids)
tracked_boards = _get_tracked_board_ids(providers_map, trackers)
@_text_cmd
async def _cmd_lists(provider: ServiceProvider, count: int) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracked_boards = _get_tracked_board_ids(provider, trackers)
all_lists: list[dict[str, Any]] = []
async with aiohttp.ClientSession() as http:
for provider, board_id in tracked_boards:
planka = make_planka_provider(http, provider)
lists = await planka.client.get_board_lists(board_id)
http = await get_http_session()
planka = make_planka_provider(http, provider)
boards = await planka.client.get_boards()
board_names = {str(b.get("id", "")): b.get("name", "") for b in boards}
boards = await planka.client.get_boards()
board_name = board_id
for b in boards:
if str(b.get("id", "")) == board_id:
board_name = b.get("name", board_id)
break
for board_id in tracked_boards:
lists = await planka.client.get_board_lists(board_id)
board_name = board_names.get(board_id, board_id)
for lst in lists:
all_lists.append({
"name": lst.get("name", ""),
"board_name": board_name,
})
for lst in lists:
all_lists.append({
"name": lst.get("name", ""),
"board_name": board_name,
})
return {"lists": all_lists}