From 381de98c40a7542cea274b7c0690527b9c53e900 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 19 Mar 2026 18:34:31 +0300 Subject: [PATCH] Comprehensive review fixes: security, performance, code quality, and UI polish 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) --- .../immich_album_watcher/__init__.py | 9 +- .../immich_album_watcher/binary_sensor.py | 8 +- .../immich_album_watcher/button.py | 8 +- .../immich_album_watcher/camera.py | 6 +- .../immich_album_watcher/config_flow.py | 40 ++--- .../immich_album_watcher/coordinator.py | 30 ++-- .../immich_album_watcher/manifest.json | 2 +- .../immich_album_watcher/sensor.py | 26 +-- .../immich_album_watcher/text.py | 2 +- frontend/src/app.css | 18 +++ frontend/src/lib/components/Card.svelte | 5 +- .../src/lib/components/ConfirmModal.svelte | 26 +++ frontend/src/lib/components/IconPicker.svelte | 15 +- frontend/src/lib/components/Modal.svelte | 39 ++++- frontend/src/lib/i18n/en.json | 35 +++- frontend/src/lib/i18n/index.svelte.ts | 62 +++++++ frontend/src/lib/i18n/index.ts | 75 +-------- frontend/src/lib/i18n/ru.json | 35 +++- frontend/src/routes/+layout.svelte | 107 ++++++------ frontend/src/routes/+page.svelte | 66 ++++++-- frontend/src/routes/login/+page.svelte | 2 +- frontend/src/routes/servers/+page.svelte | 38 ++++- frontend/src/routes/targets/+page.svelte | 68 ++++++-- .../src/routes/telegram-bots/+page.svelte | 25 ++- .../src/routes/template-configs/+page.svelte | 30 +++- frontend/src/routes/trackers/+page.svelte | 112 +++++++++++-- .../src/routes/tracking-configs/+page.svelte | 40 +++-- frontend/src/routes/users/+page.svelte | 39 +++-- .../ai/telegram_webhook.py | 24 +-- .../immich_watcher_server/api/scheduled.py | 152 ------------------ .../src/immich_watcher_server/api/servers.py | 18 +++ .../src/immich_watcher_server/api/sync.py | 39 +++-- .../src/immich_watcher_server/api/targets.py | 4 + .../api/telegram_bots.py | 3 +- .../immich_watcher_server/api/templates.py | 145 ----------------- .../src/immich_watcher_server/api/trackers.py | 4 +- .../src/immich_watcher_server/config.py | 11 +- .../server/src/immich_watcher_server/main.py | 6 +- .../immich_watcher_server/services/watcher.py | 37 +++-- 39 files changed, 785 insertions(+), 626 deletions(-) create mode 100644 frontend/src/lib/components/ConfirmModal.svelte create mode 100644 frontend/src/lib/i18n/index.svelte.ts delete mode 100644 packages/server/src/immich_watcher_server/api/scheduled.py delete mode 100644 packages/server/src/immich_watcher_server/api/templates.py diff --git a/custom_components/immich_album_watcher/__init__.py b/custom_components/immich_album_watcher/__init__.py index 53f8e25..b272357 100644 --- a/custom_components/immich_album_watcher/__init__.py +++ b/custom_components/immich_album_watcher/__init__.py @@ -354,6 +354,7 @@ async def _send_queued_items( items = queue.get_all() sent_count = 0 + sent_indices = [] for i in indices: if i >= len(items): continue @@ -371,14 +372,16 @@ async def _send_queued_items( blocking=True, ) sent_count += 1 + sent_indices.append(i) except Exception: _LOGGER.exception("Failed to send queued notification %d", i + 1) # Small delay between notifications to avoid rate limiting await asyncio.sleep(1) - # Remove sent items from queue (in reverse order to preserve indices) - await queue.async_remove_indices(sorted(indices, reverse=True)) + # Only remove successfully sent items (in reverse order to preserve indices) + if sent_indices: + await queue.async_remove_indices(sorted(sent_indices, reverse=True)) _LOGGER.info("Sent %d/%d queued notifications", sent_count, len(indices)) @@ -419,7 +422,7 @@ async def _async_update_listener( subentries_data = entry_data["subentries"] for subentry_data in subentries_data.values(): subentry_data.coordinator.update_scan_interval(new_interval) - subentry_data.coordinator._sync_client = sync_client + subentry_data.coordinator.update_sync_client(sync_client) _LOGGER.info("Updated hub options (scan_interval=%d)", new_interval) diff --git a/custom_components/immich_album_watcher/binary_sensor.py b/custom_components/immich_album_watcher/binary_sensor.py index 0f7c08b..fb26c48 100644 --- a/custom_components/immich_album_watcher/binary_sensor.py +++ b/custom_components/immich_album_watcher/binary_sensor.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from datetime import timedelta + +from homeassistant.util import dt as dt_util from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -74,7 +76,7 @@ class ImmichAlbumNewAssetsSensor( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_new_assets" @property @@ -93,7 +95,7 @@ class ImmichAlbumNewAssetsSensor( # Check if we're still within the reset window if self._album_data.last_change_time: - elapsed = datetime.now() - self._album_data.last_change_time + elapsed = dt_util.now() - self._album_data.last_change_time if elapsed > timedelta(seconds=NEW_ASSETS_RESET_DELAY): # Auto-reset the flag self.coordinator.clear_new_assets_flag() diff --git a/custom_components/immich_album_watcher/button.py b/custom_components/immich_album_watcher/button.py index 953efd2..da67d73 100644 --- a/custom_components/immich_album_watcher/button.py +++ b/custom_components/immich_album_watcher/button.py @@ -75,7 +75,7 @@ class ImmichCreateShareLinkButton( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_create_share_link" @property @@ -158,7 +158,7 @@ class ImmichDeleteShareLinkButton( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_delete_share_link" @property @@ -248,7 +248,7 @@ class ImmichCreateProtectedLinkButton( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_create_protected_link" @property @@ -335,7 +335,7 @@ class ImmichDeleteProtectedLinkButton( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_delete_protected_link" @property diff --git a/custom_components/immich_album_watcher/camera.py b/custom_components/immich_album_watcher/camera.py index 43eeeb7..d6042b4 100644 --- a/custom_components/immich_album_watcher/camera.py +++ b/custom_components/immich_album_watcher/camera.py @@ -22,7 +22,7 @@ from .coordinator import AlbumData, ImmichAlbumWatcherCoordinator _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=60) +_THUMBNAIL_TIMEOUT = aiohttp.ClientTimeout(total=10) async def async_setup_entry( @@ -68,7 +68,7 @@ class ImmichAlbumThumbnailCamera( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_thumbnail" self._cached_image: bytes | None = None self._last_thumbnail_id: str | None = None @@ -131,7 +131,7 @@ class ImmichAlbumThumbnailCamera( ) try: - async with session.get(thumbnail_url, headers=headers) as response: + async with session.get(thumbnail_url, headers=headers, timeout=_THUMBNAIL_TIMEOUT) as response: if response.status == 200: self._cached_image = await response.read() self._last_thumbnail_id = self._album_data.thumbnail_asset_id diff --git a/custom_components/immich_album_watcher/config_flow.py b/custom_components/immich_album_watcher/config_flow.py index e7b4d7f..82387ad 100644 --- a/custom_components/immich_album_watcher/config_flow.py +++ b/custom_components/immich_album_watcher/config_flow.py @@ -39,13 +39,16 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +_CONNECT_TIMEOUT = aiohttp.ClientTimeout(total=10) + + async def validate_connection( session: aiohttp.ClientSession, url: str, api_key: str ) -> dict[str, Any]: """Validate the Immich connection and return server info.""" headers = {"x-api-key": api_key} async with session.get( - f"{url.rstrip('/')}/api/server/ping", headers=headers + f"{url.rstrip('/')}/api/server/ping", headers=headers, timeout=_CONNECT_TIMEOUT ) as response: if response.status == 401: raise InvalidAuth @@ -169,23 +172,7 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow): url = config_entry.data[CONF_IMMICH_URL] api_key = config_entry.data[CONF_API_KEY] - # Fetch available albums - session = async_get_clientsession(self.hass) - try: - self._albums = await fetch_albums(session, url, api_key) - except Exception: - _LOGGER.exception("Failed to fetch albums") - errors["base"] = "cannot_connect" - return self.async_show_form( - step_id="user", - data_schema=vol.Schema({}), - errors=errors, - ) - - if not self._albums: - return self.async_abort(reason="no_albums") - - if user_input is not None: + if user_input is not None and self._albums: album_id = user_input[CONF_ALBUM_ID] # Check if album is already configured @@ -208,6 +195,23 @@ class ImmichAlbumSubentryFlowHandler(ConfigSubentryFlow): }, ) + # Fetch available albums (only when displaying the form) + if not self._albums: + session = async_get_clientsession(self.hass) + try: + self._albums = await fetch_albums(session, url, api_key) + except Exception: + _LOGGER.exception("Failed to fetch albums") + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({}), + errors=errors, + ) + + if not self._albums: + return self.async_abort(reason="no_albums") + # Get already configured album IDs configured_albums = set() for subentry in config_entry.subentries.values(): diff --git a/custom_components/immich_album_watcher/coordinator.py b/custom_components/immich_album_watcher/coordinator.py index 3ff8a67..577b077 100644 --- a/custom_components/immich_album_watcher/coordinator.py +++ b/custom_components/immich_album_watcher/coordinator.py @@ -3,15 +3,14 @@ from __future__ import annotations import logging -from datetime import datetime, timedelta +from datetime import timedelta from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .storage import ImmichAlbumStorage -import aiohttp - from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -122,7 +121,9 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): # Caches managed by the client self._users_cache: dict[str, str] = {} + self._users_cache_time: float = 0 self._shared_links: list[SharedLinkInfo] = [] + self._shared_links_dirty = True self._server_config_fetched = False def _get_client(self) -> ImmichClient: @@ -181,6 +182,10 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): """Update the scan interval.""" self.update_interval = timedelta(seconds=scan_interval) + def update_sync_client(self, sync_client: Any | None) -> None: + """Update the server sync client.""" + self._sync_client = sync_client + async def async_refresh_now(self) -> None: """Force an immediate refresh.""" await self.async_request_refresh() @@ -321,6 +326,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): result = await client.create_shared_link(self._album_id, password) if result: self._shared_links = await client.get_shared_links(self._album_id) + self._shared_links_dirty = False return result async def async_delete_shared_link(self, link_id: str) -> bool: @@ -329,6 +335,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): result = await client.delete_shared_link(link_id) if result: self._shared_links = await client.get_shared_links(self._album_id) + self._shared_links_dirty = False return result async def async_set_shared_link_password( @@ -339,6 +346,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): result = await client.set_shared_link_password(link_id, password) if result: self._shared_links = await client.get_shared_links(self._album_id) + self._shared_links_dirty = False return result def clear_new_assets_flag(self) -> None: @@ -358,12 +366,16 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): await client.get_server_config() self._server_config_fetched = True - # Fetch users to resolve owner names - if not self._users_cache: + # Fetch users to resolve owner names (refresh every hour) + import time + if not self._users_cache or (time.monotonic() - self._users_cache_time > 3600): self._users_cache = await client.get_users() + self._users_cache_time = time.monotonic() - # Fetch shared links (refresh each time as links can change) - self._shared_links = await client.get_shared_links(self._album_id) + # Fetch shared links only when needed (on first load or after mutations) + if self._shared_links_dirty: + self._shared_links = await client.get_shared_links(self._album_id) + self._shared_links_dirty = False try: album = await client.get_album(self._album_id, self._users_cache) @@ -389,7 +401,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): ) if change: album.has_new_assets = change.added_count > 0 - album.last_change_time = datetime.now() + album.last_change_time = dt_util.now() self._fire_events(change, album) elif self._persisted_asset_ids is not None: # First refresh after restart - compare with persisted state @@ -423,7 +435,7 @@ class ImmichAlbumWatcherCoordinator(DataUpdateCoordinator[AlbumData | None]): removed_asset_ids=list(removed_ids), ) album.has_new_assets = change.added_count > 0 - album.last_change_time = datetime.now() + album.last_change_time = dt_util.now() self._fire_events(change, album) _LOGGER.info( "Detected changes during downtime for album '%s': +%d -%d", diff --git a/custom_components/immich_album_watcher/manifest.json b/custom_components/immich_album_watcher/manifest.json index 1507659..047b6ef 100644 --- a/custom_components/immich_album_watcher/manifest.json +++ b/custom_components/immich_album_watcher/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "dependencies": [], "documentation": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher", - "iot_class": "cloud_polling", + "iot_class": "local_polling", "issue_tracker": "https://github.com/DolgolyovAlexei/haos-hacs-immich-album-watcher/issues", "requirements": ["immich-watcher-core==0.1.0"], "version": "2.8.0" diff --git a/custom_components/immich_album_watcher/sensor.py b/custom_components/immich_album_watcher/sensor.py index 6c1c2d1..c9b1486 100644 --- a/custom_components/immich_album_watcher/sensor.py +++ b/custom_components/immich_album_watcher/sensor.py @@ -174,7 +174,7 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - self._unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + self._unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") @property def _album_data(self) -> AlbumData | None: @@ -239,27 +239,6 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se ) return {"assets": assets} - @staticmethod - def _is_quiet_hours(start_str: str | None, end_str: str | None) -> bool: - """Check if current time is within quiet hours.""" - from datetime import time as dt_time - from homeassistant.util import dt as dt_util - - if not start_str or not end_str: - return False - - try: - now = dt_util.now().time() - start_time = dt_time.fromisoformat(start_str) - end_time = dt_time.fromisoformat(end_str) - except ValueError: - return False - - if start_time <= end_time: - return start_time <= now < end_time - else: - return now >= start_time or now < end_time - async def async_send_telegram_notification( self, chat_id: str, @@ -280,7 +259,8 @@ class ImmichAlbumBaseSensor(CoordinatorEntity[ImmichAlbumWatcherCoordinator], Se ) -> ServiceResponse: """Send notification to Telegram.""" # Check quiet hours — queue notification if active - if self._is_quiet_hours(quiet_hours_start, quiet_hours_end): + from . import _is_quiet_hours + if _is_quiet_hours(quiet_hours_start, quiet_hours_end): from . import _register_queue_timers queue: NotificationQueue = self.hass.data[DOMAIN][self._entry.entry_id]["notification_queue"] await queue.async_enqueue({ diff --git a/custom_components/immich_album_watcher/text.py b/custom_components/immich_album_watcher/text.py index fdffa96..7e8a415 100644 --- a/custom_components/immich_album_watcher/text.py +++ b/custom_components/immich_album_watcher/text.py @@ -71,7 +71,7 @@ class ImmichAlbumProtectedPasswordText( self._album_id = subentry.data[CONF_ALBUM_ID] self._album_name = subentry.data.get(CONF_ALBUM_NAME, "Unknown Album") self._hub_name = entry.data.get(CONF_HUB_NAME, "Immich") - unique_id_prefix = slugify(f"{self._hub_name}_album_{self._album_name}") + unique_id_prefix = slugify(f"{self._hub_name}_{self._album_id}") self._attr_unique_id = f"{unique_id_prefix}_protected_password_edit" @property diff --git a/frontend/src/app.css b/frontend/src/app.css index 5aef0c0..7668dd3 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -59,6 +59,24 @@ input, select, textarea { border-color: var(--color-border); } +/* Global focus-visible styles for accessibility */ +input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +button:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 0.25rem; +} + +a:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 0.25rem; +} + /* Override browser autofill styles in dark mode */ [data-theme="dark"] input:-webkit-autofill, [data-theme="dark"] input:-webkit-autofill:hover, diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index 9bf4981..f66f700 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -1,10 +1,11 @@ -
+
{@render children()}
diff --git a/frontend/src/lib/components/ConfirmModal.svelte b/frontend/src/lib/components/ConfirmModal.svelte new file mode 100644 index 0000000..1dbdb02 --- /dev/null +++ b/frontend/src/lib/components/ConfirmModal.svelte @@ -0,0 +1,26 @@ + + + +

{message}

+
+ + +
+
diff --git a/frontend/src/lib/components/IconPicker.svelte b/frontend/src/lib/components/IconPicker.svelte index 465e730..0524730 100644 --- a/frontend/src/lib/components/IconPicker.svelte +++ b/frontend/src/lib/components/IconPicker.svelte @@ -50,8 +50,17 @@ open = false; search = ''; } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape' && open) { + open = false; + search = ''; + } + } + +
{#if open} - - -
{ open = false; search = ''; }}>
+
{ open = false; search = ''; }}>
diff --git a/frontend/src/lib/components/Modal.svelte b/frontend/src/lib/components/Modal.svelte index bff498b..7b6dc78 100644 --- a/frontend/src/lib/components/Modal.svelte +++ b/frontend/src/lib/components/Modal.svelte @@ -1,27 +1,50 @@ + + {#if open} - - + + + -
-
+
+
{@render children()}
+{:else} + +
+

{t('common.loading')}

+
{/if} - - { showPasswordForm = false; pwdMsg = ''; }}> + + { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; }}>
- +
- +
{#if pwdMsg} -

{pwdMsg}

+

{pwdMsg}

{/if} -
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 332cef4..24b85b5 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -5,35 +5,79 @@ import PageHeader from '$lib/components/PageHeader.svelte'; import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; + import MdiIcon from '$lib/components/MdiIcon.svelte'; let status = $state(null); let loaded = $state(false); - onMount(async () => { try { status = await api('/status'); } catch {} finally { loaded = true; } }); + let error = $state(''); + + onMount(async () => { + try { + status = await api('/status'); + } catch (err: any) { + error = err.message || t('common.error'); + } finally { + loaded = true; + } + }); {#if !loaded} +{:else if error} + +
+ +

{error}

+
+
{:else if status}
- -

{t('dashboard.servers')}

-

{status.servers}

+ +
+
+ +
+
+

{t('dashboard.servers')}

+

{status.servers}

+
+
- -

{t('dashboard.activeTrackers')}

-

{status.trackers.active} / {status.trackers.total}

+ +
+
+ +
+
+

{t('dashboard.activeTrackers')}

+

{status.trackers.active} / {status.trackers.total}

+
+
- -

{t('dashboard.targets')}

-

{status.targets}

+ +
+
+ +
+
+

{t('dashboard.targets')}

+

{status.targets}

+
+

{t('dashboard.recentEvents')}

{#if status.recent_events.length === 0} -

{t('dashboard.noEvents')}

+ +
+ +

{t('dashboard.noEvents')}

+
+
{:else}
diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index e739635..7eeed97 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -39,7 +39,7 @@
- diff --git a/frontend/src/routes/servers/+page.svelte b/frontend/src/routes/servers/+page.svelte index d2d04c6..32bda06 100644 --- a/frontend/src/routes/servers/+page.svelte +++ b/frontend/src/routes/servers/+page.svelte @@ -1,5 +1,6 @@ @@ -69,7 +84,14 @@ {:else} +{#if loadError} + +
{loadError}
+
+{/if} + {#if showForm} +
{#if error}
{error}
{/if}
@@ -95,6 +117,7 @@
+
{/if} {#if servers.length === 0 && !showForm} @@ -102,11 +125,11 @@ {:else}
{#each servers as server} - +
+ title={health[server.id] === true ? t('servers.online') : health[server.id] === false ? t('servers.offline') : t('servers.checking')}> {#if server.icon}{/if}

{server.name}

@@ -115,7 +138,7 @@
- +
@@ -124,3 +147,6 @@ {/if} {/if} + + confirmDelete = null} /> diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index fc63f97..c9a3a69 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -1,5 +1,6 @@ @@ -103,11 +118,16 @@ {#if !loaded}{:else} +{#if loadError} +
{loadError}
+{/if} + {#if testResult}
{testResult}
{/if} {#if showForm} +
{#if error}
{error}
{/if}
@@ -163,9 +183,14 @@ {/if} -
- {t('targets.telegramSettings')} -
+
+ + {#if showTelegramSettings} +
@@ -185,12 +210,18 @@
-
+ {/if} +
{:else}
+
+ + + {#if headersError}

{headersError}

{/if} +
{/if} @@ -198,14 +229,14 @@
@@ -216,6 +247,7 @@ +
{/if} {#if targets.length === 0 && !showForm} @@ -223,7 +255,7 @@ {:else}
{#each targets as target} - +
@@ -238,7 +270,7 @@
- +
@@ -247,3 +279,11 @@ {/if} {/if} + + { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }} + oncancel={() => confirmDelete = null} +/> diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte index cf9f4ae..7efa0c7 100644 --- a/frontend/src/routes/telegram-bots/+page.svelte +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -7,6 +7,7 @@ import Loading from '$lib/components/Loading.svelte'; import IconPicker from '$lib/components/IconPicker.svelte'; import MdiIcon from '$lib/components/MdiIcon.svelte'; + import ConfirmModal from '$lib/components/ConfirmModal.svelte'; let bots = $state([]); let loaded = $state(false); @@ -14,6 +15,7 @@ let form = $state({ name: '', icon: '', token: '' }); let error = $state(''); let submitting = $state(false); + let confirmDelete = $state(null); // Per-bot chat lists let chats = $state>({}); @@ -21,7 +23,11 @@ let expandedBot = $state(null); onMount(load); - async function load() { try { bots = await api('/telegram-bots'); } catch {} finally { loaded = true; } } + async function load() { + try { bots = await api('/telegram-bots'); } + catch (err: any) { error = err.message || t('common.loadError'); } + finally { loaded = true; } + } async function create(e: SubmitEvent) { e.preventDefault(); error = ''; submitting = true; @@ -32,9 +38,15 @@ submitting = false; } - async function remove(id: number) { - if (!confirm(t('common.delete') + '?')) return; - try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { error = err.message; } + function remove(id: number) { + confirmDelete = { + id, + onconfirm: async () => { + try { await api(`/telegram-bots/${id}`, { method: 'DELETE' }); await load(); } + catch (err: any) { error = err.message; } + finally { confirmDelete = null; } + } + }; } async function loadChats(botId: number) { @@ -96,7 +108,7 @@ {:else}
{#each bots as bot} - +
@@ -149,3 +161,6 @@ {/if} {/if} + + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index fb9bb6a..2aae239 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -1,5 +1,6 @@ @@ -116,6 +129,7 @@ {#if !loaded}{:else} {#if showForm} +
{#if error}
{error}
{/if}
@@ -135,7 +149,7 @@ {#each group.slots as slot}
-
{/each} @@ -148,6 +162,7 @@
+
{/if} {#if configs.length === 0 && !showForm} @@ -155,7 +170,7 @@ {:else}
{#each configs as config} - +
@@ -182,3 +197,6 @@ {/if} {/if} + + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/frontend/src/routes/trackers/+page.svelte b/frontend/src/routes/trackers/+page.svelte index ed86b8c..0d1bc53 100644 --- a/frontend/src/routes/trackers/+page.svelte +++ b/frontend/src/routes/trackers/+page.svelte @@ -1,5 +1,6 @@ @@ -63,6 +76,7 @@ {#if !loaded}{:else} {#if showForm} +
{#if error}
{error}
{/if}
@@ -104,13 +118,13 @@
@@ -138,12 +152,12 @@
@@ -160,12 +174,12 @@
@@ -178,6 +192,7 @@ +
{/if} {#if configs.length === 0 && !showForm} @@ -185,7 +200,7 @@ {:else}
{#each configs as config} - +
@@ -210,3 +225,6 @@ {/if} {/if} + + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/frontend/src/routes/users/+page.svelte b/frontend/src/routes/users/+page.svelte index efbc1e8..30329fa 100644 --- a/frontend/src/routes/users/+page.svelte +++ b/frontend/src/routes/users/+page.svelte @@ -7,6 +7,7 @@ import Card from '$lib/components/Card.svelte'; import Loading from '$lib/components/Loading.svelte'; import Modal from '$lib/components/Modal.svelte'; + import ConfirmModal from '$lib/components/ConfirmModal.svelte'; const auth = getAuth(); let users = $state([]); @@ -14,35 +15,48 @@ let form = $state({ username: '', password: '', role: 'user' }); let error = $state(''); let loaded = $state(false); + let confirmDelete = $state(null); // Admin reset password let resetUserId = $state(null); let resetUsername = $state(''); let resetPassword = $state(''); let resetMsg = $state(''); + let resetSuccess = $state(false); onMount(load); - async function load() { try { users = await api('/users'); } catch {} finally { loaded = true; } } + async function load() { + try { users = await api('/users'); } + catch (err: any) { error = err.message || t('common.loadError'); } + finally { loaded = true; } + } async function create(e: SubmitEvent) { e.preventDefault(); error = ''; try { await api('/users', { method: 'POST', body: JSON.stringify(form) }); form = { username: '', password: '', role: 'user' }; showForm = false; await load(); } catch (err: any) { error = err.message; } } - async function remove(id: number) { - if (!confirm(t('users.confirmDelete'))) return; - try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } catch (err: any) { alert(err.message); } + function remove(id: number) { + confirmDelete = { + id, + onconfirm: async () => { + try { await api(`/users/${id}`, { method: 'DELETE' }); await load(); } + catch (err: any) { error = err.message; } + finally { confirmDelete = null; } + } + }; } function openResetPassword(user: any) { - resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; + resetUserId = user.id; resetUsername = user.username; resetPassword = ''; resetMsg = ''; resetSuccess = false; } async function resetUserPassword(e: SubmitEvent) { - e.preventDefault(); resetMsg = ''; + e.preventDefault(); resetMsg = ''; resetSuccess = false; try { await api(`/users/${resetUserId}/password`, { method: 'PUT', body: JSON.stringify({ new_password: resetPassword }) }); resetMsg = t('common.passwordChanged'); - setTimeout(() => { resetUserId = null; resetMsg = ''; }, 2000); - } catch (err: any) { resetMsg = err.message; } + resetSuccess = true; + setTimeout(() => { resetUserId = null; resetMsg = ''; resetSuccess = false; }, 2000); + } catch (err: any) { resetMsg = err.message; resetSuccess = false; } } @@ -81,7 +95,7 @@
{#each users as user} - +

{user.username}

@@ -101,7 +115,7 @@ {/if} - { resetUserId = null; resetMsg = ''; }}> + { resetUserId = null; resetMsg = ''; resetSuccess = false; }}>
@@ -109,10 +123,13 @@ class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if resetMsg} -

{resetMsg}

+

{resetMsg}

{/if}
+ + confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} /> diff --git a/packages/server/src/immich_watcher_server/ai/telegram_webhook.py b/packages/server/src/immich_watcher_server/ai/telegram_webhook.py index 717df41..f8bec48 100644 --- a/packages/server/src/immich_watcher_server/ai/telegram_webhook.py +++ b/packages/server/src/immich_watcher_server/ai/telegram_webhook.py @@ -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) diff --git a/packages/server/src/immich_watcher_server/api/scheduled.py b/packages/server/src/immich_watcher_server/api/scheduled.py deleted file mode 100644 index 40950dd..0000000 --- a/packages/server/src/immich_watcher_server/api/scheduled.py +++ /dev/null @@ -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 diff --git a/packages/server/src/immich_watcher_server/api/servers.py b/packages/server/src/immich_watcher_server/api/servers.py index 74ee4f9..a4c6320 100644 --- a/packages/server/src/immich_watcher_server/api/servers.py +++ b/packages/server/src/immich_watcher_server/api/servers.py @@ -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) diff --git a/packages/server/src/immich_watcher_server/api/sync.py b/packages/server/src/immich_watcher_server/api/sync.py index b856cf4..dfc5d99 100644 --- a/packages/server/src/immich_watcher_server/api/sync.py +++ b/packages/server/src/immich_watcher_server/api/sync.py @@ -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, diff --git a/packages/server/src/immich_watcher_server/api/targets.py b/packages/server/src/immich_watcher_server/api/targets.py index 264e915..c206704 100644 --- a/packages/server/src/immich_watcher_server/api/targets.py +++ b/packages/server/src/immich_watcher_server/api/targets.py @@ -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) diff --git a/packages/server/src/immich_watcher_server/api/telegram_bots.py b/packages/server/src/immich_watcher_server/api/telegram_bots.py index 029d1c7..e2de198 100644 --- a/packages/server/src/immich_watcher_server/api/telegram_bots.py +++ b/packages/server/src/immich_watcher_server/api/telegram_bots.py @@ -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} diff --git a/packages/server/src/immich_watcher_server/api/templates.py b/packages/server/src/immich_watcher_server/api/templates.py deleted file mode 100644 index 6895ce7..0000000 --- a/packages/server/src/immich_watcher_server/api/templates.py +++ /dev/null @@ -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 diff --git a/packages/server/src/immich_watcher_server/api/trackers.py b/packages/server/src/immich_watcher_server/api/trackers.py index 5780c5c..fb34d95 100644 --- a/packages/server/src/immich_watcher_server/api/trackers.py +++ b/packages/server/src/immich_watcher_server/api/trackers.py @@ -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), ): diff --git a/packages/server/src/immich_watcher_server/config.py b/packages/server/src/immich_watcher_server/config.py index 6047c5d..5384699 100644 --- a/packages/server/src/immich_watcher_server/config.py +++ b/packages/server/src/immich_watcher_server/config.py @@ -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 diff --git a/packages/server/src/immich_watcher_server/main.py b/packages/server/src/immich_watcher_server/main.py index 57f4f7d..fa01fe2 100644 --- a/packages/server/src/immich_watcher_server/main.py +++ b/packages/server/src/immich_watcher_server/main.py @@ -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=["*"], ) diff --git a/packages/server/src/immich_watcher_server/services/watcher.py b/packages/server/src/immich_watcher_server/services/watcher.py index 88e0588..46dbffa 100644 --- a/packages/server/src/immich_watcher_server/services/watcher.py +++ b/packages/server/src/immich_watcher_server/services/watcher.py @@ -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,