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:
@@ -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 @@
|
||||
/events 10
|
||||
+1
@@ -0,0 +1 @@
|
||||
/favorites 5
|
||||
@@ -0,0 +1 @@
|
||||
/find IMG_001
|
||||
@@ -0,0 +1 @@
|
||||
/latest 10
|
||||
@@ -0,0 +1 @@
|
||||
/memory
|
||||
@@ -0,0 +1 @@
|
||||
/person Alice
|
||||
@@ -0,0 +1 @@
|
||||
/place Paris
|
||||
@@ -0,0 +1 @@
|
||||
/random 3
|
||||
@@ -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 @@
|
||||
/events 10
|
||||
+1
@@ -0,0 +1 @@
|
||||
/favorites 5
|
||||
@@ -0,0 +1 @@
|
||||
/find IMG_001
|
||||
@@ -0,0 +1 @@
|
||||
/latest 10
|
||||
@@ -0,0 +1 @@
|
||||
/memory
|
||||
@@ -0,0 +1 @@
|
||||
/person Алиса
|
||||
@@ -0,0 +1 @@
|
||||
/place Париж
|
||||
@@ -0,0 +1 @@
|
||||
/random 3
|
||||
@@ -0,0 +1 @@
|
||||
/search закат на пляже
|
||||
Reference in New Issue
Block a user