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
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
import time
from functools import lru_cache
from typing import Any
import aiohttp
@@ -25,17 +26,21 @@ from ..database.models import (
ServiceProvider,
TelegramBot,
)
from .base import CommandResponse
from .parser import parse_command
from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
# Singleton Jinja2 environment for template rendering (Phase 4d)
_JINJA_ENV = SandboxedEnvironment(autoescape=False)
_JINJA_ENV = SandboxedEnvironment(autoescape=True)
# Rate limit state with automatic TTL expiry (Phase 4e)
_rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
# Maximum responses per command to avoid Telegram rate limits
_MAX_RESPONSES_PER_COMMAND = 5
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
"""Check rate limit. Returns seconds to wait, or None if OK."""
@@ -60,6 +65,12 @@ def _resolve_template(
return locale_map.get(locale) or locale_map.get("en")
@lru_cache(maxsize=256)
def _compile_template(template_str: str):
"""Cache compiled Jinja2 templates to avoid re-parsing identical strings."""
return _JINJA_ENV.from_string(template_str)
def _render_cmd_template(
templates: dict[str, dict[str, str]], slot_name: str, locale: str,
context: dict[str, Any],
@@ -70,20 +81,28 @@ def _render_cmd_template(
_LOGGER.warning("No command template found for slot '%s' locale '%s'", slot_name, locale)
return f"[No template: {slot_name}]"
try:
tmpl = _JINJA_ENV.from_string(template_str)
tmpl = _compile_template(template_str)
return tmpl.render(**context)
except Exception as e:
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
return f"[Template error: {slot_name}]"
# ---------------------------------------------------------------------------
# Context resolution
# ---------------------------------------------------------------------------
async def _resolve_command_context(
bot: TelegramBot,
) -> tuple[list[tuple[CommandTracker, CommandConfig, ServiceProvider]], dict[str, dict[str, str]]]:
) -> tuple[
list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
dict[int, dict[str, dict[str, str]]],
]:
"""Resolve all enabled command trackers, configs, and providers for a bot.
Returns (context_tuples, cmd_template_slots).
cmd_template_slots is {slot_name: {locale: template}}.
Returns:
(context_tuples, templates_by_config_id)
templates_by_config_id is {command_template_config_id: {slot_name: {locale: template}}}.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
@@ -142,8 +161,8 @@ async def _resolve_command_context(
continue
tuples.append((tracker, config, provider))
# Load command template slots — merge from all configs
cmd_template_slots: dict[str, dict[str, str]] = {}
# Load command template slots per config (not merged)
templates_by_config_id: dict[int, dict[str, dict[str, str]]] = {}
seen_config_ids: set[int] = set()
for _, config, _ in tuples:
cfg_id = config.command_template_config_id
@@ -154,98 +173,136 @@ async def _resolve_command_context(
CommandTemplateSlot.config_id == cfg_id
)
)
slots: dict[str, dict[str, str]] = {}
for s in slot_result.all():
cmd_template_slots.setdefault(s.slot_name, {})[s.locale] = s.template
slots.setdefault(s.slot_name, {})[s.locale] = s.template
templates_by_config_id[cfg_id] = slots
return tuples, cmd_template_slots
return tuples, templates_by_config_id
def _merge_command_context(
def _templates_for_config(
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
config: CommandConfig,
) -> dict[str, dict[str, str]]:
"""Get template slots for a specific command config."""
cfg_id = config.command_template_config_id
if cfg_id and cfg_id in templates_by_config_id:
return templates_by_config_id[cfg_id]
return {}
def _merge_all_templates(
templates_by_config_id: dict[int, dict[str, dict[str, str]]],
) -> dict[str, dict[str, str]]:
"""Merge all template config slots into one dict (for universal commands)."""
merged: dict[str, dict[str, str]] = {}
for slots in templates_by_config_id.values():
for slot_name, locale_map in slots.items():
merged.setdefault(slot_name, {}).update(locale_map)
return merged
def _merge_enabled_commands(
ctx: list[tuple[CommandTracker, CommandConfig, ServiceProvider]],
) -> tuple[list[str], str, int, dict[str, Any]]:
"""Merge enabled_commands from all configs and pick defaults from first config."""
) -> tuple[list[str], dict[str, Any]]:
"""Merge enabled_commands (union) and rate_limits from all configs.
Rate limits use the most restrictive (minimum) cooldown per category.
"""
if not ctx:
return [], "media", 5, {}
return [], {}
enabled: set[str] = set()
merged_limits: dict[str, int] = {}
for _, config, _ in ctx:
enabled.update(config.enabled_commands or [])
for category, cooldown in (config.rate_limits or {}).items():
if category not in merged_limits:
merged_limits[category] = cooldown
else:
merged_limits[category] = min(merged_limits[category], cooldown)
first_config = ctx[0][1]
response_mode = first_config.response_mode or "media"
default_count = first_config.default_count or 5
rate_limits = first_config.rate_limits or {}
return sorted(enabled), merged_limits
return sorted(enabled), response_mode, default_count, rate_limits
# ---------------------------------------------------------------------------
# Main dispatcher
# ---------------------------------------------------------------------------
async def handle_command(
bot: TelegramBot,
chat_id: str,
text: str,
language_code: str = "",
) -> str | list[dict[str, Any]] | None:
) -> list[CommandResponse] | None:
"""Handle a bot command. Routes to provider-specific handlers.
Returns text response, media list, or None.
Returns a list of CommandResponse objects (one per tracker), or None.
Universal commands (/start, /help) return a single-element list.
Provider-specific commands dispatch per-tracker with per-tracker config.
"""
cmd, args, count_override = parse_command(text)
if not cmd:
return None
ctx_tuples, cmd_templates = await _resolve_command_context(bot)
enabled, response_mode, default_count, rate_limits = _merge_command_context(ctx_tuples)
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
enabled, rate_limits = _merge_enabled_commands(ctx_tuples)
locale = language_code[:2].lower() if language_code else "en"
if locale not in ("en", "ru"):
locale = "en"
# Merged templates for universal commands
merged_templates = _merge_all_templates(templates_by_config_id)
if cmd == "start":
return _render_cmd_template(cmd_templates, "start", locale, {"bot_name": bot.name})
text_resp = _render_cmd_template(merged_templates, "start", locale, {"bot_name": bot.name})
return [CommandResponse(text=text_resp)]
if cmd not in enabled and cmd != "start":
return None
# Rate limit check
# Rate limit check (once per command, shared across all trackers)
wait = _check_rate_limit(bot.id, chat_id, cmd, rate_limits)
if wait is not None:
return _render_cmd_template(cmd_templates, "rate_limited", locale, {"wait": wait})
text_resp = _render_cmd_template(merged_templates, "rate_limited", locale, {"wait": wait})
return [CommandResponse(text=text_resp)]
count = min(count_override or default_count, 20)
# Build providers map from command context
providers_map: dict[int, ServiceProvider] = {}
for _, _, provider in ctx_tuples:
providers_map[provider.id] = provider
# Universal commands
# Universal commands — single merged response
if cmd == "help":
ctx = _cmd_help(enabled, locale, cmd_templates)
return _render_cmd_template(cmd_templates, "help", locale, ctx)
ctx = _cmd_help(enabled, locale, merged_templates)
text_resp = _render_cmd_template(merged_templates, "help", locale, ctx)
return [CommandResponse(text=text_resp)]
# Provider-specific dispatch
# Provider-specific dispatch — per-tracker
from .dispatch import get_handler
# Group ctx_tuples by provider type
by_type: dict[str, list[tuple[CommandTracker, CommandConfig, ServiceProvider]]] = {}
for t in ctx_tuples:
ptype = t[2].type
by_type.setdefault(ptype, []).append(t)
# Find which handler claims this command
for ptype, ptuples in by_type.items():
handler = get_handler(ptype)
if handler and cmd in handler.get_provider_commands():
# Build provider map filtered to this provider type
pmap = {p.id: p for _, _, p in ptuples}
result = await handler.handle(
cmd, args, count, locale, response_mode,
pmap, cmd_templates, bot, ptuples,
responses: list[CommandResponse] = []
for tracker, config, provider in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
_LOGGER.warning(
"Truncated command responses at %d for bot %d cmd /%s",
_MAX_RESPONSES_PER_COMMAND, bot.id, cmd,
)
if result is not None:
return result
break
return None
handler = get_handler(provider.type)
if not handler or cmd not in handler.get_provider_commands():
continue
tracker_templates = _templates_for_config(templates_by_config_id, config)
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
)
if result is not None:
responses.append(result)
return responses if responses else None
def _cmd_help(
@@ -283,17 +340,13 @@ async def send_reply(
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply via TelegramClient."""
async def _send(http: aiohttp.ClientSession) -> None:
client = TelegramClient(http, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
if session is not None:
await _send(session)
else:
async with aiohttp.ClientSession() as http:
await _send(http)
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
async def send_media_group(
@@ -319,52 +372,50 @@ async def send_media_group(
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
caption = "\n".join(captions) if captions else None
async def _send(http: aiohttp.ClientSession) -> None:
client = TelegramClient(http, bot_token)
result = await client.send_notification(
chat_id, assets=assets, caption=caption,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
if not result.get("success"):
_LOGGER.warning("Telegram media group failed: %s", result.get("error"))
if session is not None:
await _send(session)
else:
async with aiohttp.ClientSession() as http:
await _send(http)
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_notification(
chat_id, assets=assets, caption=caption,
reply_to_message_id=reply_to_message_id,
chat_action=None,
)
if not result.get("success"):
_LOGGER.warning("Telegram media group failed: %s", result.get("error"))
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
ctx_tuples, templates_by_config_id = await _resolve_command_context(bot)
enabled, _ = _merge_enabled_commands(ctx_tuples)
templates = _merge_all_templates(templates_by_config_id)
async with aiohttp.ClientSession() as http:
client = TelegramClient(http, bot.token)
success = False
from ..services.http_session import get_http_session
http = await get_http_session()
client = TelegramClient(http, bot.token)
success = False
# Register per-locale commands
for locale in ("en", "ru"):
commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"command": cmd, "description": desc})
result = await client.set_my_commands(commands, language_code=locale)
if result.get("success"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
# Register default (no language_code) with EN descriptions
en_commands = []
# Register per-locale commands
for locale in ("en", "ru"):
commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
en_commands.append({"command": cmd, "description": desc})
result = await client.set_my_commands(en_commands)
desc = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"command": cmd, "description": desc})
result = await client.set_my_commands(commands, language_code=locale)
if result.get("success"):
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("error"))
return success
# Register default (no language_code) with EN descriptions
en_commands = []
for cmd in enabled:
desc = _resolve_template(templates, f"desc_{cmd}", "en") or cmd
en_commands.append({"command": cmd, "description": desc})
result = await client.set_my_commands(en_commands)
if result.get("success"):
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
return success