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
@@ -57,7 +57,11 @@ async def get_settings(
"""Return all app settings."""
result = {}
for key in _SETTING_KEYS:
result[key] = await get_setting(session, key)
value = await get_setting(session, key)
if key == "telegram_webhook_secret" and value:
result[key] = f"***{value[-4:]}" if len(value) > 4 else "***"
else:
result[key] = value
return result
@@ -124,6 +124,7 @@ async def get_command_variables():
command_fields = {
"name": "Command name (e.g. status, albums)",
"description": "Command description text",
"usage": "Usage example (e.g. /search sunset) — only for commands that take arguments",
}
event_fields = {
"type": "Event type (assets_added, assets_removed, etc.)",
@@ -197,6 +198,31 @@ async def get_command_variables():
"description": "Empty results fallback",
"variables": {**common_vars, "command": "Command name", "query": "Search query (empty for non-search commands)"},
},
# --- Description slots (shown in /help listing) ---
"desc_help": {"description": "Description for /help command", "variables": common_vars},
"desc_status": {"description": "Description for /status command", "variables": common_vars},
"desc_albums": {"description": "Description for /albums command", "variables": common_vars},
"desc_events": {"description": "Description for /events command", "variables": common_vars},
"desc_summary": {"description": "Description for /summary command", "variables": common_vars},
"desc_latest": {"description": "Description for /latest command", "variables": common_vars},
"desc_memory": {"description": "Description for /memory command", "variables": common_vars},
"desc_random": {"description": "Description for /random command", "variables": common_vars},
"desc_search": {"description": "Description for /search command", "variables": common_vars},
"desc_find": {"description": "Description for /find command", "variables": common_vars},
"desc_person": {"description": "Description for /person command", "variables": common_vars},
"desc_place": {"description": "Description for /place command", "variables": common_vars},
"desc_favorites": {"description": "Description for /favorites command", "variables": common_vars},
"desc_people": {"description": "Description for /people command", "variables": common_vars},
# --- Usage example slots (shown in /help listing) ---
"usage_search": {"description": "Usage example for /search (e.g. '/search sunset')", "variables": common_vars},
"usage_find": {"description": "Usage example for /find", "variables": common_vars},
"usage_person": {"description": "Usage example for /person", "variables": common_vars},
"usage_place": {"description": "Usage example for /place", "variables": common_vars},
"usage_latest": {"description": "Usage example for /latest", "variables": common_vars},
"usage_random": {"description": "Usage example for /random", "variables": common_vars},
"usage_favorites": {"description": "Usage example for /favorites", "variables": common_vars},
"usage_events": {"description": "Usage example for /events", "variables": common_vars},
"usage_memory": {"description": "Usage example for /memory", "variables": common_vars},
}
@@ -256,6 +282,8 @@ async def update_config(
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
if config.user_id == 0 and user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot modify system default configs")
for field, value in body.model_dump(exclude_unset=True, exclude={"slots"}).items():
if value is not None:
setattr(config, field, value)
@@ -275,6 +303,8 @@ async def delete_config(
):
from .delete_protection import check_command_template_config, raise_if_used
config = await _get(session, config_id, user.id)
if config.user_id == 0 and user.role != "admin":
raise HTTPException(status_code=403, detail="Cannot delete system default configs")
raise_if_used(await check_command_template_config(session, config.id), config.name)
slot_result = await session.exec(
select(CommandTemplateSlot).where(CommandTemplateSlot.config_id == config.id)
@@ -306,9 +336,10 @@ async def preview_raw(
"last_event": "2026-03-19 14:30",
# /help
"commands": [
{"name": "status", "description": "Show tracker status"},
{"name": "albums", "description": "List tracked albums"},
{"name": "latest", "description": "Show latest photos"},
{"name": "status", "description": "Show tracker status", "usage": ""},
{"name": "albums", "description": "List tracked albums", "usage": ""},
{"name": "latest", "description": "Show latest photos", "usage": "/latest 10"},
{"name": "search", "description": "Smart search (AI)", "usage": "/search sunset at the beach"},
],
# /albums, /summary
"albums": [
@@ -3,7 +3,7 @@
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from typing import Any
@@ -42,6 +42,48 @@ class ProviderResponse(BaseModel):
created_at: str
# -- Per-provider config validation models --
class ImmichProviderConfig(BaseModel):
url: str
api_key: str
external_domain: str | None = None
class GiteaProviderConfig(BaseModel):
url: str
webhook_secret: str
api_token: str | None = None
class SchedulerProviderConfig(BaseModel):
"""Scheduler is a virtual provider — no required fields."""
pass
_PROVIDER_CONFIG_MODELS: dict[str, type[BaseModel]] = {
"immich": ImmichProviderConfig,
"gitea": GiteaProviderConfig,
"scheduler": SchedulerProviderConfig,
}
def _validate_provider_config(provider_type: str, config: dict[str, Any]) -> None:
"""Validate provider config against the schema for the given type."""
config_model = _PROVIDER_CONFIG_MODELS.get(provider_type)
if config_model is None:
return
try:
config_model.model_validate(config)
except ValidationError as exc:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid config for '{provider_type}' provider: {exc}",
)
@router.get("")
async def list_providers(
user: User = Depends(get_current_user),
@@ -62,6 +104,8 @@ async def create_provider(
session: AsyncSession = Depends(get_session),
):
"""Add a new service provider (validates connection for known types)."""
_validate_provider_config(body.type, body.config)
# Validate connection for known provider types
if body.type == "immich":
from notify_bridge_core.providers.immich import ImmichServiceProvider
@@ -177,6 +221,7 @@ async def update_provider(
config_changed = body.config is not None and body.config != provider.config
if body.config is not None:
_validate_provider_config(provider.type, body.config)
provider.config = body.config
# Re-validate connection when config changes for known provider types
@@ -17,18 +17,11 @@ from notify_bridge_core.providers.gitea.event_parser import parse_webhook as par
from ..database.engine import get_engine
from ..database.models import (
EmailBot,
EventLog,
MatrixBot,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
TemplateConfig,
TemplateSlot,
TrackingConfig,
)
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
_LOGGER = logging.getLogger(__name__)
@@ -93,10 +86,15 @@ async def gitea_webhook(provider_id: int, request: Request):
# Read raw body for HMAC check
raw_body = await request.body()
if webhook_secret:
signature = request.headers.get("X-Gitea-Signature", "")
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
if not webhook_secret:
raise HTTPException(
status_code=403,
detail="Webhook secret not configured on this provider",
)
signature = request.headers.get("X-Gitea-Signature", "")
if not signature or not _verify_gitea_signature(webhook_secret, raw_body, signature):
raise HTTPException(status_code=403, detail="Invalid signature")
# Parse event header + payload
event_header = request.headers.get("X-Gitea-Event", "")
@@ -133,7 +131,7 @@ async def gitea_webhook(provider_id: int, request: Request):
continue
# Load tracker-target links
link_data = await _load_link_data(session, tracker.id)
link_data = await load_link_data(session, tracker.id)
if not link_data:
continue
@@ -176,122 +174,6 @@ async def gitea_webhook(provider_id: int, request: Request):
return {"ok": True, "dispatched": dispatched}
# ---------------------------------------------------------------------------
# Shared dispatch helpers (extracted from watcher pattern)
# ---------------------------------------------------------------------------
async def _load_link_data(
session: AsyncSession,
tracker_id: int,
) -> list[dict[str, Any]]:
"""Load tracker-target link data for dispatch (same pattern as watcher)."""
tt_result = await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.tracker_id == tracker_id
)
)
tracker_targets = tt_result.all()
link_data: list[dict[str, Any]] = []
for tt in tracker_targets:
if not tt.enabled:
continue
target = await session.get(NotificationTarget, tt.target_id)
if not target:
continue
# Load receivers
recv_result = await session.exec(
select(TargetReceiver).where(
TargetReceiver.target_id == target.id,
TargetReceiver.enabled == True,
)
)
receivers = [dict(r.config) for r in recv_result.all()]
tracking_config = None
if tt.tracking_config_id:
tracking_config = await session.get(TrackingConfig, tt.tracking_config_id)
template_config = None
template_slots: dict[str, str] | None = None
if tt.template_config_id:
template_config = await session.get(TemplateConfig, tt.template_config_id)
if template_config:
slot_result = await session.exec(
select(TemplateSlot).where(TemplateSlot.config_id == template_config.id)
)
raw_slots = {s.slot_name: s.template for s in slot_result.all()}
template_slots = {}
for slot_name, tmpl_text in raw_slots.items():
event_key = slot_name.removeprefix("message_") if slot_name.startswith("message_") else slot_name
template_slots[event_key] = tmpl_text
target_config = dict(target.config)
# Inject chat_action for Telegram targets
if hasattr(target, 'chat_action') and target.chat_action:
target_config["chat_action"] = target.chat_action
# Inject bot credentials
if target.type == "email":
email_bot_id = target.config.get("email_bot_id")
if email_bot_id:
email_bot = await session.get(EmailBot, email_bot_id)
if email_bot:
target_config["smtp"] = {
"host": email_bot.smtp_host,
"port": email_bot.smtp_port,
"username": email_bot.smtp_username,
"password": email_bot.smtp_password,
"from_address": email_bot.email,
"from_name": email_bot.name,
"use_tls": email_bot.smtp_use_tls,
}
elif target.type == "matrix":
matrix_bot_id = target.config.get("matrix_bot_id")
if matrix_bot_id:
matrix_bot = await session.get(MatrixBot, matrix_bot_id)
if matrix_bot:
target_config["homeserver_url"] = matrix_bot.homeserver_url
target_config["access_token"] = matrix_bot.access_token
link_data.append({
"target_type": target.type,
"target_config": target_config,
"receivers": receivers,
"tracking_config": tracking_config,
"template_config": template_config,
"template_slots": template_slots,
})
return link_data
def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by tracking config flags."""
event_type = event.event_type.value
flag_map = {
"push": tc.track_push,
"issue_opened": tc.track_issue_opened,
"issue_closed": tc.track_issue_closed,
"issue_commented": tc.track_issue_commented,
"pr_opened": tc.track_pr_opened,
"pr_closed": tc.track_pr_closed,
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,
"collection_renamed": tc.track_collection_renamed,
"collection_deleted": tc.track_collection_deleted,
"sharing_changed": tc.track_sharing_changed,
}
return flag_map.get(event_type, True)
def _build_target_configs(
event: ServiceEvent,
link_data: list[dict[str, Any]],
@@ -301,7 +183,7 @@ def _build_target_configs(
target_configs: list[TargetConfig] = []
for ld in link_data:
tc = ld["tracking_config"]
if tc and not _event_allowed_by_tracking_config(event, tc):
if tc and not event_allowed_by_config(event, tc):
continue
tmpl = ld["template_config"]