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
@@ -165,7 +165,7 @@ class TelegramClient:
"parse_mode": parse_mode,
}
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
if disable_web_page_preview:
payload["link_preview_options"] = {"is_disabled": True}
@@ -174,6 +174,14 @@ class TelegramClient:
result = await response.json()
if response.status == 200 and result.get("ok"):
return {"success": True, "message_id": result.get("result", {}).get("message_id")}
# Retry without parse_mode on parse errors
desc = str(result.get("description", ""))
if "parse" in desc.lower():
payload.pop("parse_mode", None)
async with self._session.post(telegram_url, json=payload) as retry_resp:
retry_result = await retry_resp.json()
if retry_resp.status == 200 and retry_result.get("ok"):
return {"success": True, "message_id": retry_result.get("result", {}).get("message_id")}
return {"success": False, "error": result.get("description", "Unknown Telegram error"), "error_code": result.get("error_code")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
@@ -218,7 +226,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -251,7 +259,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendPhoto"
async with self._session.post(telegram_url, data=form) as response:
@@ -286,7 +294,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -315,7 +323,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendVideo"
async with self._session.post(telegram_url, data=form) as response:
@@ -351,7 +359,7 @@ class TelegramClient:
if caption:
payload["caption"] = caption
if reply_to_message_id:
payload["reply_to_message_id"] = reply_to_message_id
payload["reply_parameters"] = {"message_id": reply_to_message_id}
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
try:
async with self._session.post(telegram_url, json=payload) as response:
@@ -369,7 +377,7 @@ class TelegramClient:
if caption:
form.add_field("caption", caption)
if reply_to_message_id:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
telegram_url = f"{TELEGRAM_API_BASE_URL}{self._token}/sendDocument"
async with self._session.post(telegram_url, data=form) as response:
@@ -418,7 +426,7 @@ class TelegramClient:
form = FormData()
form.add_field("chat_id", chat_id)
if reply_to_message_id and chunk_idx == 0:
form.add_field("reply_to_message_id", str(reply_to_message_id))
form.add_field("reply_parameters", json.dumps({"message_id": reply_to_message_id}))
media_json = []
upload_idx = 0
@@ -488,3 +496,96 @@ class TelegramClient:
return {"success": False, "error": str(err), "failed_at_chunk": chunk_idx + 1}
return {"success": True, "message_ids": all_message_ids, "chunks_sent": len(chunks)}
# ------------------------------------------------------------------
# Bot management methods
# ------------------------------------------------------------------
async def get_me(self) -> dict[str, Any]:
"""Call getMe to verify the bot token and get bot info."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getMe"
try:
async with self._session.get(url) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_webhook_info(self) -> dict[str, Any]:
"""Call getWebhookInfo to check current webhook status."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getWebhookInfo"
try:
async with self._session.get(url) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", {})}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def set_webhook(self, webhook_url: str, secret: str | None = None) -> dict[str, Any]:
"""Register a webhook URL with Telegram."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setWebhook"
payload: dict[str, Any] = {"url": webhook_url}
if secret:
payload["secret_token"] = secret
try:
async with self._session.post(url, json=payload) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def delete_webhook(self) -> dict[str, Any]:
"""Remove the webhook from Telegram."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/deleteWebhook"
try:
async with self._session.post(url) as resp:
data = await resp.json()
return {"success": data.get("ok", False)}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def get_updates(
self, offset: int | None = None, limit: int = 50, timeout: int = 0,
) -> dict[str, Any]:
"""Long-poll for updates via getUpdates."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/getUpdates"
params: dict[str, Any] = {
"timeout": timeout,
"limit": limit,
"allowed_updates": '["message"]',
}
if offset is not None:
params["offset"] = offset
try:
async with self._session.get(
url, params=params, timeout=aiohttp.ClientTimeout(total=max(10, timeout + 5)),
) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True, "result": data.get("result", [])}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
async def set_my_commands(
self, commands: list[dict[str, str]], language_code: str | None = None,
) -> dict[str, Any]:
"""Register bot commands with BotFather API."""
url = f"{TELEGRAM_API_BASE_URL}{self._token}/setMyCommands"
payload: dict[str, Any] = {"commands": commands}
if language_code:
payload["language_code"] = language_code
try:
async with self._session.post(url, json=payload) as resp:
data = await resp.json()
if data.get("ok"):
return {"success": True}
return {"success": False, "error": data.get("description", "Unknown error")}
except aiohttp.ClientError as err:
return {"success": False, "error": str(err)}
@@ -71,20 +71,29 @@ IMMICH_CAPABILITIES = ProviderCapabilities(
{"name": "memory", "description": "/memory On This Day photos"},
{"name": "rate_limited", "description": "Rate limit warning message"},
{"name": "no_results", "description": "Empty results fallback"},
{"name": "desc_help", "description": "Menu description for /help"},
{"name": "desc_status", "description": "Menu description for /status"},
{"name": "desc_albums", "description": "Menu description for /albums"},
{"name": "desc_events", "description": "Menu description for /events"},
{"name": "usage_events", "description": "Usage example for /events"},
{"name": "desc_summary", "description": "Menu description for /summary"},
{"name": "desc_latest", "description": "Menu description for /latest"},
{"name": "usage_latest", "description": "Usage example for /latest"},
{"name": "desc_memory", "description": "Menu description for /memory"},
{"name": "usage_memory", "description": "Usage example for /memory"},
{"name": "desc_random", "description": "Menu description for /random"},
{"name": "usage_random", "description": "Usage example for /random"},
{"name": "desc_search", "description": "Menu description for /search"},
{"name": "usage_search", "description": "Usage example for /search"},
{"name": "desc_find", "description": "Menu description for /find"},
{"name": "usage_find", "description": "Usage example for /find"},
{"name": "desc_person", "description": "Menu description for /person"},
{"name": "usage_person", "description": "Usage example for /person"},
{"name": "desc_place", "description": "Menu description for /place"},
{"name": "usage_place", "description": "Usage example for /place"},
{"name": "desc_favorites", "description": "Menu description for /favorites"},
{"name": "usage_favorites", "description": "Usage example for /favorites"},
{"name": "desc_people", "description": "Menu description for /people"},
{"name": "desc_help", "description": "Menu description for /help"},
],
events=[
{"name": "assets_added", "description": "New assets detected in album"},
@@ -237,6 +237,8 @@ class ImmichClient:
limit: int = 10,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": 1, "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
async with self._session.post(
f"{self._url}/api/search/smart",
@@ -246,15 +248,6 @@ class ImmichClient:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if album_ids:
tracked = set(album_ids)
items = [
a for a in items
if any(
alb.get("id") in tracked
for alb in a.get("albums", [])
)
]
return items[:limit]
except aiohttp.ClientError:
pass
@@ -267,6 +260,8 @@ class ImmichClient:
limit: int = 10,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": 1, "size": limit}
if album_ids:
payload["albumIds"] = album_ids
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
@@ -276,12 +271,6 @@ class ImmichClient:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
if album_ids:
tracked = set(album_ids)
items = [
a for a in items
if any(alb.get("id") in tracked for alb in a.get("albums", []))
]
return items[:limit]
except aiohttp.ClientError:
pass
@@ -1,4 +1,5 @@
Available commands:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
/search sunset at the beach
@@ -19,6 +19,10 @@ PROVIDER_COMMAND_SLOTS: dict[str, list[str]] = {
"desc_latest", "desc_memory", "desc_random", "desc_search",
"desc_find", "desc_person", "desc_place", "desc_favorites",
"desc_people", "desc_help",
# Usage example slots
"usage_search", "usage_find", "usage_person", "usage_place",
"usage_latest", "usage_random", "usage_favorites", "usage_events",
"usage_memory",
],
"gitea": [
# Response templates
@@ -1,4 +1,5 @@
Доступные команды:
{%- for cmd in commands %}
/{{ cmd.name }} — {{ cmd.description }}
{%- if cmd.usage %} ↳ {{ cmd.usage }}{% endif %}
{%- endfor %}
@@ -0,0 +1 @@
/person Алиса
@@ -0,0 +1 @@
/place Париж
@@ -0,0 +1 @@
/search закат на пляже