Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5028f15f4f | |||
| 5a232f18b8 | |||
| 3b76a09759 | |||
| 4ff3876e49 | |||
| 83215473c7 | |||
| 4e23d2b054 | |||
| f7d51b27d2 | |||
| 3bb0585e43 | |||
| 58cba88c92 | |||
| 645331d320 | |||
| 6c3dd67c1b | |||
| 56993d2ca3 |
+17
-67
@@ -1,82 +1,32 @@
|
||||
## v0.2.0 (2026-04-22)
|
||||
## v0.2.3 (2026-04-22)
|
||||
|
||||
First feature release since the initial `v0.1.0` cut: a broad polish pass across
|
||||
the backend, frontend, and schema, plus two fixes landed on top.
|
||||
Bot-command scope hardening: commands now see only what their chat is wired to
|
||||
receive notifications about, closing a leak where a bot serving multiple chats
|
||||
exposed the whole provider catalog to every chat. Plus a handful of Immich
|
||||
command fixes (missing `public_url` enrichment, silently-swallowed search errors,
|
||||
always-on link previews).
|
||||
|
||||
### Features
|
||||
|
||||
#### Immich commands & tracking
|
||||
|
||||
- Per-chat album scope for Immich commands (`/search`, `/latest`, `/memory`, etc.) with a new *Edit album scope* modal on command-tracker listeners (inherit or explicit multiselect) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- `/search` and `/find` accept a trailing page number; Immich client `search_smart` / `search_metadata` take a `page` param ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Auto-organize rules now set the target album's thumbnail to the first added image (falls back to any asset type) when the album has none ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Dashboard & status
|
||||
|
||||
- Action events (`action_success` / `action_partial` / `action_failed`) are emitted on every non-dry-run and surfaced on the dashboard with icons, filters, and colors ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Clear-events button + confirm modal on the dashboard (`DELETE /api/status/events`, scoped to the current user) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Event rows render live tracker/provider/action names via FK join, with snapshot fallback when an entity has been deleted ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Backup & restore
|
||||
|
||||
- Full backup restore flow: prepare-restore writes a pending marker, a restart banner offers Apply-now / Apply-later, and the lifespan hook applies on next startup and archives under `data/applied_restores/` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Manual *Create backup* button on the Backup page (`POST /api/backup/files`, same format as scheduled) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- `apply-restart` sends SIGTERM so the lifespan shutdown runs; `NOTIFY_BRIDGE_SUPERVISED` env var gates the button ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Users & deletion protection
|
||||
|
||||
- `PATCH /api/users/{id}` for username and role changes with a last-admin guard, plus an *Edit user* modal on the Users page ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Deletion protection now returns structured `{message, entity, blocked_by}`; `ApiError` carries `.blockedBy` and the new `BlockedByModal` is wired into 8 deletion flows ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Telegram
|
||||
|
||||
- Per-receiver locale for Telegram test messages (resolves `TelegramChat.language_override` per chat instead of applying the first receiver's locale to everyone) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Telegram poller detects the "webhook is active" 409 and auto-calls `deleteWebhook` for bots whose DB `update_mode` is polling (throttled per bot) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- New `TelegramClient.get_chat` and `set_album_thumbnail` helpers (CLAUDE.md rule 6 — all Bot API calls go through `TelegramClient`) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Form UX
|
||||
|
||||
- Auto-select first available tracking / template / command / config + bot on create forms (trackers, command-trackers, targets, template/command configs) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Global provider selector is visible even when there is only one provider ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Telegram target `disable_url_preview` defaults to `true` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- **Per-chat album scope derived from notification routing** — for a `(provider, bot, chat_id)` triple, the allowed album set is now computed by walking `TargetReceiver → NotificationTarget → NotificationTrackerTarget → NotificationTracker` and unioning the collection IDs. `/albums`, `/random`, `/search`, `/find`, `/latest`, `/memory`, `/summary`, `/favorites`, `/place`, `/person`, `/status`, `/events` all intersect their results with the resolved scope. Chats with no notification routing for a tracker return nothing rather than leaking the provider's catalog. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Scope modal relabeled** — the per-listener `allowed_album_ids` UI is now explicitly an *override for this bot* (escape hatch when you want a divergent scope for a whole bot); the default is *derive from notification routing*, which matches what operators have already configured elsewhere. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Drop tracker counts from `/status`** — `trackers_active` / `trackers_total` were per-provider aggregates that would leak info about trackers a chat has no visibility into. Immich default `/status` templates (en, ru) now show only *Albums* + *Last event*; the template-editor variable catalog no longer suggests the removed vars for the Immich `/status` slot. **Note:** custom templates that reference `{{ trackers_active }}` / `{{ trackers_total }}` need to be updated. ([5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Telegram target list: load chats/listeners **before** expanding so the slide animation computes the right height ([cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d))
|
||||
- Test dispatch falls back to tracker defaults for tracking/template config (matching `load_link_data`), distinguishes "no template config linked" vs. "slot missing in linked config", and the frontend `testTrackerTarget` now treats `{success:false,error:...}` in a 2xx body as a failure instead of flashing a success snack ([80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d))
|
||||
- Immich person-asset lookup switched from the removed `GET /api/people/{id}/assets` to `POST /api/search/metadata` with `personIds` — fixes `/person` and auto-organize rules silently returning zero candidates on Immich 1.106+ ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- `add_assets_to_album` now surfaces the Immich error body on non-2xx responses ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Immich tracker "Checking links" parallelised (concurrency cap 6) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
---
|
||||
|
||||
### Development / Internal
|
||||
|
||||
#### Schema / Migrations
|
||||
|
||||
- `event_log`: add `user_id`, `action_id`, `action_name` (+ defensive migration and backfill of `user_id` from `notification_tracker`) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- `command_tracker_listener`: add `allowed_album_ids` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Refactors & Internals
|
||||
|
||||
- Bounded concurrency (semaphores) in `NotificationDispatcher._preload_asset_data` and `_refresh_telegram_chat_titles`; chat-title sweep extended to 24h since `save_chat_from_webhook` covers active chats opportunistically ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- Periodic-summary test path reuses the shared `collect_scheduled_assets` primitive (`limit=0`) so test and production go through one path ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- New `fetchAuth` helper for multipart/binary calls (reuses `api()`'s refresh + `ApiError` mapping) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- `parseDate` helper for consistent UTC date rendering ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
|
||||
#### Seeds
|
||||
|
||||
- Rename "Default Commands" → "Default Immich Commands"; `track_assets_removed` now defaults to `False` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
|
||||
- **`/albums` honors per-chat scope** — previously ignored `CommandTrackerListener.allowed_album_ids` and listed every album tracked by the provider, so scoped chats saw neighbours' albums. Now applies the same intersect filter the `/_cmd_immich` media commands use. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
|
||||
- **Disable Telegram link previews on command text replies** — listings (`/albums`, `/events`, `/people`, …) embed multiple links and were rendering a preview for the first URL regardless of the operator's *Disable link previews* toggle. `send_reply` now always passes `disable_web_page_preview=True`. ([4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876))
|
||||
- **Restore `public_url` enrichment on `/search`, `/find`, `/person`, `/place`** — `_enrich_assets`'s return value was being discarded, dropping the public URL populated on each asset. Now assigned properly. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Surface Immich search errors instead of silently returning `[]`** — `search_smart` / `search_metadata` consolidated into a `_search_items` helper that logs non-200 responses and transport errors, and accepts the alternate `{"assets": [...]}` flat-list shape from older Immich versions. "Always no results" bugs are now diagnosable. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
- **Redact Immich search error bodies** before they land in server logs — credentials echoed by authenticating proxies no longer leak into logs. ([3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d) | fix(telegram): load chats/listeners before expanding to fix slide animation height | alexei.dolgolyov |
|
||||
| [80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d) | fix(test-dispatch): fall back to tracker defaults, surface soft errors | alexei.dolgolyov |
|
||||
| [a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e) | feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events | alexei.dolgolyov |
|
||||
- [5a232f1](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/5a232f1) — feat(commands): drop tracker counts from /status *(alexei.dolgolyov)*
|
||||
- [4ff3876](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4ff3876) — fix(commands): /albums honors per-chat scope, disable link previews *(alexei.dolgolyov)*
|
||||
- [3b76a09](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3b76a09) — feat(commands): per-chat album scope derived from notification routing *(alexei.dolgolyov)*
|
||||
|
||||
</details>
|
||||
|
||||
@@ -12,6 +12,10 @@ services:
|
||||
environment:
|
||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
||||
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
|
||||
# Homelab target: allow outbound requests to RFC1918 / link-local addresses.
|
||||
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts,
|
||||
# which breaks tracking of Immich / Gitea / etc. running on the same LAN.
|
||||
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
||||
interval: 30s
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
+46
-27
@@ -94,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
|
||||
// and binary downloads where a 30s limit can cut off a legit slow upload.
|
||||
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
|
||||
|
||||
export async function api<T = any>(
|
||||
path: string,
|
||||
@@ -170,42 +173,58 @@ export async function api<T = any>(
|
||||
*/
|
||||
export async function fetchAuth(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
options: RequestInit & { timeoutMs?: number } = {},
|
||||
): Promise<Response> {
|
||||
const token = getToken();
|
||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||
let res = await fetch(url, { ...options, headers });
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(url, { ...options, headers });
|
||||
// Abort after timeout so uploads/downloads don't hang indefinitely if
|
||||
// the backend stops responding. Callers can override per-request via
|
||||
// options.timeoutMs or pass their own signal to opt out.
|
||||
const { timeoutMs, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
timeoutMs ?? DEFAULT_FETCHAUTH_TIMEOUT_MS,
|
||||
);
|
||||
const signal = options.signal ?? controller.signal;
|
||||
|
||||
try {
|
||||
let res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||
|
||||
if (res.status === 401 && token) {
|
||||
const refreshed = await refreshAccessToken();
|
||||
if (refreshed) {
|
||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
||||
res = await fetch(url, { ...fetchOptions, headers, signal });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
if (res.status === 401) {
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||
throw new ApiError('Unauthorized', 401);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res;
|
||||
if (!res.ok) {
|
||||
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
|
||||
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
|
||||
const bb: BlockedByDetail = {
|
||||
message: err.detail.message || `HTTP ${res.status}`,
|
||||
entity: err.detail.entity || '',
|
||||
blocked_by: err.detail.blocked_by,
|
||||
};
|
||||
throw new ApiError(bb.message, res.status, bb);
|
||||
}
|
||||
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
|
||||
throw new ApiError(msg, res.status);
|
||||
}
|
||||
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,6 +523,9 @@
|
||||
"memorySource": "Memory source",
|
||||
"memorySourceAlbums": "Scan tracked albums",
|
||||
"memorySourceNative": "Immich native memories",
|
||||
"quietHours": "Quiet hours",
|
||||
"quietHoursStart": "Start",
|
||||
"quietHoursEnd": "End",
|
||||
"test": "Test",
|
||||
"confirmDelete": "Delete this tracking config?",
|
||||
"sortNone": "None",
|
||||
@@ -670,6 +673,8 @@
|
||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||
"cacheTtl": "Media Cache TTL (hours)",
|
||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
||||
"timezone": "Timezone",
|
||||
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
|
||||
"locales": "Template Languages",
|
||||
"supportedLocales": "Supported Locales",
|
||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
||||
@@ -680,6 +685,7 @@
|
||||
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
|
||||
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
|
||||
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
|
||||
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:00–07:00 are supported.",
|
||||
"favoritesOnly": "Only include assets marked as favorites.",
|
||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
||||
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||
@@ -796,11 +802,11 @@
|
||||
"selectBot": "Select bot...",
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Edit album scope",
|
||||
"scopeAll": "all albums",
|
||||
"scopeAll": "derived from notification routing",
|
||||
"albumsShort": "albums",
|
||||
"scopeTitle": "Album Scope for This Chat",
|
||||
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
|
||||
"scopeInherit": "Inherit: allow all tracked albums",
|
||||
"scopeTitle": "Album Scope Override for This Bot",
|
||||
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"snackbar": {
|
||||
|
||||
@@ -523,6 +523,9 @@
|
||||
"memorySource": "Источник воспоминаний",
|
||||
"memorySourceAlbums": "Сканировать альбомы",
|
||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||
"quietHours": "Тихие часы",
|
||||
"quietHoursStart": "Начало",
|
||||
"quietHoursEnd": "Конец",
|
||||
"test": "Тест",
|
||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||
"sortNone": "Нет",
|
||||
@@ -670,6 +673,8 @@
|
||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||
"cacheTtl": "TTL кэша медиа (часы)",
|
||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||
"timezone": "Часовой пояс",
|
||||
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||
"locales": "Языки шаблонов",
|
||||
"supportedLocales": "Поддерживаемые локали",
|
||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||
@@ -680,6 +685,7 @@
|
||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
||||
@@ -796,11 +802,11 @@
|
||||
"selectBot": "Выберите бота...",
|
||||
"listenerType": "telegram_bot",
|
||||
"editScope": "Изменить область альбомов",
|
||||
"scopeAll": "все альбомы",
|
||||
"scopeAll": "из маршрутизации уведомлений",
|
||||
"albumsShort": "альбомов",
|
||||
"scopeTitle": "Область альбомов для этого чата",
|
||||
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
|
||||
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
|
||||
"scopeTitle": "Переопределение области альбомов для этого бота",
|
||||
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"snackbar": {
|
||||
|
||||
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
|
||||
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
|
||||
enabledField: 'quiet_hours_enabled', enabledDefault: false,
|
||||
fields: [
|
||||
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
|
||||
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
collectionMeta: {
|
||||
|
||||
@@ -192,6 +192,9 @@ export interface TrackingConfig {
|
||||
memory_favorite_only: boolean;
|
||||
memory_asset_type: string;
|
||||
memory_min_rating: number;
|
||||
quiet_hours_enabled: boolean;
|
||||
quiet_hours_start: string | null;
|
||||
quiet_hours_end: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
telegram_webhook_secret: '',
|
||||
telegram_cache_ttl_hours: '48',
|
||||
supported_locales: 'en,ru',
|
||||
timezone: 'UTC',
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
@@ -57,6 +58,11 @@
|
||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium mb-1">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
|
||||
<input bind:value={settings.timezone} placeholder="UTC"
|
||||
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -181,9 +181,12 @@
|
||||
{:else if field.type === 'grid-select' && field.gridItems}
|
||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||
{:else}
|
||||
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'}
|
||||
<input type={field.key.includes('date') ? 'date'
|
||||
: field.key.startsWith('quiet_hours_') ? 'time'
|
||||
: field.key.includes('times') ? 'text'
|
||||
: 'number'}
|
||||
bind:value={form[field.key]} min={field.min} max={field.max}
|
||||
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''}
|
||||
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
|
||||
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -300,6 +300,16 @@ class TelegramClient:
|
||||
# Retry without parse_mode on parse errors
|
||||
desc = str(result.get("description", ""))
|
||||
if "parse" in desc.lower():
|
||||
# Log loudly: a parse failure means the template author (or
|
||||
# an asset field) is producing malformed HTML. Silent
|
||||
# fallback hides bugs and makes XSS-via-unescaped-field
|
||||
# harder to spot. Do not log the full payload — it may
|
||||
# contain secrets.
|
||||
_LOGGER.warning(
|
||||
"Telegram rejected parse_mode=%s (%r); retrying as plain text. "
|
||||
"Check template output for unescaped characters.",
|
||||
payload.get("parse_mode"), desc,
|
||||
)
|
||||
payload.pop("parse_mode", None)
|
||||
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
||||
retry_result = await retry_resp.json()
|
||||
|
||||
@@ -321,6 +321,12 @@ def collect_scheduled_assets(
|
||||
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
|
||||
collections_extra: list[dict[str, Any]] = []
|
||||
|
||||
# limit=0 is the periodic-summary test path — the caller only needs
|
||||
# per-album stats (name/url/counts), not a sample of assets. Skip the
|
||||
# expensive ``filter_assets`` + sampling loop entirely; on a 50k-asset
|
||||
# album the serial scan-then-discard pattern wasted seconds per test.
|
||||
stats_only = limit <= 0
|
||||
|
||||
for album_id, album in albums.items():
|
||||
links = shared_links.get(album_id, [])
|
||||
album_public_url = get_public_url(external_url, links) or ""
|
||||
@@ -336,6 +342,9 @@ def collect_scheduled_assets(
|
||||
"owner": album.owner,
|
||||
})
|
||||
|
||||
if stats_only:
|
||||
continue
|
||||
|
||||
filtered = filter_assets(
|
||||
list(album.assets.values()),
|
||||
favorite_only=favorite_only,
|
||||
@@ -348,6 +357,9 @@ def collect_scheduled_assets(
|
||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
||||
all_eligible.append(asset)
|
||||
|
||||
if stats_only:
|
||||
return [], collections_extra
|
||||
|
||||
# Random sample
|
||||
if len(all_eligible) > limit:
|
||||
selected = random.sample(all_eligible, limit)
|
||||
|
||||
@@ -3,14 +3,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||
from .models import ImmichAlbumData, SharedLinkInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Cap user-controlled Immich search parameters so a low-privileged command
|
||||
# listener (e.g. an Immich ``/search`` command) cannot DoS the upstream.
|
||||
MAX_SEARCH_QUERY_LEN = 256
|
||||
MAX_SEARCH_PERSON_IDS = 50
|
||||
|
||||
# User-facing error bodies — Immich responses may leak internal paths,
|
||||
# hostnames, or headers injected by intermediary proxies. These helpers keep
|
||||
# only a short, scrubbed summary; full bodies are logged server-side only.
|
||||
_REDACTED_BODY_MAX = 120
|
||||
_SECRET_PATTERN = re.compile(
|
||||
r"(?i)(bearer\s+\S+|x-api-key[:=]\s*\S+|authorization[:=]\s*\S+|cookie[:=]\s*\S+|"
|
||||
r"password[:=]?\s*\S+|token[:=]?\s*[A-Za-z0-9._\-]+)"
|
||||
)
|
||||
|
||||
|
||||
def _redact_body(text: str) -> str:
|
||||
"""Return a short, credential-scrubbed snippet safe to surface to UI callers.
|
||||
|
||||
Immich error responses are admin-configurable (via reverse proxies, custom
|
||||
error pages) and may echo request headers or environment leak. Stripping
|
||||
anything that looks like a credential + capping length keeps us from
|
||||
persisting secrets into ``ActionExecution.error`` / ``EventLog.details``
|
||||
(both of which are returned through the dashboard API).
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
cleaned = _SECRET_PATTERN.sub("[redacted]", text)
|
||||
if len(cleaned) > _REDACTED_BODY_MAX:
|
||||
return cleaned[:_REDACTED_BODY_MAX] + "..."
|
||||
return cleaned
|
||||
|
||||
|
||||
class ImmichClient:
|
||||
"""Async client for the Immich API."""
|
||||
@@ -25,6 +58,19 @@ class ImmichClient:
|
||||
self._url = url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._external_domain: str | None = None
|
||||
# SSRF guard — admin-set Immich URLs are loaded from provider config
|
||||
# which can be mutated via PATCH /api/providers or imported via
|
||||
# prepare-restore, so we revalidate at construction time rather than
|
||||
# trusting DB state. Homelab deployments pointing at RFC1918 targets
|
||||
# must set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the runtime env.
|
||||
if self._url:
|
||||
try:
|
||||
validate_outbound_url(self._url)
|
||||
except UnsafeURLError as err:
|
||||
raise UnsafeURLError(
|
||||
f"Refusing to build ImmichClient for unsafe URL {self._url!r}: {err}. "
|
||||
"If this is a LAN/homelab Immich, set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1."
|
||||
) from err
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -36,6 +82,14 @@ class ImmichClient:
|
||||
|
||||
@external_domain.setter
|
||||
def external_domain(self, value: str | None) -> None:
|
||||
# Mirror the constructor's SSRF guard. Set
|
||||
# ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` for LAN/homelab targets.
|
||||
if value:
|
||||
try:
|
||||
validate_outbound_url(value)
|
||||
except UnsafeURLError as err:
|
||||
_LOGGER.warning("Ignoring unsafe external_domain %r: %s", value, err)
|
||||
return
|
||||
self._external_domain = value
|
||||
|
||||
@property
|
||||
@@ -237,22 +291,15 @@ class ImmichClient:
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": limit}
|
||||
# Cap user-controlled inputs — a low-privileged Telegram listener can
|
||||
# craft arbitrarily long queries to DoS the upstream Immich.
|
||||
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
|
||||
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
||||
if album_ids:
|
||||
payload["albumIds"] = album_ids
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/smart",
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
return []
|
||||
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||
return await self._search_items(
|
||||
f"{self._url}/api/search/smart", payload, limit, "smart",
|
||||
)
|
||||
|
||||
async def search_metadata(
|
||||
self,
|
||||
@@ -261,21 +308,61 @@ class ImmichClient:
|
||||
limit: int = 10,
|
||||
page: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": limit}
|
||||
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
|
||||
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
|
||||
if album_ids:
|
||||
payload["albumIds"] = album_ids
|
||||
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||
return await self._search_items(
|
||||
f"{self._url}/api/search/metadata", payload, limit, "metadata",
|
||||
)
|
||||
|
||||
async def _search_items(
|
||||
self,
|
||||
url: str,
|
||||
payload: dict[str, Any],
|
||||
limit: int,
|
||||
kind: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Shared POST-and-extract-items helper with error logging.
|
||||
|
||||
Returns an empty list on any error; previously these paths swallowed
|
||||
non-200s silently, making "/search always returns no results" on
|
||||
misbehaving Immich deployments impossible to diagnose without a
|
||||
network trace. Logging keeps the empty-list contract but tells the
|
||||
operator *why* it's empty.
|
||||
"""
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
url,
|
||||
headers=self._json_headers,
|
||||
json=payload,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
items = data.get("assets", {}).get("items", [])
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError:
|
||||
pass
|
||||
if response.status != 200:
|
||||
body_snip = await response.text()
|
||||
_LOGGER.warning(
|
||||
"Immich %s search non-200: HTTP %s body=%s",
|
||||
kind, response.status, _redact_body(body_snip),
|
||||
)
|
||||
return []
|
||||
data = await response.json()
|
||||
# Modern Immich: {"assets": {"items": [...], ...}}
|
||||
assets_block = data.get("assets")
|
||||
if isinstance(assets_block, dict):
|
||||
items = assets_block.get("items", []) or []
|
||||
elif isinstance(assets_block, list):
|
||||
# Older/alternate shape — flat list of assets.
|
||||
items = assets_block
|
||||
else:
|
||||
_LOGGER.warning(
|
||||
"Immich %s search returned unexpected shape: keys=%s",
|
||||
kind, list(data.keys())[:5],
|
||||
)
|
||||
items = []
|
||||
return items[:limit]
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
|
||||
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
|
||||
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
|
||||
return []
|
||||
|
||||
async def search_by_person(
|
||||
@@ -289,7 +376,7 @@ class ImmichClient:
|
||||
to return an empty list on current servers.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"personIds": [person_id],
|
||||
"personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
|
||||
"page": 1,
|
||||
"size": max(1, min(limit, 100)),
|
||||
}
|
||||
@@ -373,9 +460,17 @@ class ImmichClient:
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return {"raw": body_text}
|
||||
# Log full body server-side (for operators), surface only a
|
||||
# redacted snippet to the caller — this string ends up in
|
||||
# ActionExecution.error / EventLog.details which are returned
|
||||
# through the dashboard API.
|
||||
_LOGGER.warning(
|
||||
"add_assets_to_album failed: HTTP %s body=%s",
|
||||
response.status, body_text[:512],
|
||||
)
|
||||
raise ImmichApiError(
|
||||
f"Failed to add assets to album {album_id}: "
|
||||
f"HTTP {response.status} body={body_text[:512]}"
|
||||
f"HTTP {response.status} {_redact_body(body_text)}"
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error adding assets to album: {err}") from err
|
||||
@@ -399,9 +494,13 @@ class ImmichClient:
|
||||
if response.status in (200, 201, 204):
|
||||
return
|
||||
body_text = await response.text()
|
||||
_LOGGER.warning(
|
||||
"set_album_thumbnail failed: HTTP %s body=%s",
|
||||
response.status, body_text[:512],
|
||||
)
|
||||
raise ImmichApiError(
|
||||
f"Failed to set album thumbnail for {album_id}: "
|
||||
f"HTTP {response.status} body={body_text[:512]}"
|
||||
f"HTTP {response.status} {_redact_body(body_text)}"
|
||||
)
|
||||
except aiohttp.ClientError as err:
|
||||
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
📊 Status
|
||||
Trackers: {{ trackers_active }}/{{ trackers_total }} active
|
||||
Albums: {{ total_albums }}
|
||||
Last event: {{ last_event }}
|
||||
Last event: {{ last_event }}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
📊 Статус
|
||||
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
|
||||
Альбомы: {{ total_albums }}
|
||||
Последнее событие: {{ last_event }}
|
||||
Последнее событие: {{ last_event }}
|
||||
|
||||
@@ -2,16 +2,67 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from notify_bridge_core.models.events import ServiceEvent
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Per-target maximum video size (bytes). None = no limit.
|
||||
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
||||
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
|
||||
}
|
||||
|
||||
# Keys that must NEVER flow into the Jinja2 template context, even if a
|
||||
# provider stuffs them into ``event.extra`` (webhooks, Immich metadata, etc.).
|
||||
# Templates that could reach a Telegram/Discord/etc. chat would otherwise
|
||||
# expose operator credentials if a template author simply did ``{{ api_key }}``.
|
||||
# Case-insensitive substring match — any ``extra`` key containing one of these
|
||||
# tokens is dropped before the merge.
|
||||
_SENSITIVE_EXTRA_TOKENS: tuple[str, ...] = (
|
||||
"api_key",
|
||||
"apikey",
|
||||
"token",
|
||||
"secret",
|
||||
"password",
|
||||
"passwd",
|
||||
"hashed_",
|
||||
"authorization",
|
||||
"cookie",
|
||||
"session_id",
|
||||
"bearer",
|
||||
"private_key",
|
||||
"access_key",
|
||||
)
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
lowered = str(key).lower()
|
||||
return any(tok in lowered for tok in _SENSITIVE_EXTRA_TOKENS)
|
||||
|
||||
|
||||
def _safe_merge_extras(ctx: dict[str, Any], extras: dict[str, Any]) -> None:
|
||||
"""Merge provider ``extras`` into ``ctx``, dropping sensitive keys.
|
||||
|
||||
Dropped keys are logged once per event (DEBUG) so operators can spot
|
||||
leaking providers without flooding the log.
|
||||
"""
|
||||
if not extras:
|
||||
return
|
||||
dropped: list[str] = []
|
||||
for key, value in extras.items():
|
||||
if _is_sensitive_key(key):
|
||||
dropped.append(key)
|
||||
continue
|
||||
ctx[key] = value
|
||||
if dropped:
|
||||
_LOGGER.debug(
|
||||
"Dropped %d sensitive key(s) from template context: %s",
|
||||
len(dropped), ", ".join(sorted(dropped)),
|
||||
)
|
||||
|
||||
|
||||
def build_template_context(
|
||||
event: ServiceEvent,
|
||||
@@ -61,8 +112,9 @@ def build_template_context(
|
||||
"preview_url": asset.preview_url or "",
|
||||
"full_url": asset.full_url or "",
|
||||
}
|
||||
# Flatten extras into asset dict for template access
|
||||
asset_dict.update(asset.extra)
|
||||
# Flatten extras into asset dict for template access — same
|
||||
# sensitive-key filtering applied as the top-level merge.
|
||||
_safe_merge_extras(asset_dict, asset.extra)
|
||||
asset_dict.setdefault("oversized", False)
|
||||
asset_dict.setdefault("file_size", None)
|
||||
asset_dict.setdefault("playback_size", None)
|
||||
@@ -138,8 +190,11 @@ def build_template_context(
|
||||
if len(locations) == 1 and "" not in locations:
|
||||
ctx["common_location"] = locations.pop()
|
||||
|
||||
# Provider-specific extras merged at top level
|
||||
ctx.update(event.extra)
|
||||
# Provider-specific extras merged at top level. Sensitive keys (tokens,
|
||||
# secrets, auth headers) are dropped — see ``_SENSITIVE_EXTRA_TOKENS``.
|
||||
# Without this, a template author could exfiltrate provider credentials
|
||||
# via ``{{ api_key }}`` in an outgoing notification body.
|
||||
_safe_merge_extras(ctx, event.extra)
|
||||
|
||||
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
|
||||
ctx.setdefault("public_url", "")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.2.0"
|
||||
version = "0.2.3"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -22,6 +22,7 @@ _SETTING_KEYS = {
|
||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||
"supported_locales": None, # comma-separated locale codes
|
||||
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||
}
|
||||
|
||||
_DEFAULTS = {
|
||||
@@ -29,6 +30,7 @@ _DEFAULTS = {
|
||||
"telegram_webhook_secret": "",
|
||||
"telegram_cache_ttl_hours": "48",
|
||||
"supported_locales": "en,ru",
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +52,7 @@ class SettingsUpdate(BaseModel):
|
||||
telegram_webhook_secret: str | None = None
|
||||
telegram_cache_ttl_hours: str | None = None
|
||||
supported_locales: str | None = None
|
||||
timezone: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Configuration backup/restore API (admin only)."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Query
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -28,6 +30,11 @@ PENDING_RESTORE_PATH_KEY = "pending_restore_path"
|
||||
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
|
||||
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
|
||||
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
|
||||
# SHA256 of the staged pending_restore.json, written atomically with the file.
|
||||
# The startup hook refuses to apply if the on-disk file's hash does not match —
|
||||
# defends against anyone dropping a tampered file into data/ between prepare
|
||||
# and restart.
|
||||
PENDING_RESTORE_SHA256_KEY = "pending_restore_sha256"
|
||||
|
||||
|
||||
def _pending_restore_path():
|
||||
@@ -44,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
|
||||
"""Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.
|
||||
|
||||
Rejects on ``content_length`` header up-front when available; always
|
||||
stream-reads with a running byte counter so we never allocate more than
|
||||
the limit even when the header is missing or lies.
|
||||
"""
|
||||
# Fast path: reject on header before we allocate anything.
|
||||
cl = file.headers.get("content-length") if hasattr(file, "headers") else None
|
||||
if cl:
|
||||
try:
|
||||
if int(cl) > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _check_same_origin(request: Request) -> None:
|
||||
"""Reject cross-origin admin-write POSTs (CSRF defense).
|
||||
|
||||
Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
|
||||
page cannot technically submit our Authorization header from a victim's
|
||||
session, BUT browser extensions and misconfigured CORS policies routinely
|
||||
break this assumption. For endpoints whose blast radius is restart/RCE-
|
||||
equivalent (restore apply), we additionally require the request to come
|
||||
from our own origin.
|
||||
"""
|
||||
host = request.headers.get("host", "").lower()
|
||||
if not host:
|
||||
raise HTTPException(status_code=400, detail="Missing Host header")
|
||||
|
||||
def _host_of(u: str | None) -> str:
|
||||
if not u:
|
||||
return ""
|
||||
try:
|
||||
return (urlparse(u).netloc or "").lower()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
origin_host = _host_of(request.headers.get("origin"))
|
||||
referer_host = _host_of(request.headers.get("referer"))
|
||||
# At least one of Origin/Referer must be present and match Host.
|
||||
# Legitimate browser requests to this endpoint always ship Origin.
|
||||
same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
|
||||
if not same:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Cross-origin request rejected",
|
||||
)
|
||||
|
||||
|
||||
def _backup_dir():
|
||||
return app_config.data_dir / "backups"
|
||||
|
||||
@@ -104,9 +174,7 @@ async def validate_config(
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
"""Validate a backup file without importing."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
content = await _read_upload_bounded(file)
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
@@ -129,9 +197,7 @@ async def import_config(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Import configuration from a backup file."""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
content = await _read_upload_bounded(file)
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
@@ -167,6 +233,7 @@ async def _clear_pending_restore_markers(session: AsyncSession) -> None:
|
||||
PENDING_RESTORE_CONFLICT_KEY,
|
||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||
PENDING_RESTORE_SHA256_KEY,
|
||||
):
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
@@ -185,9 +252,7 @@ async def prepare_restore(
|
||||
Validates the uploaded file, writes it to ``data/pending_restore.json``,
|
||||
and persists marker settings so startup will apply it atomically.
|
||||
"""
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_SIZE:
|
||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
||||
content = await _read_upload_bounded(file)
|
||||
|
||||
try:
|
||||
raw = json.loads(content)
|
||||
@@ -205,15 +270,25 @@ async def prepare_restore(
|
||||
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Atomic write: write to tmp then rename, so a crash mid-write never
|
||||
# leaves a truncated pending_restore.json that would break startup apply.
|
||||
payload = json.dumps(raw).encode("utf-8")
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
|
||||
tmp_path.write_text(json.dumps(raw), encoding="utf-8")
|
||||
tmp_path.write_bytes(payload)
|
||||
os.replace(tmp_path, pending_path)
|
||||
# Best-effort tighten perms so a non-root local user cannot swap the file
|
||||
# for one they control between prepare and restart. On Windows this is a
|
||||
# no-op; on POSIX we restrict to owner-only rw.
|
||||
try:
|
||||
os.chmod(pending_path, 0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
|
||||
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
|
||||
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
|
||||
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
|
||||
await _set_app_setting(session, PENDING_RESTORE_SHA256_KEY, digest)
|
||||
await session.commit()
|
||||
|
||||
return {
|
||||
@@ -292,6 +367,7 @@ def _is_supervised() -> bool:
|
||||
|
||||
@router.post("/apply-restart")
|
||||
async def apply_and_restart(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: User = Depends(require_admin),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
@@ -299,7 +375,11 @@ async def apply_and_restart(
|
||||
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore.
|
||||
|
||||
Only allowed when a pending restore is staged AND the process is supervised.
|
||||
Requires same-origin Origin/Referer — this endpoint's blast radius is a
|
||||
full config replace + restart, so an admin token alone (vulnerable to
|
||||
XSS-driven CSRF) is not enough.
|
||||
"""
|
||||
_check_same_origin(request)
|
||||
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
|
||||
if not path_row or not path_row.value:
|
||||
raise HTTPException(status_code=409, detail="No pending restore to apply")
|
||||
|
||||
@@ -138,13 +138,11 @@ async def get_command_variables(
|
||||
# --- Immich-specific ---
|
||||
immich = {
|
||||
"status": {
|
||||
"description": "/status tracker summary",
|
||||
"description": "/status tracker summary (scoped to this chat)",
|
||||
"variables": {
|
||||
**common_vars,
|
||||
"trackers_active": "Number of active trackers",
|
||||
"trackers_total": "Total tracker count",
|
||||
"total_albums": "Total tracked albums",
|
||||
"last_event": "Last event timestamp string",
|
||||
"total_albums": "Tracked albums visible to this chat",
|
||||
"last_event": "Last event timestamp string (scoped to this chat's albums)",
|
||||
},
|
||||
},
|
||||
"albums": {
|
||||
|
||||
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
|
||||
memory_favorite_only: bool = False
|
||||
memory_asset_type: str = "all"
|
||||
memory_min_rating: int = 0
|
||||
quiet_hours_enabled: bool = False
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
|
||||
|
||||
class TrackingConfigUpdate(BaseModel):
|
||||
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
|
||||
memory_favorite_only: bool | None = None
|
||||
memory_asset_type: str | None = None
|
||||
memory_min_rating: int | None = None
|
||||
quiet_hours_enabled: bool | None = None
|
||||
quiet_hours_start: str | None = None
|
||||
quiet_hours_end: str | None = None
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
@@ -81,6 +82,12 @@ async def update_user(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Track whether the identity that JWTs encode has changed. Any such change
|
||||
# must bump ``token_version`` so already-issued tokens are rejected — a
|
||||
# user demoted admin→user must not keep admin in their cached JWT until
|
||||
# expiry, and a rename should invalidate prior sessions too.
|
||||
identity_changed = False
|
||||
|
||||
if body.username is not None and body.username != user.username:
|
||||
new_username = body.username.strip()
|
||||
if not new_username:
|
||||
@@ -89,21 +96,51 @@ async def update_user(
|
||||
if dup.first():
|
||||
raise HTTPException(status_code=409, detail="Username already exists")
|
||||
user.username = new_username
|
||||
identity_changed = True
|
||||
|
||||
if body.role is not None and body.role != user.role:
|
||||
if body.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
# Prevent demoting the last admin
|
||||
# Prevent demoting the last admin. Done via a COUNT to avoid loading
|
||||
# every admin row; more importantly, re-checked *after* the role
|
||||
# change is staged (TOCTOU guard — two concurrent demotes can each
|
||||
# see admin_count=2 and both proceed, dropping to 0).
|
||||
if user.role == "admin" and body.role != "admin":
|
||||
admins = (await session.exec(
|
||||
select(User).where(User.role == "admin")
|
||||
)).all()
|
||||
if len(admins) <= 1:
|
||||
admin_count = (await session.exec(
|
||||
select(func.count(User.id)).where(User.role == "admin")
|
||||
)).one()
|
||||
if isinstance(admin_count, tuple):
|
||||
admin_count = admin_count[0]
|
||||
if (admin_count or 0) <= 1:
|
||||
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||
user.role = body.role
|
||||
identity_changed = True
|
||||
|
||||
if identity_changed:
|
||||
user.token_version = (user.token_version or 1) + 1
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
# Final defense against admin-count race: if we just demoted the last admin
|
||||
# due to a concurrent demote landing between our check and commit, undo.
|
||||
if body.role is not None and body.role != "admin":
|
||||
admin_count_after = (await session.exec(
|
||||
select(func.count(User.id)).where(User.role == "admin")
|
||||
)).one()
|
||||
if isinstance(admin_count_after, tuple):
|
||||
admin_count_after = admin_count_after[0]
|
||||
if (admin_count_after or 0) < 1:
|
||||
# Roll the user back to admin and re-commit.
|
||||
user.role = "admin"
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
raise HTTPException(status_code=409, detail="Refused: would remove the last admin")
|
||||
|
||||
await session.refresh(user)
|
||||
return {"id": user.id, "username": user.username, "role": user.role}
|
||||
|
||||
@@ -126,6 +163,9 @@ async def reset_user_password(
|
||||
if len(body.new_password) < 8:
|
||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
||||
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
|
||||
# Invalidate all prior JWTs issued for this user — matches the self-serve
|
||||
# password-change path in auth/routes.py.
|
||||
user.token_version = (user.token_version or 1) + 1
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
return {"success": True}
|
||||
|
||||
@@ -27,7 +27,11 @@ from ..database.models import (
|
||||
ServiceProvider,
|
||||
WebhookPayloadLog,
|
||||
)
|
||||
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
|
||||
from ..services.dispatch_helpers import (
|
||||
event_allowed_by_config,
|
||||
get_app_timezone,
|
||||
load_link_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
|
||||
if not link_data:
|
||||
continue
|
||||
|
||||
app_tz = await get_app_timezone(session)
|
||||
|
||||
# Log event
|
||||
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
||||
session.add(EventLog(
|
||||
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
|
||||
|
||||
# Dispatch to targets
|
||||
dispatcher = NotificationDispatcher()
|
||||
target_configs = _build_target_configs(event, link_data, provider_config)
|
||||
target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
|
||||
if target_configs:
|
||||
results = await dispatcher.dispatch(event, target_configs)
|
||||
for r in results:
|
||||
@@ -513,12 +519,13 @@ def _build_target_configs(
|
||||
event: ServiceEvent,
|
||||
link_data: list[dict[str, Any]],
|
||||
provider_config: dict[str, Any],
|
||||
app_tz: str = "UTC",
|
||||
) -> list[TargetConfig]:
|
||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
||||
target_configs: list[TargetConfig] = []
|
||||
for ld in link_data:
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not event_allowed_by_config(event, tc):
|
||||
if tc and not event_allowed_by_config(event, tc, app_tz):
|
||||
continue
|
||||
|
||||
tmpl = ld["template_config"]
|
||||
|
||||
@@ -56,6 +56,7 @@ class ProviderCommandHandler(ABC):
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
"""Handle a provider-specific command for a single tracker.
|
||||
@@ -71,6 +72,13 @@ class ProviderCommandHandler(ABC):
|
||||
bot: The Telegram bot instance.
|
||||
tracker: The command tracker being dispatched.
|
||||
config: The command config for this tracker.
|
||||
listener: The listener row for this (tracker, bot) pair.
|
||||
allowed_album_ids: Precomputed album scope for this (bot, chat)
|
||||
pair. Resolved by the dispatcher from the listener override
|
||||
(if set) or the notification-routing graph. ``None`` means
|
||||
"no scope restriction" (rarely the right default for album
|
||||
providers — empty set is the common case).
|
||||
page: 1-based page number for paginated commands (/search, /find).
|
||||
|
||||
Returns:
|
||||
A CommandResponse, or None if unhandled.
|
||||
|
||||
@@ -8,7 +8,14 @@ from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import EventLog, NotificationTracker, ServiceProvider
|
||||
from ..database.models import (
|
||||
EventLog,
|
||||
NotificationTarget,
|
||||
NotificationTracker,
|
||||
NotificationTrackerTarget,
|
||||
ServiceProvider,
|
||||
TargetReceiver,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,25 +27,125 @@ async def get_trackers_for_provider(provider_id: int) -> list[NotificationTracke
|
||||
return await _get_notification_trackers_for_providers({provider_id})
|
||||
|
||||
|
||||
async def get_last_event_str(tracker_ids: list[int]) -> str:
|
||||
async def get_last_event_str(
|
||||
tracker_ids: list[int],
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> str:
|
||||
"""Get formatted timestamp of most recent event for given trackers.
|
||||
|
||||
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist.
|
||||
|
||||
When ``allowed_album_ids`` is provided, only events whose
|
||||
``collection_id`` is in the set are considered — matches the per-chat
|
||||
scope applied via ``CommandTrackerListener.allowed_album_ids``.
|
||||
"""
|
||||
if not tracker_ids:
|
||||
return "-"
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
query = (
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if allowed_album_ids is not None:
|
||||
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
|
||||
result = await session.exec(query.limit(1))
|
||||
last_event = result.first()
|
||||
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
|
||||
|
||||
|
||||
async def resolve_chat_album_scope(
|
||||
*,
|
||||
provider_id: int,
|
||||
bot_id: int,
|
||||
chat_id: str,
|
||||
) -> set[str]:
|
||||
"""Compute the album scope for a (provider, bot, chat) triple.
|
||||
|
||||
Walks the notification-routing graph: find every notification tracker for
|
||||
``provider_id`` that ultimately delivers to a Telegram receiver matching
|
||||
this ``(bot_id, chat_id)``, then union their ``collection_ids``. The
|
||||
result is the set of albums this specific chat legitimately sees
|
||||
notifications for — which is the natural "allowed albums" for commands
|
||||
issued in that chat.
|
||||
|
||||
Returns:
|
||||
set of album ids. Empty set = "no tracker routes to this chat" —
|
||||
caller should treat as "show nothing" (defense in depth); otherwise
|
||||
a bot's chats would leak the provider's full album catalog.
|
||||
|
||||
Notes:
|
||||
- Only enabled ``TargetReceiver`` rows are considered.
|
||||
- Both direct Telegram targets and broadcast targets that fan out
|
||||
to a Telegram child target are resolved.
|
||||
- Explicit ``CommandTrackerListener.allowed_album_ids`` override is
|
||||
NOT applied here — that's the dispatcher's job. This helper is
|
||||
the "derived" fallback.
|
||||
"""
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
# 1. Telegram receivers in this chat (directly or via broadcast).
|
||||
direct_rows = (await session.exec(
|
||||
select(TargetReceiver, NotificationTarget)
|
||||
.join(
|
||||
NotificationTarget,
|
||||
TargetReceiver.target_id == NotificationTarget.id,
|
||||
)
|
||||
.where(
|
||||
TargetReceiver.enabled == True, # noqa: E712
|
||||
NotificationTarget.type == "telegram",
|
||||
)
|
||||
)).all()
|
||||
target_ids: set[int] = set()
|
||||
for recv, target in direct_rows:
|
||||
rc_chat = str(recv.config.get("chat_id", "") or "")
|
||||
rc_bot = target.config.get("bot_id")
|
||||
if rc_chat == str(chat_id) and rc_bot == bot_id:
|
||||
target_ids.add(target.id)
|
||||
|
||||
# Follow broadcast parents: any broadcast target whose
|
||||
# child_target_ids includes one of our direct Telegram target_ids
|
||||
# also counts as "routes to this chat".
|
||||
broadcast_rows = (await session.exec(
|
||||
select(NotificationTarget).where(NotificationTarget.type == "broadcast")
|
||||
)).all()
|
||||
for b in broadcast_rows:
|
||||
children = set(b.config.get("child_target_ids", []) or [])
|
||||
disabled = set(b.config.get("disabled_child_ids", []) or [])
|
||||
if (children - disabled) & target_ids:
|
||||
target_ids.add(b.id)
|
||||
|
||||
if not target_ids:
|
||||
return set()
|
||||
|
||||
# 2. Trackers pointing at those targets.
|
||||
tracker_target_rows = (await session.exec(
|
||||
select(NotificationTrackerTarget).where(
|
||||
NotificationTrackerTarget.target_id.in_(target_ids)
|
||||
)
|
||||
)).all()
|
||||
tracker_ids = {tt.tracker_id for tt in tracker_target_rows}
|
||||
if not tracker_ids:
|
||||
return set()
|
||||
|
||||
# 3. Filter trackers by provider and collect collection_ids.
|
||||
trackers = (await session.exec(
|
||||
select(NotificationTracker).where(
|
||||
NotificationTracker.id.in_(tracker_ids),
|
||||
NotificationTracker.provider_id == provider_id,
|
||||
)
|
||||
)).all()
|
||||
|
||||
scope: set[str] = set()
|
||||
for tr in trackers:
|
||||
for aid in (tr.collection_ids or []):
|
||||
if aid:
|
||||
scope.add(aid)
|
||||
return scope
|
||||
|
||||
|
||||
def get_tracked_collection_ids(
|
||||
provider: ServiceProvider,
|
||||
trackers: list[NotificationTracker],
|
||||
|
||||
@@ -79,6 +79,7 @@ class GiteaCommandHandler(ProviderCommandHandler):
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
|
||||
@@ -286,6 +286,8 @@ async def handle_command(
|
||||
page = max(1, count_override)
|
||||
count_override = None
|
||||
|
||||
from .command_utils import resolve_chat_album_scope
|
||||
|
||||
responses: list[CommandResponse] = []
|
||||
for tracker, config, provider, listener in ctx_tuples:
|
||||
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
|
||||
@@ -303,10 +305,27 @@ async def handle_command(
|
||||
count = min(count_override or config.default_count or 5, 20)
|
||||
response_mode = config.response_mode or "media"
|
||||
|
||||
# Resolve the album scope for this (provider, bot, chat) triple.
|
||||
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
|
||||
# - Otherwise derive from notification routing: only albums that
|
||||
# already deliver notifications to this chat are queryable from
|
||||
# it. Prevents commands leaking the full album catalog into
|
||||
# chats that were never set up to receive from those trackers.
|
||||
if listener is not None and listener.allowed_album_ids is not None:
|
||||
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
|
||||
else:
|
||||
allowed_album_ids = await resolve_chat_album_scope(
|
||||
provider_id=provider.id,
|
||||
bot_id=bot.id,
|
||||
chat_id=chat_id,
|
||||
)
|
||||
|
||||
result = await handler.handle(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, tracker_templates, bot, tracker, config,
|
||||
listener=listener, page=page,
|
||||
listener=listener,
|
||||
allowed_album_ids=allowed_album_ids,
|
||||
page=page,
|
||||
)
|
||||
if result is not None:
|
||||
responses.append(result)
|
||||
@@ -348,12 +367,23 @@ async def send_reply(
|
||||
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
) -> None:
|
||||
"""Send a text reply via TelegramClient."""
|
||||
"""Send a text reply via TelegramClient.
|
||||
|
||||
Command responses are listings (albums, people, events, ...) that embed
|
||||
multiple links; Telegram's default behavior of rendering a preview of
|
||||
the first URL is almost never what the user wants and clashes with the
|
||||
"Disable link previews" toggle operators set on their Telegram target.
|
||||
We always pass ``disable_web_page_preview=True`` here.
|
||||
"""
|
||||
if session is None:
|
||||
from ..services.http_session import get_http_session
|
||||
session = await get_http_session()
|
||||
client = TelegramClient(session, bot_token)
|
||||
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
|
||||
result = await client.send_message(
|
||||
chat_id, text,
|
||||
reply_to_message_id=reply_to_message_id,
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
if not result.get("success"):
|
||||
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _cmd_albums(
|
||||
provider: ServiceProvider, locale: str,
|
||||
provider: ServiceProvider,
|
||||
locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
if not trackers:
|
||||
@@ -31,6 +34,13 @@ async def _cmd_albums(
|
||||
if aid not in seen:
|
||||
seen.add(aid)
|
||||
album_ids.append(aid)
|
||||
|
||||
# Intersect with the dispatcher-resolved scope (listener override, else
|
||||
# derived from notification routing for this chat). Without this,
|
||||
# /albums leaks the full tracked-album list into chats never wired up.
|
||||
if allowed_album_ids is not None:
|
||||
album_ids = [aid for aid in album_ids if aid in allowed_album_ids]
|
||||
|
||||
if not album_ids:
|
||||
return {"albums": []}
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
|
||||
async def _cmd_events(
|
||||
provider: ServiceProvider,
|
||||
count: int, locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
@@ -35,12 +37,14 @@ async def _cmd_events(
|
||||
|
||||
engine = get_engine()
|
||||
async with AsyncSession(engine) as session:
|
||||
result = await session.exec(
|
||||
query = (
|
||||
select(EventLog)
|
||||
.where(EventLog.tracker_id.in_(tracker_ids))
|
||||
.order_by(EventLog.created_at.desc())
|
||||
.limit(count)
|
||||
)
|
||||
if allowed_album_ids is not None:
|
||||
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
|
||||
result = await session.exec(query.limit(count))
|
||||
events = result.all()
|
||||
|
||||
events_data = [
|
||||
|
||||
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def _cmd_status(
|
||||
provider: ServiceProvider, locale: str,
|
||||
*,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
trackers = await get_trackers_for_provider(provider.id)
|
||||
active = sum(1 for t in trackers if t.enabled)
|
||||
total = len(trackers)
|
||||
total_albums = sum(len(t.collection_ids or []) for t in trackers)
|
||||
|
||||
# Count only albums visible to this chat. Without the scope filter,
|
||||
# /status in a restricted chat leaks the full album count across the
|
||||
# provider. ``None`` = no filter; empty set = show nothing.
|
||||
total_albums = 0
|
||||
for t in trackers:
|
||||
for aid in (t.collection_ids or []):
|
||||
if allowed_album_ids is None or aid in allowed_album_ids:
|
||||
total_albums += 1
|
||||
|
||||
# Last-event timestamp is already scoped — see get_last_event_str, which
|
||||
# filters EventLog by collection_id against allowed_album_ids.
|
||||
tracker_ids = [t.id for t in trackers]
|
||||
last_str = await get_last_event_str(tracker_ids)
|
||||
last_str = await get_last_event_str(
|
||||
tracker_ids, allowed_album_ids=allowed_album_ids,
|
||||
)
|
||||
|
||||
# Tracker counts (``trackers_active`` / ``trackers_total``) are a
|
||||
# per-provider aggregate — they'd leak info about trackers this chat
|
||||
# has no visibility into once we've scoped everything else. Omitted.
|
||||
return {
|
||||
"trackers_active": active, "trackers_total": total,
|
||||
"total_albums": total_albums, "last_event": last_str,
|
||||
}
|
||||
|
||||
@@ -80,16 +94,17 @@ class ImmichCommandHandler(ProviderCommandHandler):
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
if cmd == "status":
|
||||
ctx = await _cmd_status(provider, locale)
|
||||
ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
|
||||
if cmd == "albums":
|
||||
ctx = await _cmd_albums(provider, locale)
|
||||
ctx = await _cmd_albums(provider, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
|
||||
if cmd == "events":
|
||||
ctx = await _cmd_events(provider, count, locale)
|
||||
ctx = await _cmd_events(provider, count, locale, allowed_album_ids=allowed_album_ids)
|
||||
return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
|
||||
if cmd == "people":
|
||||
ctx = await _cmd_people(provider, locale)
|
||||
@@ -99,7 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
|
||||
return await _cmd_immich(
|
||||
cmd, args, count, locale, response_mode,
|
||||
provider, cmd_templates,
|
||||
listener=listener, page=page,
|
||||
allowed_album_ids=allowed_album_ids, page=page,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -109,7 +124,7 @@ async def _cmd_immich(
|
||||
response_mode: str, provider: ServiceProvider,
|
||||
cmd_templates: dict[str, dict[str, str]],
|
||||
*,
|
||||
listener: CommandTrackerListener | None = None,
|
||||
allowed_album_ids: set[str] | None = None,
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
"""Handle commands that need Immich API access and may return media."""
|
||||
@@ -123,10 +138,12 @@ async def _cmd_immich(
|
||||
seen.add(aid)
|
||||
all_album_ids.append(aid)
|
||||
|
||||
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
|
||||
if listener is not None and listener.allowed_album_ids is not None:
|
||||
allowed = set(listener.allowed_album_ids)
|
||||
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
|
||||
# Intersect with the scope resolved by the dispatcher (from the listener
|
||||
# override if set, otherwise from the notification-routing graph for this
|
||||
# chat). ``None`` = no filter (rare); empty set = show nothing (common
|
||||
# when the chat has no tracker routing).
|
||||
if allowed_album_ids is not None:
|
||||
all_album_ids = [aid for aid in all_album_ids if aid in allowed_album_ids]
|
||||
|
||||
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ async def cmd_search(
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
|
||||
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ async def cmd_find(
|
||||
if not args:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
|
||||
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ async def cmd_person(
|
||||
if not person_id:
|
||||
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
|
||||
assets = await client.search_by_person(person_id, limit=count)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
|
||||
@@ -84,5 +84,5 @@ async def cmd_place(
|
||||
assets = await client.search_smart(
|
||||
f"photos taken in {args}", album_ids=all_album_ids, limit=count
|
||||
)
|
||||
_enrich_assets(assets, asset_public_urls or {})
|
||||
assets = _enrich_assets(assets, asset_public_urls or {})
|
||||
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
|
||||
|
||||
@@ -54,6 +54,7 @@ class NutCommandHandler(ProviderCommandHandler):
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
|
||||
@@ -71,6 +71,7 @@ class PlankaCommandHandler(ProviderCommandHandler):
|
||||
config: CommandConfig,
|
||||
*,
|
||||
listener: Any = None,
|
||||
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
|
||||
page: int = 1,
|
||||
) -> CommandResponse | None:
|
||||
fn = _TEXT_COMMANDS.get(cmd)
|
||||
|
||||
@@ -92,6 +92,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
await conn.execute(text(sql))
|
||||
logger.info("Added %s column to event_log table", col)
|
||||
|
||||
# Explicit indexes on the dashboard-query columns. SQLModel's
|
||||
# ``index=True`` is emitted by ``create_all`` on *new* installs,
|
||||
# but ALTER TABLE ADD COLUMN doesn't create them on upgrades —
|
||||
# so the first boot after upgrade would leave these unindexed
|
||||
# and status.py ``WHERE user_id=...`` would table-scan. The
|
||||
# indexes are redundant-but-safe once create_all also runs.
|
||||
for idx_name, col in [
|
||||
("ix_event_log_user_id", "user_id"),
|
||||
("ix_event_log_action_id", "action_id"),
|
||||
("ix_event_log_provider_id", "provider_id"),
|
||||
]:
|
||||
await conn.execute(
|
||||
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
|
||||
)
|
||||
|
||||
# Backfill user_id from notification_tracker for legacy rows.
|
||||
# Safe to run repeatedly: only touches rows where user_id is still NULL.
|
||||
await conn.execute(text("""
|
||||
@@ -250,6 +265,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
logger.info("Added track_webhook_received column to tracking_config table")
|
||||
|
||||
# Add quiet hours to tracking_config if missing.
|
||||
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
if not await _has_column(conn, "tracking_config", "quiet_hours_enabled"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE tracking_config ADD COLUMN quiet_hours_enabled INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added quiet_hours_enabled column to tracking_config table")
|
||||
for col_name in ("quiet_hours_start", "quiet_hours_end"):
|
||||
if not await _has_column(conn, "tracking_config", col_name):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} TEXT")
|
||||
)
|
||||
logger.info("Added %s column to tracking_config table", col_name)
|
||||
|
||||
# Drop legacy template content columns from template_config
|
||||
# (template content moved to template_slot child rows)
|
||||
if await _has_table(conn, "template_config"):
|
||||
|
||||
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
|
||||
memory_asset_type: str = Field(default="all")
|
||||
memory_min_rating: int = Field(default=0)
|
||||
|
||||
# Quiet hours — HH:MM strings interpreted in the app-level timezone
|
||||
# (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window
|
||||
# still represents "explicitly disabled" vs "not yet configured".
|
||||
quiet_hours_enabled: bool = Field(default=False)
|
||||
quiet_hours_start: str | None = Field(default=None)
|
||||
quiet_hours_end: str | None = Field(default=None)
|
||||
|
||||
created_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, time, timezone
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlmodel import select
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
@@ -29,12 +30,32 @@ from ..database.models import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
"""Check if the current UTC time is within the quiet hours window."""
|
||||
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
|
||||
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
|
||||
if not tz_name:
|
||||
return ZoneInfo("UTC")
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except (ZoneInfoNotFoundError, ValueError):
|
||||
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def in_quiet_hours(
|
||||
start: str | None,
|
||||
end: str | None,
|
||||
tz_name: str | None = "UTC",
|
||||
) -> bool:
|
||||
"""Check if the current time (in the given timezone) is within the quiet window.
|
||||
|
||||
HH:MM strings are interpreted in the supplied timezone. If either bound is
|
||||
missing, quiet hours are disabled.
|
||||
"""
|
||||
if not start or not end:
|
||||
return False
|
||||
try:
|
||||
now = datetime.now(timezone.utc).time()
|
||||
tz = _resolve_zoneinfo(tz_name)
|
||||
now = datetime.now(timezone.utc).astimezone(tz).time()
|
||||
t_start = time.fromisoformat(start)
|
||||
t_end = time.fromisoformat(end)
|
||||
if t_start <= t_end:
|
||||
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
||||
"""Check if an event type is allowed by the tracking config's flags."""
|
||||
async def get_app_timezone(session: AsyncSession) -> str:
|
||||
"""Load the app-level timezone from AppSetting (falls back to UTC)."""
|
||||
from ..api.app_settings import get_setting
|
||||
value = await get_setting(session, "timezone")
|
||||
return value or "UTC"
|
||||
|
||||
|
||||
def event_allowed_by_config(
|
||||
event: ServiceEvent,
|
||||
tc: TrackingConfig,
|
||||
tz_name: str | None = "UTC",
|
||||
) -> bool:
|
||||
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
|
||||
# Quiet hours gate every event type when enabled.
|
||||
if tc.quiet_hours_enabled and in_quiet_hours(
|
||||
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
|
||||
):
|
||||
return False
|
||||
|
||||
event_type = event.event_type.value
|
||||
flag_map = {
|
||||
# Immich events
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Cap on concurrent per-receiver test sends. Keeps us under Telegram's per-bot
|
||||
# rate limit (~30 msg/s) while still saving ~N×RTT on multi-chat broadcasts.
|
||||
_TEST_SEND_CONCURRENCY = 5
|
||||
|
||||
_TEST_MESSAGES: dict[str, dict[str, str]] = {
|
||||
"en": {
|
||||
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
|
||||
@@ -358,19 +363,29 @@ async def _send_telegram_test_per_receiver(
|
||||
|
||||
http = await get_http_session()
|
||||
client = TelegramClient(http, bot_token)
|
||||
results: list[dict] = []
|
||||
for r in recv_rows:
|
||||
|
||||
# Parallelize per-receiver sends with a small semaphore — broadcast to
|
||||
# N chats now takes ~ceil(N / concurrency) × RTT instead of N × RTT,
|
||||
# matching the dispatcher's bounded-concurrency pattern. Capped below
|
||||
# Telegram's rate limit so we don't trigger 429s on large fleets.
|
||||
sem = asyncio.Semaphore(_TEST_SEND_CONCURRENCY)
|
||||
|
||||
async def _send_one(r: TargetReceiver) -> dict | None:
|
||||
chat_id = str(r.config.get("chat_id", ""))
|
||||
if not chat_id:
|
||||
continue
|
||||
return None
|
||||
explicit = getattr(r, "locale", "") or ""
|
||||
locale = explicit or chat_locale_map.get(chat_id) or default_locale
|
||||
message = _get_test_message(locale[:2].lower(), "telegram")
|
||||
results.append(await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=message,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
))
|
||||
async with sem:
|
||||
return await client.send_message(
|
||||
chat_id=chat_id,
|
||||
text=message,
|
||||
disable_web_page_preview=bool(disable_preview),
|
||||
)
|
||||
|
||||
raw = await asyncio.gather(*(_send_one(r) for r in recv_rows))
|
||||
results = [r for r in raw if r is not None]
|
||||
return _aggregate(results)
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,19 @@ If the apply fails, the pending file is kept so the operator can inspect it
|
||||
and markers are updated to record the last error. On success, the staged file
|
||||
is archived under data/applied_restores/<timestamp>.json and markers are
|
||||
cleared.
|
||||
|
||||
Integrity checks on startup:
|
||||
- The on-disk file's SHA256 must match ``PENDING_RESTORE_SHA256_KEY``
|
||||
(written atomically with the staged file). Protects against tampering
|
||||
between prepare and restart.
|
||||
- The pending path must resolve *inside* ``app_config.data_dir``. Protects
|
||||
against a rogue AppSetting pointing at an arbitrary file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
@@ -24,11 +33,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from ..api.backup import (
|
||||
PENDING_RESTORE_CONFLICT_KEY,
|
||||
PENDING_RESTORE_PATH_KEY,
|
||||
PENDING_RESTORE_SHA256_KEY,
|
||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||
_applied_restores_dir,
|
||||
_pending_restore_path,
|
||||
)
|
||||
from ..config import settings as app_config
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import AppSetting
|
||||
from .backup_schema import BackupFile, ConflictMode
|
||||
@@ -49,6 +60,23 @@ async def apply_pending_restore_if_any() -> None:
|
||||
return
|
||||
|
||||
pending_path = _pending_restore_path()
|
||||
|
||||
# Defensive: ensure the hard-coded path still lives inside data_dir.
|
||||
# If future refactors let this be read from AppSetting, this check
|
||||
# blocks arbitrary-file reads.
|
||||
try:
|
||||
resolved = pending_path.resolve()
|
||||
data_root = app_config.data_dir.resolve()
|
||||
resolved.relative_to(data_root)
|
||||
except (ValueError, OSError):
|
||||
_LOGGER.error(
|
||||
"Pending-restore path %s is outside data_dir %s — refusing to apply",
|
||||
pending_path, app_config.data_dir,
|
||||
)
|
||||
await _record_error(session, "Pending path outside data_dir")
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
if not pending_path.exists():
|
||||
_LOGGER.warning(
|
||||
"Pending-restore marker present but file missing at %s — clearing marker",
|
||||
@@ -62,9 +90,42 @@ async def apply_pending_restore_if_any() -> None:
|
||||
conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP
|
||||
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
|
||||
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
|
||||
sha_row = await session.get(AppSetting, PENDING_RESTORE_SHA256_KEY)
|
||||
expected_sha = (sha_row.value or "").strip().lower() if sha_row else ""
|
||||
|
||||
try:
|
||||
raw = json.loads(pending_path.read_text(encoding="utf-8"))
|
||||
raw_bytes = await asyncio.to_thread(pending_path.read_bytes)
|
||||
except OSError as err:
|
||||
_LOGGER.exception("Pending-restore file unreadable")
|
||||
await _record_error(session, f"Unreadable backup: {err}")
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
# Integrity: reject unless hash matches what prepare-restore stored.
|
||||
# An attacker with write access to data/ (swapped file, bind-mount
|
||||
# abuse) does not also have write access to the DB.
|
||||
if not expected_sha:
|
||||
_LOGGER.error("Pending-restore marker has no SHA256; refusing to apply")
|
||||
await _record_error(session, "Missing integrity marker")
|
||||
await session.commit()
|
||||
return
|
||||
actual_sha = hashlib.sha256(raw_bytes).hexdigest()
|
||||
if actual_sha != expected_sha:
|
||||
_LOGGER.error(
|
||||
"Pending-restore SHA256 mismatch (expected %s, got %s) — refusing to apply",
|
||||
expected_sha, actual_sha,
|
||||
)
|
||||
await _record_error(
|
||||
session,
|
||||
"Integrity check failed: on-disk backup SHA256 does not match the hash "
|
||||
"recorded at prepare time. File may have been tampered with; cancel and "
|
||||
"re-upload.",
|
||||
)
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
raw = json.loads(raw_bytes.decode("utf-8"))
|
||||
backup = BackupFile.model_validate(raw)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.exception("Pending-restore file unreadable")
|
||||
@@ -88,8 +149,14 @@ async def apply_pending_restore_if_any() -> None:
|
||||
result = await import_backup(session, admin_row.id, backup, conflict_mode)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.exception("Pending-restore apply failed")
|
||||
await _record_error(session, str(err))
|
||||
await session.commit()
|
||||
# Discard any partial inserts the importer made before raising —
|
||||
# committing partial state would let a crafted failing backup
|
||||
# selectively mutate entities. The error-record commit below
|
||||
# happens on a *fresh* session.
|
||||
await session.rollback()
|
||||
async with AsyncSession(engine) as fresh:
|
||||
await _record_error(fresh, str(err))
|
||||
await fresh.commit()
|
||||
return
|
||||
|
||||
# Archive the file
|
||||
@@ -136,6 +203,7 @@ async def _clear_markers(session: AsyncSession) -> None:
|
||||
PENDING_RESTORE_CONFLICT_KEY,
|
||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||
PENDING_RESTORE_SHA256_KEY,
|
||||
):
|
||||
row = await session.get(AppSetting, key)
|
||||
if row:
|
||||
|
||||
@@ -166,30 +166,41 @@ async def _refresh_telegram_chat_titles() -> None:
|
||||
|
||||
refreshed = 0
|
||||
errors = 0
|
||||
# Bucket results first, then fetch all rows in one IN-query instead of
|
||||
# per-row ``session.get`` — otherwise a 50-chat fleet issues 50 extra
|
||||
# SELECTs before commit.
|
||||
successes: dict[int, dict] = {}
|
||||
for chat_id, info, err in results:
|
||||
if err is not None or info is None:
|
||||
errors += 1
|
||||
if err:
|
||||
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
|
||||
continue
|
||||
if chat_id is not None:
|
||||
successes[chat_id] = info
|
||||
async with AsyncSession(engine) as session:
|
||||
for chat_id, info, err in results:
|
||||
if err is not None or info is None:
|
||||
errors += 1
|
||||
if err:
|
||||
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
|
||||
continue
|
||||
merged = await session.get(TelegramChat, chat_id)
|
||||
if not merged:
|
||||
continue
|
||||
title = info.get("title") or (
|
||||
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
|
||||
)
|
||||
changed = False
|
||||
if title and merged.title != title:
|
||||
merged.title = title
|
||||
changed = True
|
||||
new_username = info.get("username")
|
||||
if new_username is not None and merged.username != new_username:
|
||||
merged.username = new_username
|
||||
changed = True
|
||||
if changed:
|
||||
session.add(merged)
|
||||
refreshed += 1
|
||||
if successes:
|
||||
rows = (await session.exec(
|
||||
select(TelegramChat).where(TelegramChat.id.in_(list(successes.keys())))
|
||||
)).all()
|
||||
for merged in rows:
|
||||
info = successes.get(merged.id)
|
||||
if not info:
|
||||
continue
|
||||
title = info.get("title") or (
|
||||
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
|
||||
)
|
||||
changed = False
|
||||
if title and merged.title != title:
|
||||
merged.title = title
|
||||
changed = True
|
||||
new_username = info.get("username")
|
||||
if new_username is not None and merged.username != new_username:
|
||||
merged.username = new_username
|
||||
changed = True
|
||||
if changed:
|
||||
session.add(merged)
|
||||
refreshed += 1
|
||||
await session.commit()
|
||||
_LOGGER.info(
|
||||
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors
|
||||
|
||||
@@ -250,14 +250,23 @@ async def _build_immich_event(
|
||||
collection_ids, limit, asset_type, favorite_only, min_rating,
|
||||
)
|
||||
|
||||
# Album-based path: use shared collect_scheduled_assets
|
||||
# Album-based path: use shared collect_scheduled_assets.
|
||||
# Fetch albums + shared links in parallel — on a 20-album tracker the old
|
||||
# serial ``await`` loop took ~2 × 20 × RTT, now it's one round-trip.
|
||||
import asyncio as _asyncio
|
||||
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
|
||||
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
|
||||
album_results, link_results = await _asyncio.gather(
|
||||
_asyncio.gather(*album_tasks, return_exceptions=True),
|
||||
_asyncio.gather(*link_tasks, return_exceptions=True),
|
||||
)
|
||||
albums: dict[str, ImmichAlbumData] = {}
|
||||
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
||||
for album_id in collection_ids:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if album:
|
||||
albums[album_id] = album
|
||||
shared_links[album_id] = await immich.client.get_shared_links(album_id)
|
||||
for album_id, album, links in zip(collection_ids, album_results, link_results):
|
||||
if isinstance(album, Exception) or album is None:
|
||||
continue
|
||||
albums[album_id] = album
|
||||
shared_links[album_id] = links if not isinstance(links, Exception) else []
|
||||
|
||||
assets, collections_extra = collect_scheduled_assets(
|
||||
albums, shared_links, ext_domain,
|
||||
@@ -320,13 +329,21 @@ async def _build_immich_periodic_event(
|
||||
|
||||
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
|
||||
|
||||
# Parallel fetch — see _build_immich_event above for the same rationale.
|
||||
import asyncio as _asyncio
|
||||
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
|
||||
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
|
||||
album_results, link_results = await _asyncio.gather(
|
||||
_asyncio.gather(*album_tasks, return_exceptions=True),
|
||||
_asyncio.gather(*link_tasks, return_exceptions=True),
|
||||
)
|
||||
albums: dict[str, ImmichAlbumData] = {}
|
||||
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
||||
for album_id in collection_ids:
|
||||
album = await immich.client.get_album(album_id)
|
||||
if album:
|
||||
albums[album_id] = album
|
||||
shared_links[album_id] = await immich.client.get_shared_links(album_id)
|
||||
for album_id, album, links in zip(collection_ids, album_results, link_results):
|
||||
if isinstance(album, Exception) or album is None:
|
||||
continue
|
||||
albums[album_id] = album
|
||||
shared_links[album_id] = links if not isinstance(links, Exception) else []
|
||||
|
||||
# limit=0 → returns ([], collections_extra) with full per-album stats.
|
||||
_assets, collections_extra = collect_scheduled_assets(
|
||||
|
||||
@@ -21,7 +21,11 @@ from ..database.models import (
|
||||
NotificationTrackerState,
|
||||
ServiceProvider,
|
||||
)
|
||||
from .dispatch_helpers import event_allowed_by_config, load_link_data
|
||||
from .dispatch_helpers import (
|
||||
event_allowed_by_config,
|
||||
get_app_timezone,
|
||||
load_link_data,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,7 +89,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
# Load tracker-target links
|
||||
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True)
|
||||
link_data = await load_link_data(session, tracker_id)
|
||||
|
||||
# Load app-level timezone for quiet-hours evaluation.
|
||||
app_tz = await get_app_timezone(session)
|
||||
|
||||
# Snapshot the data we need
|
||||
provider_type = provider.type
|
||||
@@ -236,7 +243,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
||||
for ld in link_data:
|
||||
# Apply per-link event filtering from tracking config
|
||||
tc = ld["tracking_config"]
|
||||
if tc and not event_allowed_by_config(event, tc):
|
||||
if tc and not event_allowed_by_config(event, tc, app_tz):
|
||||
_LOGGER.info(" Skipped by tracking config filter")
|
||||
continue
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ fi
|
||||
# Start backend
|
||||
export NOTIFY_BRIDGE_DATA_DIR=./test-data
|
||||
export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars
|
||||
# Dev targets (homelab Immich / Gitea / etc.) live on RFC1918 ranges; the SSRF
|
||||
# guard rejects private addresses by default, which would make trackers fail.
|
||||
export NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
|
||||
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
||||
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 &
|
||||
|
||||
|
||||
Reference in New Issue
Block a user