Comprehensive review fixes: security, performance, code quality, and UI polish
Some checks failed
Validate / Hassfest (push) Has been cancelled

Backend: Fix CORS wildcard+credentials, add secret key warning, remove raw
API keys from sync endpoint, fix N+1 queries in watcher/sync, fix
AttributeError on event_types, delete dead scheduled.py/templates.py,
add limit cap on history, re-validate server on URL/key update, apply
tracking/template config IDs in update_target.

HA Integration: Replace datetime.now() with dt_util.now(), fix notification
queue to only remove successfully sent items, use album UUID for entity
unique IDs, add shared links dirty flag and users cache hourly refresh,
deduplicate _is_quiet_hours, add HTTP timeouts, cache albums in config
flow, change iot_class to local_polling.

Frontend: Make i18n reactive via $state (remove window.location.reload),
add Modal transitions/a11y/Escape key, create ConfirmModal replacing all
confirm() calls, add error handling to all pages, replace Unicode nav
icons with MDI SVGs, add card hover effects, dashboard stat icons, global
focus-visible styles, form slide transitions, mobile responsive bottom
nav, fix password error color, add ~20 i18n keys (EN/RU).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:34:31 +03:00
parent a04d5618d0
commit 381de98c40
39 changed files with 785 additions and 626 deletions

View File

