feat: comprehensive code review fixes — security, performance, quality

Backend security:
- Reject Gitea webhooks when webhook_secret is empty (was silently skipping)
- Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints
- Add CORS middleware with configurable origins
- Mask telegram_webhook_secret in settings API response
- Protect system-owned command template configs from regular user modification
- Increase minimum password length to 8 characters

Backend performance:
- Batch queries in _resolve_command_context (3 queries instead of 3N)
- Concurrent album fetching with asyncio.gather in immich commands
- Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation)
- TTLCache for rate limits (bounded memory, auto-eviction)
- Optional aiohttp session reuse in send_reply/send_media_group

Backend code quality:
- Extract dispatch_helpers.py (shared link_data loading + event filtering)
- Extract database/seeds.py from main.py (490 lines → dedicated module)
- Split immich_handler.py (415 lines) into commands/immich/ subpackage
- Replace bare except blocks with logged warnings
- Add per-provider config validation (Pydantic models)
- Truncate command input to 512 chars
- Expose usage_* and desc_* slots in capabilities and variables API

Frontend security:
- CSS.escape() for user-controlled querySelector in highlight.ts
- Client-side password min 8 chars validation on setup and password change

Frontend code quality:
- Replace any types with proper interfaces across top files
- Decompose targets/+page.svelte into TargetForm + ReceiverSection
- Fix $derived.by usage, $state mutation patterns
- Add console.warn to empty catch blocks

Frontend UX:
- Auth redirect via goto() with "Redirecting..." state
- Platform-aware Ctrl/Cmd K keyboard hint
- Remove stat-card hover transform

