Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 83215473c7 | |||
| 4e23d2b054 | |||
| f7d51b27d2 | |||
| 3bb0585e43 | |||
| 58cba88c92 | |||
| 645331d320 | |||
| 6c3dd67c1b | |||
| 56993d2ca3 |
+13
-68
@@ -1,82 +1,27 @@
|
||||
## v0.2.0 (2026-04-22)
|
||||
## v0.2.2 (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.
|
||||
|
||||
### 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))
|
||||
Patch release — homelab usability fixes on top of v0.2.1. The SSRF hardening
|
||||
introduced in v0.2.1 blocks outbound requests to RFC1918 / link-local hosts,
|
||||
which breaks tracking of Immich / Gitea / etc. running on the same LAN.
|
||||
This release makes the workaround discoverable and enables it by default
|
||||
in the shipped `docker-compose.yml`.
|
||||
|
||||
### 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))
|
||||
- **Default `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1` in `docker-compose.yml`** — the shipped compose is intended for homelab use. The flag is now hardcoded in the `environment:` block (not a `${...}` substitution) so it works correctly with Portainer's per-stack env panel, which only does compose-file substitution and not runtime container env. Operators running on a public-facing host can drop the line. ([4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b))
|
||||
|
||||
---
|
||||
### Documentation
|
||||
|
||||
### 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))
|
||||
- **Surface `NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS` hint in SSRF rejection errors** — the `UnsafeURLError` raised by `ImmichClient` now tells operators how to allow LAN targets, instead of leaving them to dig through source. ([58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88))
|
||||
|
||||
---
|
||||
|
||||
<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 |
|
||||
- [4e23d2b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4e23d2b) — chore(compose): hardcode NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 in compose _(alexei.dolgolyov)_
|
||||
- [f7d51b2](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/f7d51b2) — Revert "chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab" _(alexei.dolgolyov)_
|
||||
- [3bb0585](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/3bb0585) — chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab _(alexei.dolgolyov)_
|
||||
- [58cba88](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/58cba88) — docs(immich-ssrf): surface NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS hint in error _(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.2",
|
||||
"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.",
|
||||
|
||||
@@ -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 дней от этой даты.",
|
||||
|
||||
@@ -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.2"
|
||||
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,9 +291,12 @@ 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
|
||||
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/smart",
|
||||
@@ -261,9 +318,10 @@ 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]
|
||||
try:
|
||||
async with self._session.post(
|
||||
f"{self._url}/api/search/metadata",
|
||||
@@ -289,7 +347,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 +431,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 +465,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
|
||||
|
||||
@@ -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.2"
|
||||
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")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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