@@ -164,7 +164,7 @@ async def _build_context(session: AsyncSession, chat_id: str) -> str:
if trackers:
parts.append(f"Active trackers: {len(trackers)}")
for t in trackers[:5]:
parts.append(f" - {t.name}: {len(t.album_ids)} album(s), events: {', '.join(t.event_types)}")
parts.append(f" - {t.name}: {len(t.album_ids)} album(s)")
result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(5)
@@ -184,15 +184,19 @@ async def _get_summary_data(
"""Fetch data for album summary."""
albums_data: list[dict[str, Any]] = []
servers_result = await session.exec(select(ImmichServer).limit(5))
for server in servers_result.all():
try:
from immich_watcher_core.immich_client import ImmichClient
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
albums_data.extend(albums[:20])
except Exception:
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
servers = servers_result.all()
try:
from immich_watcher_core.immich_client import ImmichClient
async with aiohttp.ClientSession() as http_session:
for server in servers:
try:
client = ImmichClient(http_session, server.url, server.api_key)
albums = await client.get_albums()
albums_data.extend(albums[:20])
except Exception:
_LOGGER.debug("Failed to fetch albums from %s for summary", server.url)
except Exception:
_LOGGER.debug("Failed to create HTTP session for summary")
events_result = await session.exec(
select(EventLog).order_by(EventLog.created_at.desc()).limit(20)

View File

@@ -1,152 +0,0 @@
"""Scheduled notification job API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import AlbumTracker, ScheduledJob, User
router = APIRouter(prefix="/api/scheduled", tags=["scheduled"])
class ScheduledJobCreate(BaseModel):
tracker_id: int
job_type: str # periodic_summary, scheduled_assets, memory
enabled: bool = True
times: str = "09:00"
interval_days: int = 1
start_date: str = "2025-01-01"
album_mode: str = "per_album"
limit: int = 10
favorite_only: bool = False
asset_type: str = "all"
min_rating: int = 0
order_by: str = "random"
order: str = "descending"
min_date: str | None = None
max_date: str | None = None
message_template: str = ""
class ScheduledJobUpdate(BaseModel):
enabled: bool | None = None
times: str | None = None
interval_days: int | None = None
album_mode: str | None = None
limit: int | None = None
favorite_only: bool | None = None
asset_type: str | None = None
min_rating: int | None = None
order_by: str | None = None
order: str | None = None
min_date: str | None = None
max_date: str | None = None
message_template: str | None = None
@router.get("")
async def list_jobs(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all scheduled jobs for the current user's trackers."""
trackers = await session.exec(
select(AlbumTracker).where(AlbumTracker.user_id == user.id)
)
tracker_ids = [t.id for t in trackers.all()]
if not tracker_ids:
return []
result = await session.exec(
select(ScheduledJob).where(ScheduledJob.tracker_id.in_(tracker_ids))
)
return [_job_response(j) for j in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_job(
body: ScheduledJobCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a scheduled notification job."""
# Verify tracker ownership
tracker = await session.get(AlbumTracker, body.tracker_id)
if not tracker or tracker.user_id != user.id:
raise HTTPException(status_code=404, detail="Tracker not found")
if body.job_type not in ("periodic_summary", "scheduled_assets", "memory"):
raise HTTPException(status_code=400, detail="Invalid job_type")
job = ScheduledJob(**body.model_dump())
session.add(job)
await session.commit()
await session.refresh(job)
return _job_response(job)
@router.put("/{job_id}")
async def update_job(
job_id: int,
body: ScheduledJobUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a scheduled job."""
job = await _get_user_job(session, job_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(job, field, value)
session.add(job)
await session.commit()
await session.refresh(job)
return _job_response(job)
@router.delete("/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_job(
job_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a scheduled job."""
job = await _get_user_job(session, job_id, user.id)
await session.delete(job)
await session.commit()
def _job_response(j: ScheduledJob) -> dict:
return {
"id": j.id,
"tracker_id": j.tracker_id,
"job_type": j.job_type,
"enabled": j.enabled,
"times": j.times,
"interval_days": j.interval_days,
"start_date": j.start_date,
"album_mode": j.album_mode,
"limit": j.limit,
"favorite_only": j.favorite_only,
"asset_type": j.asset_type,
"min_rating": j.min_rating,
"order_by": j.order_by,
"order": j.order,
"min_date": j.min_date,
"max_date": j.max_date,
"message_template": j.message_template,
"created_at": j.created_at.isoformat(),
}
async def _get_user_job(
session: AsyncSession, job_id: int, user_id: int
) -> ScheduledJob:
job = await session.get(ScheduledJob, job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
tracker = await session.get(AlbumTracker, job.tracker_id)
if not tracker or tracker.user_id != user_id:
raise HTTPException(status_code=404, detail="Job not found")
return job

View File

@@ -104,10 +104,28 @@ async def update_server(
server = await _get_user_server(session, server_id, user.id)
if body.name is not None:
server.name = body.name
url_changed = body.url is not None and body.url != server.url
key_changed = body.api_key is not None and body.api_key != server.api_key
if body.url is not None:
server.url = body.url
if body.api_key is not None:
server.api_key = body.api_key
# Re-validate and refresh external_domain when URL or API key changes
if url_changed or key_changed:
try:
async with aiohttp.ClientSession() as http_session:
client = ImmichClient(http_session, server.url, server.api_key)
if not await client.ping():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot connect to Immich server at {server.url}",
)
server.external_domain = await client.get_server_config()
except aiohttp.ClientError as err:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Connection error: {err}",
)
session.add(server)
await session.commit()
await session.refresh(server)

View File

@@ -52,12 +52,9 @@ class SyncTrackerResponse(BaseModel):
id: int
name: str
server_url: str
server_api_key: str
album_ids: list[str]
event_types: list[str]
scan_interval: int
enabled: bool
template_body: str | None = None
targets: list[dict] = []
@@ -84,40 +81,60 @@ async def get_sync_trackers(
)
trackers = result.all()
# Batch-load servers and targets to avoid N+1 queries
server_ids = {t.server_id for t in trackers}
all_target_ids = {tid for t in trackers for tid in t.target_ids}
servers_result = await session.exec(
select(ImmichServer).where(ImmichServer.id.in_(server_ids))
)
servers_map = {s.id: s for s in servers_result.all()}
targets_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(all_target_ids))
)
targets_map = {t.id: t for t in targets_result.all()}
responses = []
for tracker in trackers:
# Fetch server details
server = await session.get(ImmichServer, tracker.server_id)
server = servers_map.get(tracker.server_id)
if not server:
continue
# Fetch target configs
targets = []
for target_id in tracker.target_ids:
target = await session.get(NotificationTarget, target_id)
target = targets_map.get(target_id)
if target:
targets.append({
"type": target.type,
"name": target.name,
"config": target.config,
"config": _safe_target_config(target),
})
responses.append(SyncTrackerResponse(
id=tracker.id,
name=tracker.name,
server_url=server.url,
server_api_key=server.api_key,
album_ids=tracker.album_ids,
event_types=[], # Event types now on tracking configs
scan_interval=tracker.scan_interval,
enabled=tracker.enabled,
template_body=None,
targets=targets,
))
return responses
def _safe_target_config(target: NotificationTarget) -> dict:
"""Return config with sensitive fields masked."""
config = dict(target.config)
if "bot_token" in config:
token = config["bot_token"]
config["bot_token"] = f"{token[:8]}...{token[-4:]}" if len(token) > 12 else "***"
if "api_key" in config:
config["api_key"] = "***"
return config
@router.post("/templates/{template_id}/render")
async def render_template(
template_id: int,

View File

@@ -92,6 +92,10 @@ async def update_target(
target.name = body.name
if body.config is not None:
target.config = body.config
if body.tracking_config_id is not None:
target.tracking_config_id = body.tracking_config_id
if body.template_config_id is not None:
target.template_config_id = body.template_config_id
session.add(target)
await session.commit()
await session.refresh(target)

View File

@@ -97,8 +97,9 @@ async def get_bot_token(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get the full bot token (for internal use by targets)."""
"""Get the full bot token (used by frontend to construct target config)."""
bot = await _get_user_bot(session, bot_id, user.id)
# Token is returned only to the authenticated owner
return {"token": bot.token}

View File

@@ -1,145 +0,0 @@
"""Message template management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
import jinja2
from jinja2.sandbox import SandboxedEnvironment
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import MessageTemplate, User
router = APIRouter(prefix="/api/templates", tags=["templates"])
# Sample data for template preview
_SAMPLE_CONTEXT = {
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
"added_count": 3,
"removed_count": 0,
"change_type": "assets_added",
"people": ["Alice", "Bob"],
"added_assets": [
{"filename": "IMG_001.jpg", "type": "IMAGE", "owner": "Alice", "created_at": "2024-03-19T10:30:00Z"},
{"filename": "IMG_002.jpg", "type": "IMAGE", "owner": "Bob", "created_at": "2024-03-19T11:00:00Z"},
{"filename": "VID_003.mp4", "type": "VIDEO", "owner": "Alice", "created_at": "2024-03-19T11:30:00Z"},
],
}
class TemplateCreate(BaseModel):
name: str
body: str
is_default: bool = False
class TemplateUpdate(BaseModel):
name: str | None = None
body: str | None = None
is_default: bool | None = None
@router.get("")
async def list_templates(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""List all templates for the current user."""
result = await session.exec(
select(MessageTemplate).where(MessageTemplate.user_id == user.id)
)
return [
{"id": t.id, "name": t.name, "body": t.body, "is_default": t.is_default, "created_at": t.created_at.isoformat()}
for t in result.all()
]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_template(
body: TemplateCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Create a new message template."""
template = MessageTemplate(
user_id=user.id,
name=body.name,
body=body.body,
is_default=body.is_default,
)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.get("/{template_id}")
async def get_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Get a specific template."""
template = await _get_user_template(session, template_id, user.id)
return {"id": template.id, "name": template.name, "body": template.body, "is_default": template.is_default}
@router.put("/{template_id}")
async def update_template(
template_id: int,
body: TemplateUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Update a template."""
template = await _get_user_template(session, template_id, user.id)
for field, value in body.model_dump(exclude_unset=True).items():
setattr(template, field, value)
session.add(template)
await session.commit()
await session.refresh(template)
return {"id": template.id, "name": template.name}
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Delete a template."""
template = await _get_user_template(session, template_id, user.id)
await session.delete(template)
await session.commit()
@router.post("/{template_id}/preview")
async def preview_template(
template_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
"""Render a template with sample data."""
template = await _get_user_template(session, template_id, user.id)
try:
env = SandboxedEnvironment(autoescape=False)
tmpl = env.from_string(template.body)
rendered = tmpl.render(**_SAMPLE_CONTEXT)
return {"rendered": rendered}
except jinja2.TemplateError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Template error: {e}",
)
async def _get_user_template(
session: AsyncSession, template_id: int, user_id: int
) -> MessageTemplate:
template = await session.get(MessageTemplate, template_id)
if not template or template.user_id != user_id:
raise HTTPException(status_code=404, detail="Template not found")
return template

View File

@@ -1,6 +1,6 @@
"""Album tracker management API routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -150,7 +150,7 @@ async def test_memory(
@router.get("/{tracker_id}/history")
async def tracker_history(
tracker_id: int,
limit: int = 20,
limit: int = Query(default=20, ge=1, le=500),
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):

View File

@@ -1,6 +1,7 @@
"""Server configuration from environment variables."""
from pathlib import Path
from typing import Any
from pydantic_settings import BaseSettings
@@ -11,8 +12,16 @@ class Settings(BaseSettings):
data_dir: Path = Path("/data")
database_url: str = "" # Computed from data_dir if empty
# JWT
# JWT (MUST be overridden via IMMICH_WATCHER_SECRET_KEY env var)
secret_key: str = "change-me-in-production"
def model_post_init(self, __context: Any) -> None:
if self.secret_key == "change-me-in-production" and not self.debug:
import logging
logging.getLogger(__name__).critical(
"SECURITY: Using default secret_key in non-debug mode! "
"Set IMMICH_WATCHER_SECRET_KEY environment variable."
)
access_token_expire_minutes: int = 60
refresh_token_expire_days: int = 30

View File

@@ -56,11 +56,11 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS for frontend dev server
# CORS: restrict to same-origin in production, allow all in debug mode
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_origins=["*"] if settings.debug else [],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

View File

@@ -166,16 +166,28 @@ async def _check_album(
)
session.add(event_log)
# Send notifications to each target, filtered by its tracking config
for target_id in target_ids:
target = await session.get(NotificationTarget, target_id)
if not target:
continue
# Batch-load targets, tracking configs, and template configs
targets_result = await session.exec(
select(NotificationTarget).where(NotificationTarget.id.in_(target_ids))
)
targets = targets_result.all()
# Check target's tracking config for event filtering
tracking_config = None
if target.tracking_config_id:
tracking_config = await session.get(TrackingConfig, target.tracking_config_id)
tc_ids = {t.tracking_config_id for t in targets if t.tracking_config_id}
tmpl_ids = {t.template_config_id for t in targets if t.template_config_id}
tracking_configs_map: dict[int, TrackingConfig] = {}
if tc_ids:
tc_result = await session.exec(select(TrackingConfig).where(TrackingConfig.id.in_(tc_ids)))
tracking_configs_map = {tc.id: tc for tc in tc_result.all()}
template_configs_map: dict[int, TemplateConfig] = {}
if tmpl_ids:
tmpl_result = await session.exec(select(TemplateConfig).where(TemplateConfig.id.in_(tmpl_ids)))
template_configs_map = {tc.id: tc for tc in tmpl_result.all()}
# Send notifications to each target, filtered by its tracking config
for target in targets:
tracking_config = tracking_configs_map.get(target.tracking_config_id) if target.tracking_config_id else None
if tracking_config:
# Filter by event type
@@ -193,16 +205,13 @@ async def _check_album(
if not should_notify:
continue
# Get target's template config
template_config = None
if target.template_config_id:
template_config = await session.get(TemplateConfig, target.template_config_id)
template_config = template_configs_map.get(target.template_config_id) if target.template_config_id else None
try:
use_ai = target.config.get("ai_captions", False)
await send_notification(target, event_data, template_config, use_ai_caption=use_ai)
except Exception:
_LOGGER.exception("Failed to send notification to target %d", target_id)
_LOGGER.exception("Failed to send notification to target %d", target.id)
return {
"album_id": album_id,