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
|
Patch release — homelab usability fixes on top of v0.2.1. The SSRF hardening
|
||||||
the backend, frontend, and schema, plus two fixes landed on top.
|
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.
|
||||||
### Features
|
This release makes the workaround discoverable and enables it by default
|
||||||
|
in the shipped `docker-compose.yml`.
|
||||||
#### 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))
|
|
||||||
|
|
||||||
### Bug Fixes
|
### 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))
|
- **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))
|
||||||
- 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))
|
|
||||||
|
|
||||||
---
|
### Documentation
|
||||||
|
|
||||||
### Development / Internal
|
- **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))
|
||||||
|
|
||||||
#### 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))
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>All Commits</summary>
|
<summary>All Commits</summary>
|
||||||
|
|
||||||
| Hash | Message | Author |
|
- [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)_
|
||||||
| [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 |
|
- [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)_
|
||||||
| [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 |
|
- [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)_
|
||||||
| [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 |
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
|
- 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:-*}
|
- 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:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "notify-bridge-frontend",
|
"name": "notify-bridge-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
+46
-27
@@ -94,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
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>(
|
export async function api<T = any>(
|
||||||
path: string,
|
path: string,
|
||||||
@@ -170,42 +173,58 @@ export async function api<T = any>(
|
|||||||
*/
|
*/
|
||||||
export async function fetchAuth(
|
export async function fetchAuth(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit & { timeoutMs?: number } = {},
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||||
let res = await fetch(url, { ...options, headers });
|
|
||||||
|
|
||||||
if (res.status === 401 && token) {
|
// Abort after timeout so uploads/downloads don't hang indefinitely if
|
||||||
const refreshed = await refreshAccessToken();
|
// the backend stops responding. Callers can override per-request via
|
||||||
if (refreshed) {
|
// options.timeoutMs or pass their own signal to opt out.
|
||||||
headers['Authorization'] = `Bearer ${getToken()}`;
|
const { timeoutMs, ...fetchOptions } = options;
|
||||||
res = await fetch(url, { ...options, headers });
|
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) {
|
if (res.status === 401) {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
if (typeof window !== 'undefined') window.location.href = '/login';
|
if (typeof window !== 'undefined') window.location.href = '/login';
|
||||||
throw new ApiError('Unauthorized', 401);
|
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);
|
|
||||||
}
|
}
|
||||||
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",
|
"memorySource": "Memory source",
|
||||||
"memorySourceAlbums": "Scan tracked albums",
|
"memorySourceAlbums": "Scan tracked albums",
|
||||||
"memorySourceNative": "Immich native memories",
|
"memorySourceNative": "Immich native memories",
|
||||||
|
"quietHours": "Quiet hours",
|
||||||
|
"quietHoursStart": "Start",
|
||||||
|
"quietHoursEnd": "End",
|
||||||
"test": "Test",
|
"test": "Test",
|
||||||
"confirmDelete": "Delete this tracking config?",
|
"confirmDelete": "Delete this tracking config?",
|
||||||
"sortNone": "None",
|
"sortNone": "None",
|
||||||
@@ -670,6 +673,8 @@
|
|||||||
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
|
||||||
"cacheTtl": "Media Cache TTL (hours)",
|
"cacheTtl": "Media Cache TTL (hours)",
|
||||||
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
|
"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",
|
"locales": "Template Languages",
|
||||||
"supportedLocales": "Supported Locales",
|
"supportedLocales": "Supported Locales",
|
||||||
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
|
"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.",
|
"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.",
|
"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).",
|
"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.",
|
"favoritesOnly": "Only include assets marked as favorites.",
|
||||||
"maxAssets": "Maximum number of asset details to include in a single notification message.",
|
"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.",
|
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
|
||||||
|
|||||||
@@ -523,6 +523,9 @@
|
|||||||
"memorySource": "Источник воспоминаний",
|
"memorySource": "Источник воспоминаний",
|
||||||
"memorySourceAlbums": "Сканировать альбомы",
|
"memorySourceAlbums": "Сканировать альбомы",
|
||||||
"memorySourceNative": "Встроенные воспоминания Immich",
|
"memorySourceNative": "Встроенные воспоминания Immich",
|
||||||
|
"quietHours": "Тихие часы",
|
||||||
|
"quietHoursStart": "Начало",
|
||||||
|
"quietHoursEnd": "Конец",
|
||||||
"test": "Тест",
|
"test": "Тест",
|
||||||
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
|
||||||
"sortNone": "Нет",
|
"sortNone": "Нет",
|
||||||
@@ -670,6 +673,8 @@
|
|||||||
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
|
||||||
"cacheTtl": "TTL кэша медиа (часы)",
|
"cacheTtl": "TTL кэша медиа (часы)",
|
||||||
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
|
||||||
|
"timezone": "Часовой пояс",
|
||||||
|
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
|
||||||
"locales": "Языки шаблонов",
|
"locales": "Языки шаблонов",
|
||||||
"supportedLocales": "Поддерживаемые локали",
|
"supportedLocales": "Поддерживаемые локали",
|
||||||
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
|
||||||
@@ -680,6 +685,7 @@
|
|||||||
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
|
||||||
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
|
||||||
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
|
||||||
|
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:00–07:00.",
|
||||||
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
|
||||||
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
|
||||||
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
|
"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: '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: {
|
collectionMeta: {
|
||||||
|
|||||||
@@ -192,6 +192,9 @@ export interface TrackingConfig {
|
|||||||
memory_favorite_only: boolean;
|
memory_favorite_only: boolean;
|
||||||
memory_asset_type: string;
|
memory_asset_type: string;
|
||||||
memory_min_rating: number;
|
memory_min_rating: number;
|
||||||
|
quiet_hours_enabled: boolean;
|
||||||
|
quiet_hours_start: string | null;
|
||||||
|
quiet_hours_end: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
telegram_webhook_secret: '',
|
telegram_webhook_secret: '',
|
||||||
telegram_cache_ttl_hours: '48',
|
telegram_cache_ttl_hours: '48',
|
||||||
supported_locales: 'en,ru',
|
supported_locales: 'en,ru',
|
||||||
|
timezone: 'UTC',
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -57,6 +58,11 @@
|
|||||||
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
|
<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" />
|
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>
|
||||||
|
<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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -181,9 +181,12 @@
|
|||||||
{:else if field.type === 'grid-select' && field.gridItems}
|
{:else if field.type === 'grid-select' && field.gridItems}
|
||||||
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
|
||||||
{:else}
|
{: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}
|
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)]" />
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-core"
|
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"
|
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -300,6 +300,16 @@ class TelegramClient:
|
|||||||
# Retry without parse_mode on parse errors
|
# Retry without parse_mode on parse errors
|
||||||
desc = str(result.get("description", ""))
|
desc = str(result.get("description", ""))
|
||||||
if "parse" in desc.lower():
|
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)
|
payload.pop("parse_mode", None)
|
||||||
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
async with self._session.post(telegram_url, json=payload) as retry_resp:
|
||||||
retry_result = await retry_resp.json()
|
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)
|
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
|
||||||
collections_extra: list[dict[str, Any]] = []
|
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():
|
for album_id, album in albums.items():
|
||||||
links = shared_links.get(album_id, [])
|
links = shared_links.get(album_id, [])
|
||||||
album_public_url = get_public_url(external_url, links) or ""
|
album_public_url = get_public_url(external_url, links) or ""
|
||||||
@@ -336,6 +342,9 @@ def collect_scheduled_assets(
|
|||||||
"owner": album.owner,
|
"owner": album.owner,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if stats_only:
|
||||||
|
continue
|
||||||
|
|
||||||
filtered = filter_assets(
|
filtered = filter_assets(
|
||||||
list(album.assets.values()),
|
list(album.assets.values()),
|
||||||
favorite_only=favorite_only,
|
favorite_only=favorite_only,
|
||||||
@@ -348,6 +357,9 @@ def collect_scheduled_assets(
|
|||||||
asset_album_map[asset.id] = (album_id, album_public_url)
|
asset_album_map[asset.id] = (album_id, album_public_url)
|
||||||
all_eligible.append(asset)
|
all_eligible.append(asset)
|
||||||
|
|
||||||
|
if stats_only:
|
||||||
|
return [], collections_extra
|
||||||
|
|
||||||
# Random sample
|
# Random sample
|
||||||
if len(all_eligible) > limit:
|
if len(all_eligible) > limit:
|
||||||
selected = random.sample(all_eligible, limit)
|
selected = random.sample(all_eligible, limit)
|
||||||
|
|||||||
@@ -3,14 +3,47 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
|
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
|
||||||
from .models import ImmichAlbumData, SharedLinkInfo
|
from .models import ImmichAlbumData, SharedLinkInfo
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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:
|
class ImmichClient:
|
||||||
"""Async client for the Immich API."""
|
"""Async client for the Immich API."""
|
||||||
@@ -25,6 +58,19 @@ class ImmichClient:
|
|||||||
self._url = url.rstrip("/")
|
self._url = url.rstrip("/")
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
self._external_domain: str | None = None
|
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
|
@property
|
||||||
def url(self) -> str:
|
def url(self) -> str:
|
||||||
@@ -36,6 +82,14 @@ class ImmichClient:
|
|||||||
|
|
||||||
@external_domain.setter
|
@external_domain.setter
|
||||||
def external_domain(self, value: str | None) -> None:
|
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
|
self._external_domain = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -237,9 +291,12 @@ class ImmichClient:
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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:
|
if album_ids:
|
||||||
payload["albumIds"] = album_ids
|
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||||
try:
|
try:
|
||||||
async with self._session.post(
|
async with self._session.post(
|
||||||
f"{self._url}/api/search/smart",
|
f"{self._url}/api/search/smart",
|
||||||
@@ -261,9 +318,10 @@ class ImmichClient:
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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:
|
if album_ids:
|
||||||
payload["albumIds"] = album_ids
|
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
|
||||||
try:
|
try:
|
||||||
async with self._session.post(
|
async with self._session.post(
|
||||||
f"{self._url}/api/search/metadata",
|
f"{self._url}/api/search/metadata",
|
||||||
@@ -289,7 +347,7 @@ class ImmichClient:
|
|||||||
to return an empty list on current servers.
|
to return an empty list on current servers.
|
||||||
"""
|
"""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"personIds": [person_id],
|
"personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
|
||||||
"page": 1,
|
"page": 1,
|
||||||
"size": max(1, min(limit, 100)),
|
"size": max(1, min(limit, 100)),
|
||||||
}
|
}
|
||||||
@@ -373,9 +431,17 @@ class ImmichClient:
|
|||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
return parsed
|
return parsed
|
||||||
return {"raw": body_text}
|
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(
|
raise ImmichApiError(
|
||||||
f"Failed to add assets to album {album_id}: "
|
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:
|
except aiohttp.ClientError as err:
|
||||||
raise ImmichApiError(f"Error adding assets to album: {err}") from 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):
|
if response.status in (200, 201, 204):
|
||||||
return
|
return
|
||||||
body_text = await response.text()
|
body_text = await response.text()
|
||||||
|
_LOGGER.warning(
|
||||||
|
"set_album_thumbnail failed: HTTP %s body=%s",
|
||||||
|
response.status, body_text[:512],
|
||||||
|
)
|
||||||
raise ImmichApiError(
|
raise ImmichApiError(
|
||||||
f"Failed to set album thumbnail for {album_id}: "
|
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:
|
except aiohttp.ClientError as err:
|
||||||
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
|
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
|
||||||
|
|||||||
@@ -2,16 +2,67 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from notify_bridge_core.models.events import ServiceEvent
|
from notify_bridge_core.models.events import ServiceEvent
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Per-target maximum video size (bytes). None = no limit.
|
# Per-target maximum video size (bytes). None = no limit.
|
||||||
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
|
||||||
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
|
"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(
|
def build_template_context(
|
||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
@@ -61,8 +112,9 @@ def build_template_context(
|
|||||||
"preview_url": asset.preview_url or "",
|
"preview_url": asset.preview_url or "",
|
||||||
"full_url": asset.full_url or "",
|
"full_url": asset.full_url or "",
|
||||||
}
|
}
|
||||||
# Flatten extras into asset dict for template access
|
# Flatten extras into asset dict for template access — same
|
||||||
asset_dict.update(asset.extra)
|
# sensitive-key filtering applied as the top-level merge.
|
||||||
|
_safe_merge_extras(asset_dict, asset.extra)
|
||||||
asset_dict.setdefault("oversized", False)
|
asset_dict.setdefault("oversized", False)
|
||||||
asset_dict.setdefault("file_size", None)
|
asset_dict.setdefault("file_size", None)
|
||||||
asset_dict.setdefault("playback_size", None)
|
asset_dict.setdefault("playback_size", None)
|
||||||
@@ -138,8 +190,11 @@ def build_template_context(
|
|||||||
if len(locations) == 1 and "" not in locations:
|
if len(locations) == 1 and "" not in locations:
|
||||||
ctx["common_location"] = locations.pop()
|
ctx["common_location"] = locations.pop()
|
||||||
|
|
||||||
# Provider-specific extras merged at top level
|
# Provider-specific extras merged at top level. Sensitive keys (tokens,
|
||||||
ctx.update(event.extra)
|
# 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)
|
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
|
||||||
ctx.setdefault("public_url", "")
|
ctx.setdefault("public_url", "")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "notify-bridge-server"
|
name = "notify-bridge-server"
|
||||||
version = "0.2.0"
|
version = "0.2.2"
|
||||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ _SETTING_KEYS = {
|
|||||||
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
|
||||||
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
"telegram_cache_ttl_hours": None, # no env fallback, default 48
|
||||||
"supported_locales": None, # comma-separated locale codes
|
"supported_locales": None, # comma-separated locale codes
|
||||||
|
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULTS = {
|
_DEFAULTS = {
|
||||||
@@ -29,6 +30,7 @@ _DEFAULTS = {
|
|||||||
"telegram_webhook_secret": "",
|
"telegram_webhook_secret": "",
|
||||||
"telegram_cache_ttl_hours": "48",
|
"telegram_cache_ttl_hours": "48",
|
||||||
"supported_locales": "en,ru",
|
"supported_locales": "en,ru",
|
||||||
|
"timezone": "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ class SettingsUpdate(BaseModel):
|
|||||||
telegram_webhook_secret: str | None = None
|
telegram_webhook_secret: str | None = None
|
||||||
telegram_cache_ttl_hours: str | None = None
|
telegram_cache_ttl_hours: str | None = None
|
||||||
supported_locales: str | None = None
|
supported_locales: str | None = None
|
||||||
|
timezone: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
"""Configuration backup/restore API (admin only)."""
|
"""Configuration backup/restore API (admin only)."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
from datetime import datetime, timezone
|
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 fastapi.responses import JSONResponse
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
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_CONFLICT_KEY = "pending_restore_conflict_mode"
|
||||||
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
|
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
|
||||||
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
|
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():
|
def _pending_restore_path():
|
||||||
@@ -44,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
|
|||||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
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():
|
def _backup_dir():
|
||||||
return app_config.data_dir / "backups"
|
return app_config.data_dir / "backups"
|
||||||
|
|
||||||
@@ -104,9 +174,7 @@ async def validate_config(
|
|||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
"""Validate a backup file without importing."""
|
"""Validate a backup file without importing."""
|
||||||
content = await file.read()
|
content = await _read_upload_bounded(file)
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
@@ -129,9 +197,7 @@ async def import_config(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Import configuration from a backup file."""
|
"""Import configuration from a backup file."""
|
||||||
content = await file.read()
|
content = await _read_upload_bounded(file)
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
@@ -167,6 +233,7 @@ async def _clear_pending_restore_markers(session: AsyncSession) -> None:
|
|||||||
PENDING_RESTORE_CONFLICT_KEY,
|
PENDING_RESTORE_CONFLICT_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||||
|
PENDING_RESTORE_SHA256_KEY,
|
||||||
):
|
):
|
||||||
row = await session.get(AppSetting, key)
|
row = await session.get(AppSetting, key)
|
||||||
if row:
|
if row:
|
||||||
@@ -185,9 +252,7 @@ async def prepare_restore(
|
|||||||
Validates the uploaded file, writes it to ``data/pending_restore.json``,
|
Validates the uploaded file, writes it to ``data/pending_restore.json``,
|
||||||
and persists marker settings so startup will apply it atomically.
|
and persists marker settings so startup will apply it atomically.
|
||||||
"""
|
"""
|
||||||
content = await file.read()
|
content = await _read_upload_bounded(file)
|
||||||
if len(content) > MAX_UPLOAD_SIZE:
|
|
||||||
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(content)
|
raw = json.loads(content)
|
||||||
@@ -205,15 +270,25 @@ async def prepare_restore(
|
|||||||
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
pending_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# Atomic write: write to tmp then rename, so a crash mid-write never
|
# Atomic write: write to tmp then rename, so a crash mid-write never
|
||||||
# leaves a truncated pending_restore.json that would break startup apply.
|
# 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 = 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)
|
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()
|
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_PATH_KEY, str(pending_path))
|
||||||
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
|
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_AT_KEY, now_iso)
|
||||||
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
|
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()
|
await session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -292,6 +367,7 @@ def _is_supervised() -> bool:
|
|||||||
|
|
||||||
@router.post("/apply-restart")
|
@router.post("/apply-restart")
|
||||||
async def apply_and_restart(
|
async def apply_and_restart(
|
||||||
|
request: Request,
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
user: User = Depends(require_admin),
|
user: User = Depends(require_admin),
|
||||||
session: AsyncSession = Depends(get_session),
|
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.
|
"""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.
|
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)
|
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
|
||||||
if not path_row or not path_row.value:
|
if not path_row or not path_row.value:
|
||||||
raise HTTPException(status_code=409, detail="No pending restore to apply")
|
raise HTTPException(status_code=409, detail="No pending restore to apply")
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
|
|||||||
memory_favorite_only: bool = False
|
memory_favorite_only: bool = False
|
||||||
memory_asset_type: str = "all"
|
memory_asset_type: str = "all"
|
||||||
memory_min_rating: int = 0
|
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):
|
class TrackingConfigUpdate(BaseModel):
|
||||||
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
|
|||||||
memory_favorite_only: bool | None = None
|
memory_favorite_only: bool | None = None
|
||||||
memory_asset_type: str | None = None
|
memory_asset_type: str | None = None
|
||||||
memory_min_rating: int | 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("")
|
@router.get("")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
@@ -81,6 +82,12 @@ async def update_user(
|
|||||||
if not user:
|
if not user:
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
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:
|
if body.username is not None and body.username != user.username:
|
||||||
new_username = body.username.strip()
|
new_username = body.username.strip()
|
||||||
if not new_username:
|
if not new_username:
|
||||||
@@ -89,21 +96,51 @@ async def update_user(
|
|||||||
if dup.first():
|
if dup.first():
|
||||||
raise HTTPException(status_code=409, detail="Username already exists")
|
raise HTTPException(status_code=409, detail="Username already exists")
|
||||||
user.username = new_username
|
user.username = new_username
|
||||||
|
identity_changed = True
|
||||||
|
|
||||||
if body.role is not None and body.role != user.role:
|
if body.role is not None and body.role != user.role:
|
||||||
if body.role not in ("admin", "user"):
|
if body.role not in ("admin", "user"):
|
||||||
raise HTTPException(status_code=400, detail="Invalid role")
|
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":
|
if user.role == "admin" and body.role != "admin":
|
||||||
admins = (await session.exec(
|
admin_count = (await session.exec(
|
||||||
select(User).where(User.role == "admin")
|
select(func.count(User.id)).where(User.role == "admin")
|
||||||
)).all()
|
)).one()
|
||||||
if len(admins) <= 1:
|
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")
|
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
|
||||||
user.role = body.role
|
user.role = body.role
|
||||||
|
identity_changed = True
|
||||||
|
|
||||||
|
if identity_changed:
|
||||||
|
user.token_version = (user.token_version or 1) + 1
|
||||||
|
|
||||||
session.add(user)
|
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)
|
await session.refresh(user)
|
||||||
return {"id": user.id, "username": user.username, "role": user.role}
|
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:
|
if len(body.new_password) < 8:
|
||||||
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
|
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()
|
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)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"success": True}
|
return {"success": True}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ from ..database.models import (
|
|||||||
ServiceProvider,
|
ServiceProvider,
|
||||||
WebhookPayloadLog,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
|
|||||||
if not link_data:
|
if not link_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
app_tz = await get_app_timezone(session)
|
||||||
|
|
||||||
# Log event
|
# Log event
|
||||||
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
|
||||||
session.add(EventLog(
|
session.add(EventLog(
|
||||||
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
|
|||||||
|
|
||||||
# Dispatch to targets
|
# Dispatch to targets
|
||||||
dispatcher = NotificationDispatcher()
|
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:
|
if target_configs:
|
||||||
results = await dispatcher.dispatch(event, target_configs)
|
results = await dispatcher.dispatch(event, target_configs)
|
||||||
for r in results:
|
for r in results:
|
||||||
@@ -513,12 +519,13 @@ def _build_target_configs(
|
|||||||
event: ServiceEvent,
|
event: ServiceEvent,
|
||||||
link_data: list[dict[str, Any]],
|
link_data: list[dict[str, Any]],
|
||||||
provider_config: dict[str, Any],
|
provider_config: dict[str, Any],
|
||||||
|
app_tz: str = "UTC",
|
||||||
) -> list[TargetConfig]:
|
) -> list[TargetConfig]:
|
||||||
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
|
||||||
target_configs: list[TargetConfig] = []
|
target_configs: list[TargetConfig] = []
|
||||||
for ld in link_data:
|
for ld in link_data:
|
||||||
tc = ld["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):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tmpl = ld["template_config"]
|
tmpl = ld["template_config"]
|
||||||
|
|||||||
@@ -92,6 +92,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
await conn.execute(text(sql))
|
await conn.execute(text(sql))
|
||||||
logger.info("Added %s column to event_log table", col)
|
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.
|
# Backfill user_id from notification_tracker for legacy rows.
|
||||||
# Safe to run repeatedly: only touches rows where user_id is still NULL.
|
# Safe to run repeatedly: only touches rows where user_id is still NULL.
|
||||||
await conn.execute(text("""
|
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")
|
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
|
# Drop legacy template content columns from template_config
|
||||||
# (template content moved to template_slot child rows)
|
# (template content moved to template_slot child rows)
|
||||||
if await _has_table(conn, "template_config"):
|
if await _has_table(conn, "template_config"):
|
||||||
|
|||||||
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
|
|||||||
memory_asset_type: str = Field(default="all")
|
memory_asset_type: str = Field(default="all")
|
||||||
memory_min_rating: int = Field(default=0)
|
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)
|
created_at: datetime = Field(default_factory=_utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, time, timezone
|
from datetime import datetime, time, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from sqlmodel import select
|
from sqlmodel import select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -29,12 +30,32 @@ from ..database.models import (
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
|
||||||
"""Check if the current UTC time is within the quiet hours window."""
|
"""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:
|
if not start or not end:
|
||||||
return False
|
return False
|
||||||
try:
|
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_start = time.fromisoformat(start)
|
||||||
t_end = time.fromisoformat(end)
|
t_end = time.fromisoformat(end)
|
||||||
if t_start <= t_end:
|
if t_start <= t_end:
|
||||||
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
|
async def get_app_timezone(session: AsyncSession) -> str:
|
||||||
"""Check if an event type is allowed by the tracking config's flags."""
|
"""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
|
event_type = event.event_type.value
|
||||||
flag_map = {
|
flag_map = {
|
||||||
# Immich events
|
# Immich events
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
"""Notification sender — unified send logic for all paths (dispatch + test)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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]] = {
|
_TEST_MESSAGES: dict[str, dict[str, str]] = {
|
||||||
"en": {
|
"en": {
|
||||||
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
|
"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()
|
http = await get_http_session()
|
||||||
client = TelegramClient(http, bot_token)
|
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", ""))
|
chat_id = str(r.config.get("chat_id", ""))
|
||||||
if not chat_id:
|
if not chat_id:
|
||||||
continue
|
return None
|
||||||
explicit = getattr(r, "locale", "") or ""
|
explicit = getattr(r, "locale", "") or ""
|
||||||
locale = explicit or chat_locale_map.get(chat_id) or default_locale
|
locale = explicit or chat_locale_map.get(chat_id) or default_locale
|
||||||
message = _get_test_message(locale[:2].lower(), "telegram")
|
message = _get_test_message(locale[:2].lower(), "telegram")
|
||||||
results.append(await client.send_message(
|
async with sem:
|
||||||
chat_id=chat_id,
|
return await client.send_message(
|
||||||
text=message,
|
chat_id=chat_id,
|
||||||
disable_web_page_preview=bool(disable_preview),
|
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)
|
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
|
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
|
is archived under data/applied_restores/<timestamp>.json and markers are
|
||||||
cleared.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
@@ -24,11 +33,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
|||||||
from ..api.backup import (
|
from ..api.backup import (
|
||||||
PENDING_RESTORE_CONFLICT_KEY,
|
PENDING_RESTORE_CONFLICT_KEY,
|
||||||
PENDING_RESTORE_PATH_KEY,
|
PENDING_RESTORE_PATH_KEY,
|
||||||
|
PENDING_RESTORE_SHA256_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||||
_applied_restores_dir,
|
_applied_restores_dir,
|
||||||
_pending_restore_path,
|
_pending_restore_path,
|
||||||
)
|
)
|
||||||
|
from ..config import settings as app_config
|
||||||
from ..database.engine import get_engine
|
from ..database.engine import get_engine
|
||||||
from ..database.models import AppSetting
|
from ..database.models import AppSetting
|
||||||
from .backup_schema import BackupFile, ConflictMode
|
from .backup_schema import BackupFile, ConflictMode
|
||||||
@@ -49,6 +60,23 @@ async def apply_pending_restore_if_any() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
pending_path = _pending_restore_path()
|
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():
|
if not pending_path.exists():
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Pending-restore marker present but file missing at %s — clearing marker",
|
"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
|
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_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
|
||||||
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
|
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:
|
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)
|
backup = BackupFile.model_validate(raw)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.exception("Pending-restore file unreadable")
|
_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)
|
result = await import_backup(session, admin_row.id, backup, conflict_mode)
|
||||||
except Exception as err: # noqa: BLE001
|
except Exception as err: # noqa: BLE001
|
||||||
_LOGGER.exception("Pending-restore apply failed")
|
_LOGGER.exception("Pending-restore apply failed")
|
||||||
await _record_error(session, str(err))
|
# Discard any partial inserts the importer made before raising —
|
||||||
await session.commit()
|
# 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
|
return
|
||||||
|
|
||||||
# Archive the file
|
# Archive the file
|
||||||
@@ -136,6 +203,7 @@ async def _clear_markers(session: AsyncSession) -> None:
|
|||||||
PENDING_RESTORE_CONFLICT_KEY,
|
PENDING_RESTORE_CONFLICT_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_AT_KEY,
|
PENDING_RESTORE_UPLOADED_AT_KEY,
|
||||||
PENDING_RESTORE_UPLOADED_BY_KEY,
|
PENDING_RESTORE_UPLOADED_BY_KEY,
|
||||||
|
PENDING_RESTORE_SHA256_KEY,
|
||||||
):
|
):
|
||||||
row = await session.get(AppSetting, key)
|
row = await session.get(AppSetting, key)
|
||||||
if row:
|
if row:
|
||||||
|
|||||||
@@ -166,30 +166,41 @@ async def _refresh_telegram_chat_titles() -> None:
|
|||||||
|
|
||||||
refreshed = 0
|
refreshed = 0
|
||||||
errors = 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:
|
async with AsyncSession(engine) as session:
|
||||||
for chat_id, info, err in results:
|
if successes:
|
||||||
if err is not None or info is None:
|
rows = (await session.exec(
|
||||||
errors += 1
|
select(TelegramChat).where(TelegramChat.id.in_(list(successes.keys())))
|
||||||
if err:
|
)).all()
|
||||||
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
|
for merged in rows:
|
||||||
continue
|
info = successes.get(merged.id)
|
||||||
merged = await session.get(TelegramChat, chat_id)
|
if not info:
|
||||||
if not merged:
|
continue
|
||||||
continue
|
title = info.get("title") or (
|
||||||
title = info.get("title") or (
|
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
|
||||||
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
|
)
|
||||||
)
|
changed = False
|
||||||
changed = False
|
if title and merged.title != title:
|
||||||
if title and merged.title != title:
|
merged.title = title
|
||||||
merged.title = title
|
changed = True
|
||||||
changed = True
|
new_username = info.get("username")
|
||||||
new_username = info.get("username")
|
if new_username is not None and merged.username != new_username:
|
||||||
if new_username is not None and merged.username != new_username:
|
merged.username = new_username
|
||||||
merged.username = new_username
|
changed = True
|
||||||
changed = True
|
if changed:
|
||||||
if changed:
|
session.add(merged)
|
||||||
session.add(merged)
|
refreshed += 1
|
||||||
refreshed += 1
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors
|
"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,
|
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] = {}
|
albums: dict[str, ImmichAlbumData] = {}
|
||||||
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
||||||
for album_id in collection_ids:
|
for album_id, album, links in zip(collection_ids, album_results, link_results):
|
||||||
album = await immich.client.get_album(album_id)
|
if isinstance(album, Exception) or album is None:
|
||||||
if album:
|
continue
|
||||||
albums[album_id] = album
|
albums[album_id] = album
|
||||||
shared_links[album_id] = await immich.client.get_shared_links(album_id)
|
shared_links[album_id] = links if not isinstance(links, Exception) else []
|
||||||
|
|
||||||
assets, collections_extra = collect_scheduled_assets(
|
assets, collections_extra = collect_scheduled_assets(
|
||||||
albums, shared_links, ext_domain,
|
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", "")
|
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] = {}
|
albums: dict[str, ImmichAlbumData] = {}
|
||||||
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
shared_links: dict[str, list[SharedLinkInfo]] = {}
|
||||||
for album_id in collection_ids:
|
for album_id, album, links in zip(collection_ids, album_results, link_results):
|
||||||
album = await immich.client.get_album(album_id)
|
if isinstance(album, Exception) or album is None:
|
||||||
if album:
|
continue
|
||||||
albums[album_id] = album
|
albums[album_id] = album
|
||||||
shared_links[album_id] = await immich.client.get_shared_links(album_id)
|
shared_links[album_id] = links if not isinstance(links, Exception) else []
|
||||||
|
|
||||||
# limit=0 → returns ([], collections_extra) with full per-album stats.
|
# limit=0 → returns ([], collections_extra) with full per-album stats.
|
||||||
_assets, collections_extra = collect_scheduled_assets(
|
_assets, collections_extra = collect_scheduled_assets(
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ from ..database.models import (
|
|||||||
NotificationTrackerState,
|
NotificationTrackerState,
|
||||||
ServiceProvider,
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -85,7 +89,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Load tracker-target links
|
# 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
|
# Snapshot the data we need
|
||||||
provider_type = provider.type
|
provider_type = provider.type
|
||||||
@@ -236,7 +243,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
|
|||||||
for ld in link_data:
|
for ld in link_data:
|
||||||
# Apply per-link event filtering from tracking config
|
# Apply per-link event filtering from tracking config
|
||||||
tc = ld["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")
|
_LOGGER.info(" Skipped by tracking config filter")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ fi
|
|||||||
# Start backend
|
# Start backend
|
||||||
export NOTIFY_BRIDGE_DATA_DIR=./test-data
|
export NOTIFY_BRIDGE_DATA_DIR=./test-data
|
||||||
export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars
|
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 \
|
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
|
||||||
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 &
|
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 &
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user