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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user