Frontend accessibility:
- Modal: role=dialog, aria-modal, focus trap, restore focus
- EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
This commit is contained in:
2026-03-23 01:59:51 +03:00
parent 31584c5d31
commit e0bae394ee
78 changed files with 2855 additions and 1658 deletions
@@ -7,10 +7,12 @@ import time
from typing import Any
import aiohttp
from cachetools import TTLCache
from jinja2.sandbox import SandboxedEnvironment
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from notify_bridge_core.notifications.telegram.media import TELEGRAM_API_BASE_URL
from notify_bridge_core.notifications.telegram.client import TelegramClient
from ..database.engine import get_engine
from ..database.models import (
CommandConfig,
@@ -28,8 +30,11 @@ from .registry import get_rate_category
_LOGGER = logging.getLogger(__name__)
# Rate limit state: { (bot_id, chat_id, category): last_used_timestamp }
_rate_limits: dict[tuple[int, str, str], float] = {}
# Singleton Jinja2 environment for template rendering (Phase 4d)
_JINJA_ENV = SandboxedEnvironment(autoescape=False)
# Rate limit state with automatic TTL expiry (Phase 4e)
_rate_limits: TTLCache = TTLCache(maxsize=10000, ttl=3600)
def _check_rate_limit(bot_id: int, chat_id: str, cmd: str, limits: dict[str, int]) -> int | None:
@@ -65,9 +70,7 @@ 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:
from jinja2.sandbox import SandboxedEnvironment
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template_str)
tmpl = _JINJA_ENV.from_string(template_str)
return tmpl.render(**context)
except Exception as e:
_LOGGER.warning("Failed to render command template '%s': %s", slot_name, e)
@@ -95,15 +98,46 @@ async def _resolve_command_context(
if not listeners:
return [], {}
# Batch-fetch all referenced entities in 3 queries instead of N*3
tracker_ids = list({l.command_tracker_id for l in listeners})
tracker_result = await session.exec(
select(CommandTracker).where(CommandTracker.id.in_(tracker_ids))
)
trackers_by_id = {t.id: t for t in tracker_result.all()}
config_ids = list({
t.command_config_id for t in trackers_by_id.values()
if t.enabled and t.command_config_id
})
if config_ids:
config_result = await session.exec(
select(CommandConfig).where(CommandConfig.id.in_(config_ids))
)
configs_by_id = {c.id: c for c in config_result.all()}
else:
configs_by_id = {}
provider_ids = list({
t.provider_id for t in trackers_by_id.values()
if t.enabled and t.provider_id
})
if provider_ids:
provider_result = await session.exec(
select(ServiceProvider).where(ServiceProvider.id.in_(provider_ids))
)
providers_by_id = {p.id: p for p in provider_result.all()}
else:
providers_by_id = {}
tuples: list[tuple[CommandTracker, CommandConfig, ServiceProvider]] = []
for listener in listeners:
tracker = await session.get(CommandTracker, listener.command_tracker_id)
tracker = trackers_by_id.get(listener.command_tracker_id)
if not tracker or not tracker.enabled:
continue
config = await session.get(CommandConfig, tracker.command_config_id)
config = configs_by_id.get(tracker.command_config_id)
if not config:
continue
provider = await session.get(ServiceProvider, tracker.provider_id)
provider = providers_by_id.get(tracker.provider_id)
if not provider:
continue
tuples.append((tracker, config, provider))
@@ -220,7 +254,11 @@ def _cmd_help(
commands = []
for cmd in enabled:
desc_text = _resolve_template(templates, f"desc_{cmd}", locale) or cmd
commands.append({"name": cmd, "description": desc_text})
entry: dict[str, str] = {"name": cmd, "description": desc_text}
usage_text = _resolve_template(templates, f"usage_{cmd}", locale)
if usage_text:
entry["usage"] = usage_text
commands.append(entry)
return {"commands": commands}
@@ -240,128 +278,93 @@ async def _get_notification_trackers_for_providers(
return list(result.all())
async def send_reply(bot_token: str, chat_id: str, text: str) -> None:
"""Send a text reply via Telegram Bot API, retrying without HTML on parse failure."""
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMessage"
payload: dict[str, Any] = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
try:
async with http.post(url, json=payload) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.debug("Telegram reply failed: %s", result.get("description"))
if "parse" in str(result.get("description", "")).lower():
payload.pop("parse_mode", None)
async with http.post(url, json=payload) as retry_resp:
if retry_resp.status != 200:
_LOGGER.warning("Telegram reply failed on retry")
except aiohttp.ClientError as err:
_LOGGER.error("Failed to send Telegram reply: %s", err)
async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
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)
async def send_media_group(
bot_token: str, chat_id: str, media_items: list[dict[str, Any]],
reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send media items as a Telegram media group (album)."""
"""Send media items via TelegramClient.send_notification."""
if not media_items:
return
async with aiohttp.ClientSession() as http:
downloaded: list[tuple[bytes, str, str]] = []
for item in media_items:
asset_id = item.get("asset_id", "")
caption = item.get("caption", "")
thumb_url = item.get("thumbnail_url", "")
api_key = item.get("api_key", "")
try:
async with http.get(thumb_url, headers={"x-api-key": api_key}) as resp:
if resp.status != 200:
_LOGGER.warning("Failed to download thumbnail for %s: HTTP %d", asset_id, resp.status)
continue
photo_bytes = await resp.read()
downloaded.append((photo_bytes, asset_id, caption))
except aiohttp.ClientError:
continue
# Convert command handler media format to TelegramClient asset format
assets = []
for item in media_items:
assets.append({
"type": "photo",
"url": item.get("thumbnail_url", ""),
"cache_key": item.get("asset_id", ""),
"headers": {"x-api-key": item.get("api_key", "")},
})
if not downloaded:
return
# Build caption from first item
captions = [item.get("caption", "") for item in media_items if item.get("caption")]
caption = "\n".join(captions) if captions else None
for i in range(0, len(downloaded), 10):
chunk = downloaded[i:i + 10]
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 len(chunk) == 1:
photo_bytes, asset_id, caption = chunk[0]
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
data.add_field("photo", photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
if caption:
data.add_field("caption", caption)
try:
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendPhoto", data=data) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.warning("Failed to send photo: %s", result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to send photo: %s", err)
else:
import json as _json
data = aiohttp.FormData()
data.add_field("chat_id", chat_id)
media_array = []
for idx, (photo_bytes, asset_id, caption) in enumerate(chunk):
attach_key = f"photo_{idx}"
media_obj: dict[str, Any] = {"type": "photo", "media": f"attach://{attach_key}"}
if caption:
media_obj["caption"] = caption
media_array.append(media_obj)
data.add_field(attach_key, photo_bytes, filename=f"{asset_id}.jpg", content_type="image/jpeg")
data.add_field("media", _json.dumps(media_array))
try:
async with http.post(f"{TELEGRAM_API_BASE_URL}{bot_token}/sendMediaGroup", data=data) as resp:
if resp.status != 200:
result = await resp.json()
_LOGGER.warning("Failed to send media group: %s", result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.warning("Failed to send media group: %s", err)
if session is not None:
await _send(session)
else:
async with aiohttp.ClientSession() as http:
await _send(http)
async def register_commands_with_telegram(bot: TelegramBot) -> bool:
"""Register enabled commands with Telegram BotFather API."""
"""Register enabled commands with Telegram BotFather API via TelegramClient."""
ctx_tuples, templates = await _resolve_command_context(bot)
enabled, _, _, _ = _merge_command_context(ctx_tuples)
async with aiohttp.ClientSession() as http:
url = f"{TELEGRAM_API_BASE_URL}{bot.token}/setMyCommands"
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"))
payload: dict[str, Any] = {"commands": commands, "language_code": locale}
try:
async with http.post(url, json=payload) as resp:
result = await resp.json()
if result.get("ok"):
success = True
else:
_LOGGER.warning("Failed to register commands for locale '%s': %s", locale, result.get("description"))
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register commands for locale '%s': %s", locale, err)
# 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})
try:
async with http.post(url, json={"commands": en_commands}) as resp:
result = await resp.json()
if result.get("ok"):
_LOGGER.info("Registered %d commands for bot @%s (all locales)", len(en_commands), bot.bot_username)
success = True
except aiohttp.ClientError as err:
_LOGGER.error("Failed to register default commands: %s", err)
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