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
@@ -13,12 +13,12 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from jinja2.sandbox import SandboxedEnvironment
from jinja2 import TemplateSyntaxError, UndefinedError, StrictUndefined
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TemplateConfig, TemplateSlot, User
from ..services.sample_context import _SAMPLE_CONTEXT
from .slot_helpers import load_slots, render_template_preview, save_slots
_LOGGER = logging.getLogger(__name__)
@@ -49,40 +49,13 @@ class TemplateConfigUpdate(BaseModel):
# ---------------------------------------------------------------------------
async def _load_slots(session: AsyncSession, config_id: int) -> dict[str, dict[str, str]]:
"""Load all template slots for a config as {slot_name: {locale: template}}."""
result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == config_id)
)
slots: dict[str, dict[str, str]] = {}
for s in result.all():
slots.setdefault(s.slot_name, {})[s.locale] = s.template
return slots
return await load_slots(session, TemplateSlot, config_id)
async def _save_slots(
session: AsyncSession, config_id: int, slots: dict[str, dict[str, str]]
) -> None:
"""Create or update template slots for a config (locale-aware)."""
for slot_name, locale_map in slots.items():
for locale, template_text in locale_map.items():
result = await session.exec(
select(TemplateSlot).where(
TemplateSlot.config_id == config_id,
TemplateSlot.slot_name == slot_name,
TemplateSlot.locale == locale,
)
)
existing = result.first()
if existing:
existing.template = template_text
session.add(existing)
else:
session.add(TemplateSlot(
config_id=config_id,
slot_name=slot_name,
locale=locale,
template=template_text,
))
await save_slots(session, TemplateSlot, config_id, slots)
async def _response(session: AsyncSession, c: TemplateConfig) -> dict[str, Any]:
@@ -155,7 +128,7 @@ async def get_template_variables(
"photo_count": "Total photo count in album",
"video_count": "Total video count in album",
"owner": "Album owner name",
"target_type": "Target type: 'telegram' or 'webhook'",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
"has_videos": "Whether added assets contain videos (boolean)",
"has_photos": "Whether added assets contain photos (boolean)",
"has_oversized_videos": "Whether any video exceeds the target's size limit (boolean)",
@@ -206,7 +179,7 @@ async def get_template_variables(
}
scheduled_vars = {
"date": "Current date string",
"target_type": "Target type: 'telegram' or 'webhook'",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
}
return {
@@ -284,7 +257,7 @@ def _webhook_variables() -> dict:
"source_ip": "IP address of the webhook sender",
"raw_payload": "Full JSON payload as dict (use raw_payload.field or raw_payload | tojson)",
"timestamp": "When the webhook was received",
"target_type": "Target type: 'telegram' or 'webhook'",
"target_type": "Target type: telegram, webhook, email, discord, slack, ntfy, or matrix",
},
},
}
@@ -529,7 +502,7 @@ async def preview_date_format(
class PreviewRequest(BaseModel):
template: str
target_type: str = "telegram" # "telegram" or "webhook"
target_type: str = "telegram" # telegram, webhook, email, discord, slack, ntfy, matrix
date_format: str = "%d.%m.%Y, %H:%M UTC"
date_only_format: str = "%d.%m.%Y"
@@ -545,33 +518,12 @@ async def preview_raw(
1. Parse with default Undefined (catches syntax errors)
2. Render with StrictUndefined (catches unknown variables like {{ asset.a }})
"""
# Pass 1: syntax check
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
env = SandboxedEnvironment(autoescape=False)
env.from_string(body.template)
except TemplateSyntaxError as e:
return {
"rendered": None,
"error": e.message,
"error_line": e.lineno,
}
# Pass 2: render with strict undefined to catch unknown variables
try:
from datetime import datetime
ctx = {**_SAMPLE_CONTEXT, "target_type": body.target_type,
"date_format": body.date_format, "date_only_format": body.date_only_format}
# Format common_date using the provided date_only_format
try:
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
strict_env = SandboxedEnvironment(autoescape=False, undefined=StrictUndefined)
tmpl = strict_env.from_string(body.template)
rendered = tmpl.render(**ctx)
return {"rendered": rendered}
except UndefinedError as e:
# Still a valid template syntactically, but references unknown variable
return {"rendered": None, "error": str(e), "error_line": None, "error_type": "undefined"}
except Exception as e:
return {"rendered": None, "error": str(e), "error_line": None}
ctx["common_date"] = datetime(2026, 3, 19).strftime(body.date_only_format)
except (ValueError, TypeError):
ctx["common_date"] = "19.03.2026"
return render_template_preview(body.template, ctx)