Compare commits

..

14 Commits

Author SHA1 Message Date
alexei.dolgolyov 93df538819 chore: release v0.2.4
Release / release (push) Successful in 1m18s
2026-04-22 15:36:08 +03:00
alexei.dolgolyov 2be608ba95 feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
  and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
  (Immich populates thumbhash in extra) and passes it to TelegramClient, so
  asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
  asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
  when cache params change.

Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.

Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
  explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).

Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
  every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
  so content can't visually overlay the bottom bar.

A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
  association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.

Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
  runtime (async generator not an iterator); replaced with explicit loop.
2026-04-22 15:09:59 +03:00
alexei.dolgolyov 5028f15f4f chore: release v0.2.3
Release / release (push) Successful in 1m17s
2026-04-22 03:30:45 +03:00
alexei.dolgolyov 5a232f18b8 feat(commands): drop tracker counts from /status
trackers_active / trackers_total are per-provider aggregates — once the
rest of /status is scoped to the chat's album set (total_albums and
last_event both filtered by the derived scope), leaving tracker counts
in would leak info about trackers this chat has no visibility into.

- _cmd_status no longer emits trackers_active / trackers_total.
- Immich default status templates (en, ru) just show Albums + Last event.
- Variable catalog updated so the template editor stops suggesting the
  removed vars for the Immich /status slot.
2026-04-22 03:28:05 +03:00
alexei.dolgolyov 3b76a09759 feat(commands): per-chat album scope derived from notification routing
The "per-chat album scope" feature stored on CommandTrackerListener was
really per-bot: listener_id = bot.id, and every chat that bot served
shared the same scope.  Commands like /albums, /random, /status,
/events leaked the full provider catalog into chats that were never
wired up to receive notifications from those trackers.

New model: the album scope for /commands in a given chat is derived
from the notification-routing graph.  For a (provider, bot, chat_id)
triple we walk TargetReceiver (chat_id match, enabled) →
NotificationTarget (telegram or broadcast parent) →
NotificationTrackerTarget → NotificationTracker (provider match) and
union their collection_ids.  That's the natural "what does this chat
get notifications about" set, and it becomes the command scope.

- New helper: command_utils.resolve_chat_album_scope(provider_id,
  bot_id, chat_id) -> set[str].  Empty set is the default for chats
  with no routing — commands return nothing rather than leaking the
  provider's catalog.
- Dispatcher computes the scope per (tracker, bot, chat) and threads
  it through handler.handle(..., allowed_album_ids=...).  Explicit
  CommandTrackerListener.allowed_album_ids override, when set, still
  wins verbatim (kept as an escape hatch for users who want a divergent
  scope for a whole bot).
- /status, /albums, /events, and all /_cmd_immich-routed commands
  (/random, /search, /find, /latest, /memory, /summary, /favorites,
  /place, /person) now intersect with the resolved scope.
- UI scope modal relabeled: it's an explicit *override for this bot*,
  not a per-chat setting.  Default is "derive from notification
  routing", which matches what users already configured elsewhere.

Also:
- /search, /find, /person, /place — _enrich_assets return value was
  discarded, dropping public_url enrichment.  Assign the return value.
- search_smart / search_metadata — consolidated into _search_items
  helper that logs non-200 responses and transport errors instead of
  silently returning [].  Makes "always no results" bugs actually
  diagnosable.  Also accepts the alternate {"assets": [...]} flat-list
  shape from older Immich versions.
- Immich search error bodies go through _redact_body so credentials
  echoed by authenticating proxies don't land in server logs.
2026-04-22 03:20:51 +03:00
alexei.dolgolyov 4ff3876e49 fix(commands): /albums honors per-chat scope, disable link previews
- /albums ignored CommandTrackerListener.allowed_album_ids and listed
  every album tracked by the provider — scoped chats saw neighbours'
  albums.  Thread the listener through _cmd_albums and apply the same
  intersect filter the media commands already use in _cmd_immich.
- Command text replies are listings (albums, events, people, ...) that
  embed multiple links; Telegram's default behavior of rendering a
  preview for the first URL is never useful here and ignored the
  "Disable link previews" toggle operators set on their target.  Always
  pass disable_web_page_preview=True from send_reply.
2026-04-22 03:03:09 +03:00
alexei.dolgolyov 83215473c7 chore: release v0.2.2
Release / release (push) Successful in 1m0s
2026-04-22 02:51:10 +03:00
alexei.dolgolyov 4e23d2b054 chore(compose): hardcode NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 in compose
This project ships for homelab use; downstream targets (Immich, Gitea,
...) sit on RFC1918 addresses which the SSRF guard blocks by default.
Setting the flag directly in compose — not via ${...} substitution —
avoids the Portainer gotcha where the stack-level "Environment variables"
panel is for compose-file substitutions only, not runtime container env.
Operators who want to run this on a public-facing box can drop the line.
2026-04-22 02:49:19 +03:00
alexei.dolgolyov f7d51b27d2 Revert "chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab"
This reverts commit 3bb0585e43.
2026-04-22 02:47:09 +03:00
alexei.dolgolyov 3bb0585e43 chore(compose): default NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 for homelab
Homelab targets (Immich, Gitea, ...) are almost always on RFC1918
addresses, which the SSRF guard rejects by default.  Exporting the flag
to 1 in the compose file — overridable via the host environment —
matches how this project is actually deployed (TrueNAS / unraid / etc.)
without weakening the defense for anyone who sets it to 0 on a
public-facing box.
2026-04-22 02:46:10 +03:00
alexei.dolgolyov 58cba88c92 docs(immich-ssrf): surface NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS hint in error
Homelab/LAN Immich instances trip the SSRF guard (Host 192.168.x resolves
to blocked address).  The fix is to set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
in the runtime env — call that out directly in the error message so
operators don't have to dig through source to find it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:42:22 +03:00
alexei.dolgolyov 645331d320 chore: release v0.2.1
Release / release (push) Successful in 1m27s
2026-04-22 02:35:38 +03:00
alexei.dolgolyov 6c3dd67c1b feat(tracking): per-config quiet hours with app-level IANA timezone
Add quiet_hours_enabled/start/end to TrackingConfig (HH:MM strings
interpreted in the app-level timezone AppSetting). The dispatch path
loads the app timezone once per run and passes it through
event_allowed_by_config -> in_quiet_hours, so overnight windows like
22:00-07:00 work correctly in any IANA tz.

Frontend exposes a Timezone field under Settings and a Quiet Hours
section on the Immich tracking-config form with time-picker inputs.
2026-04-22 02:31:48 +03:00
alexei.dolgolyov 56993d2ca3 fix(security,perf): harden restore, CSRF, token_version + perf pass
Security
- Sign pending_restore.json (SHA256 stored in AppSetting, verified on
  startup apply) + refuse path outside data_dir, tighten to 0600.
- Require same-origin Origin/Referer on POST /api/backup/apply-restart —
  Bearer-in-localStorage is CSRF-reachable from any XSS'd admin tab.
- Bump token_version on role/username change and admin password reset so
  demoted admins lose admin in already-issued JWTs.  Guard last-admin
  TOCTOU via COUNT + post-commit re-check that rolls back a race.
- SSRF guard (validate_outbound_url) in ImmichClient.__init__ and the
  external_domain setter — admin-mutable URLs were bypassing the check
  that webhook/slack/discord paths already used.  Dev restart script now
  sets NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1 so homelab Immich still works.
- Redact + cap Immich error bodies to ~120 chars before they flow into
  ActionExecution.error / EventLog.details (both UI-visible).
- Deny-list sensitive keys (api_key / token / secret / password /
  authorization / cookie / ...) in template-context merges so a rogue
  template can't exfiltrate provider creds via {{ api_key }}.
- Cap user-controlled Immich search params (query ≤256, person_ids ≤50,
  size ≤100) so a Telegram listener can't DoS upstream.
- Stream upload reads with running byte counter + content-length precheck
  instead of buffering the full body and then rejecting.
- Log Telegram parse_mode fallbacks instead of swallowing silently;
  template escape bugs now surface in server logs.
- Rollback partial imports on pending-restore failure (error recorded on
  a fresh session).

Performance
- Fix N+1 in _refresh_telegram_chat_titles: single IN query instead of
  session.get per chat.
- Parallelize album + shared-link fetches in test_dispatch (asyncio.gather)
  and per-receiver Telegram test sends in notifier (semaphore 5).
- Early-exit collect_scheduled_assets(limit=0) so the periodic-summary
  test path skips full per-album filter/sample (was O(album_assets)).
- Emit explicit CREATE INDEX IF NOT EXISTS for event_log user_id /
  action_id / provider_id so the first boot after upgrade isn't left
  unindexed for the dashboard query.
- Add AbortController timeout (120s) to fetchAuth so uploads/downloads
  don't hang indefinitely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 02:28:55 +03:00
48 changed files with 2780 additions and 324 deletions
+20 -60
View File
@@ -1,82 +1,42 @@
## v0.2.0 (2026-04-22)
## v0.2.4 (2026-04-22)
First feature release since the initial `v0.1.0` cut: a broad polish pass across
the backend, frontend, and schema, plus two fixes landed on top.
Telegram media cache rebuilt around **thumbhash validation** — asset cache
entries now invalidate when the visual content changes, not after a fixed
TTL — plus a settings-page overhaul (cache stats, clear button, timezone /
locale pickers) and full mobile-nav parity with the desktop sidebar.
### Features
#### Immich commands & tracking
#### Telegram media cache
- 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))
- **Thumbhash-validated asset cache** — dispatcher builds an `asset.id → thumbhash` resolver from `event.added_assets` (Immich already populates `thumbhash` in `extra`) and passes it to `TelegramClient`. Asset-cache entries now invalidate on visual change rather than age. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
- **Configurable cache cap & stats** — `TelegramFileCache` gets a `max_entries` LRU cap (applies in both TTL and thumbhash modes), `ttl_seconds <= 0` disables TTL entirely, and a `stats()` method exposes per-bucket counts / sizes / oldest+newest timestamps. New settings: `telegram_asset_cache_max_entries` (default 5000); `telegram_cache_ttl_hours` default bumped `48 → 720` (30 days) and is now URL-only. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
- **Cache admin endpoints** — `GET /settings/telegram-cache/stats` and `POST /settings/telegram-cache/clear`. `PUT /settings` now soft-resets the in-memory caches when cache-shaping keys change (on-disk `file_id`s preserved). ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
#### Dashboard & status
#### Settings page
- 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))
- **Cache stats card** — per-bucket (URL / asset) counts, cumulative uploaded-to-Telegram byte size, oldest/newest timestamps, and a hint explaining what the size means. Clear-cache button behind a confirm modal. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
- **New `TimezoneSelector` and `LocaleSelector` components** replace the raw inputs with IANA-aware searchable pickers. Max-entries input exposed; TTL range widened to `0..8760` hours (`0` = disabled). ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
#### Backup & restore
#### Mobile nav
- 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))
- **Full sidebar parity in the *More* panel** — now mirrors the desktop sidebar tree (groups + subnodes) so every destination is reachable from mobile. Previously the panel carried a hand-picked flat list that drifted behind newly-added routes. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
- **Safe-area handling** — nav height uses `env(safe-area-inset-bottom)`; panel bottom + `z-index` fixed so page content can no longer visually overlay the bottom bar. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
### Bug Fixes
- Telegram target list: load chats/listeners **before** expanding so the slide animation computes the right height ([cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d))
- Test dispatch falls back to tracker defaults for tracking/template config (matching `load_link_data`), distinguishes "no template config linked" vs. "slot missing in linked config", and the frontend `testTrackerTarget` now treats `{success:false,error:...}` in a 2xx body as a failure instead of flashing a success snack ([80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d))
- Immich person-asset lookup switched from the removed `GET /api/people/{id}/assets` to `POST /api/search/metadata` with `personIds` — fixes `/person` and auto-organize rules silently returning zero candidates on Immich 1.106+ ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `add_assets_to_album` now surfaces the Immich error body on non-2xx responses ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Immich tracker "Checking links" parallelised (concurrency cap 6) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- **`update_settings` TypeError** — `any(await ... for ...)` was an async generator (not an iterator) and raised at runtime; replaced with an explicit loop so settings updates actually commit. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
---
### Accessibility
### Development / Internal
#### Schema / Migrations
- `event_log`: add `user_id`, `action_id`, `action_name` (+ defensive migration and backfill of `user_id` from `notification_tracker`) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `command_tracker_listener`: add `allowed_album_ids` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Refactors & Internals
- Bounded concurrency (semaphores) in `NotificationDispatcher._preload_asset_data` and `_refresh_telegram_chat_titles`; chat-title sweep extended to 24h since `save_chat_from_webhook` covers active chats opportunistically ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- Periodic-summary test path reuses the shared `collect_scheduled_assets` primitive (`limit=0`) so test and production go through one path ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- New `fetchAuth` helper for multipart/binary calls (reuses `api()`'s refresh + `ApiError` mapping) ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- `parseDate` helper for consistent UTC date rendering ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
#### Seeds
- Rename "Default Commands" → "Default Immich Commands"; `track_assets_removed` now defaults to `False` ([a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e))
- **Password-manager association** on the password-change form — hidden `username` field + `autocomplete` hints on all three password inputs so browsers stop warning and password managers fill correctly. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
- **Telegram webhook secret** wrapped in a no-op form with `autocomplete=off` to silence DOM/a11y warnings. ([2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b))
---
<details>
<summary>All Commits</summary>
| Hash | Message | Author |
|------|---------|--------|
| [cf4976d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/cf4976d) | fix(telegram): load chats/listeners before expanding to fix slide animation height | alexei.dolgolyov |
| [80c034d](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/80c034d) | fix(test-dispatch): fall back to tracker defaults, surface soft errors | alexei.dolgolyov |
| [a7a2b4e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/a7a2b4e) | feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events | alexei.dolgolyov |
- [2be608b](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/2be608b) — feat(cache): thumbhash-validated asset cache + settings UX overhaul *(alexei.dolgolyov)*
</details>
+4
View File
@@ -12,6 +12,10 @@ services:
environment:
- NOTIFY_BRIDGE_SECRET_KEY=${NOTIFY_BRIDGE_SECRET_KEY:?Set NOTIFY_BRIDGE_SECRET_KEY (min 32 chars)}
- NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS=${NOTIFY_BRIDGE_CORS_ALLOWED_ORIGINS:-*}
# Homelab target: allow outbound requests to RFC1918 / link-local addresses.
# The SSRF guard otherwise rejects 10.*/172.16.*/192.168.*/169.254.* hosts,
# which breaks tracking of Immich / Gitea / etc. running on the same LAN.
- NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8420/api/health')"]
interval: 30s
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "notify-bridge-frontend",
"private": true,
"version": "0.2.0",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite dev",
+46 -27
View File
@@ -94,6 +94,9 @@ async function doRefreshAccessToken(): Promise<boolean> {
}
const DEFAULT_TIMEOUT_MS = 30_000;
// Longer cap for fetchAuth — it's used for multipart uploads (backup restore)
// and binary downloads where a 30s limit can cut off a legit slow upload.
const DEFAULT_FETCHAUTH_TIMEOUT_MS = 120_000;
export async function api<T = any>(
path: string,
@@ -170,42 +173,58 @@ export async function api<T = any>(
*/
export async function fetchAuth(
path: string,
options: RequestInit = {},
options: RequestInit & { timeoutMs?: number } = {},
): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let res = await fetch(url, { ...options, headers });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...options, headers });
// Abort after timeout so uploads/downloads don't hang indefinitely if
// the backend stops responding. Callers can override per-request via
// options.timeoutMs or pass their own signal to opt out.
const { timeoutMs, ...fetchOptions } = options;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
timeoutMs ?? DEFAULT_FETCHAUTH_TIMEOUT_MS,
);
const signal = options.signal ?? controller.signal;
try {
let res = await fetch(url, { ...fetchOptions, headers, signal });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...fetchOptions, headers, signal });
}
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,764 @@
<script lang="ts">
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
interface LocaleMeta {
code: string;
name: string; // English name
native: string; // Native script
rtl?: boolean;
}
const CATALOG: LocaleMeta[] = [
{ code: 'en', name: 'English', native: 'English' },
{ code: 'ru', name: 'Russian', native: 'Русский' },
{ code: 'de', name: 'German', native: 'Deutsch' },
{ code: 'fr', name: 'French', native: 'Français' },
{ code: 'es', name: 'Spanish', native: 'Español' },
{ code: 'it', name: 'Italian', native: 'Italiano' },
{ code: 'pt', name: 'Portuguese', native: 'Português' },
{ code: 'pl', name: 'Polish', native: 'Polski' },
{ code: 'nl', name: 'Dutch', native: 'Nederlands' },
{ code: 'sv', name: 'Swedish', native: 'Svenska' },
{ code: 'fi', name: 'Finnish', native: 'Suomi' },
{ code: 'no', name: 'Norwegian', native: 'Norsk' },
{ code: 'da', name: 'Danish', native: 'Dansk' },
{ code: 'cs', name: 'Czech', native: 'Čeština' },
{ code: 'hu', name: 'Hungarian', native: 'Magyar' },
{ code: 'ro', name: 'Romanian', native: 'Română' },
{ code: 'el', name: 'Greek', native: 'Ελληνικά' },
{ code: 'tr', name: 'Turkish', native: 'Türkçe' },
{ code: 'uk', name: 'Ukrainian', native: 'Українська' },
{ code: 'be', name: 'Belarusian', native: 'Беларуская' },
{ code: 'bg', name: 'Bulgarian', native: 'Български' },
{ code: 'sr', name: 'Serbian', native: 'Српски' },
{ code: 'ar', name: 'Arabic', native: 'العربية', rtl: true },
{ code: 'he', name: 'Hebrew', native: 'עברית', rtl: true },
{ code: 'fa', name: 'Persian', native: 'فارسی', rtl: true },
{ code: 'zh', name: 'Chinese', native: '中文' },
{ code: 'ja', name: 'Japanese', native: '日本語' },
{ code: 'ko', name: 'Korean', native: '한국어' },
{ code: 'hi', name: 'Hindi', native: 'हिन्दी' },
{ code: 'vi', name: 'Vietnamese', native: 'Tiếng Việt' },
{ code: 'th', name: 'Thai', native: 'ไทย' },
{ code: 'id', name: 'Indonesian', native: 'Bahasa Indonesia' },
];
// Locales that ship with default notification & command templates.
const SHIPPED = new Set(['en', 'ru']);
let {
value = $bindable<string>(''),
}: {
value: string;
} = $props();
// Parse the comma-separated backend string into an ordered array of codes.
const codes = $derived.by<string[]>(() => {
if (!value) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const raw of value.split(',')) {
const c = raw.trim().toLowerCase();
if (!c || seen.has(c)) continue;
seen.add(c);
out.push(c);
}
return out;
});
function commit(next: string[]) {
// De-dupe (preserve order) and serialise back to the backend format.
const seen = new Set<string>();
const clean = next.map(c => c.trim().toLowerCase())
.filter(c => c && !seen.has(c) && (seen.add(c), true));
value = clean.join(',');
}
function meta(code: string): LocaleMeta {
return CATALOG.find(l => l.code === code) ?? {
code,
name: code.toUpperCase(),
native: code.toUpperCase(),
};
}
function remove(code: string) {
commit(codes.filter(c => c !== code));
}
function makePrimary(code: string) {
commit([code, ...codes.filter(c => c !== code)]);
}
function moveUp(code: string) {
const i = codes.indexOf(code);
if (i <= 0) return;
const next = [...codes];
[next[i - 1], next[i]] = [next[i], next[i - 1]];
commit(next);
}
function moveDown(code: string) {
const i = codes.indexOf(code);
if (i < 0 || i >= codes.length - 1) return;
const next = [...codes];
[next[i], next[i + 1]] = [next[i + 1], next[i]];
commit(next);
}
// --- Add flow ----------------------------------------------------------
let addOpen = $state(false);
let addQuery = $state('');
let addInputEl = $state<HTMLInputElement | null>(null);
let highlightIdx = $state(0);
// Valid BCP 47-ish: 23 letter primary, optional '-' subtag(s) 2-8 chars.
const CUSTOM_RE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
const selectedSet = $derived(new Set(codes));
const suggestions = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
const available = CATALOG.filter(l => !selectedSet.has(l.code));
if (!q) return available;
return available.filter(l =>
l.code.includes(q)
|| l.name.toLowerCase().includes(q)
|| l.native.toLowerCase().includes(q),
);
});
const canAddCustom = $derived.by(() => {
const q = addQuery.trim().toLowerCase();
if (!q) return false;
if (!CUSTOM_RE.test(q)) return false;
if (selectedSet.has(q)) return false;
// Skip "custom" entry when it matches an existing catalog entry exactly.
if (CATALOG.some(l => l.code === q)) return false;
return true;
});
function openAdd() {
addOpen = true;
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function closeAdd() {
addOpen = false;
addQuery = '';
}
function addCode(code: string) {
const c = code.trim().toLowerCase();
if (!c) return;
commit([...codes, c]);
addQuery = '';
highlightIdx = 0;
requestAnimationFrame(() => addInputEl?.focus());
}
function onAddKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closeAdd(); return; }
const total = suggestions.length + (canAddCustom ? 1 : 0);
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, Math.max(0, total - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightIdx < suggestions.length) {
addCode(suggestions[highlightIdx].code);
} else if (canAddCustom) {
addCode(addQuery);
}
}
}
$effect(() => { addQuery; highlightIdx = 0; });
// --- Drag & drop -------------------------------------------------------
let dragCode = $state<string | null>(null);
let dragOverCode = $state<string | null>(null);
function onDragStart(e: DragEvent, code: string) {
dragCode = code;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', code);
}
}
function onDragOver(e: DragEvent, code: string) {
if (!dragCode || dragCode === code) return;
e.preventDefault();
dragOverCode = code;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
}
function onDrop(e: DragEvent, code: string) {
e.preventDefault();
if (!dragCode || dragCode === code) return;
const from = codes.indexOf(dragCode);
const to = codes.indexOf(code);
if (from < 0 || to < 0) return;
const next = [...codes];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
commit(next);
dragCode = null;
dragOverCode = null;
}
function onDragEnd() {
dragCode = null;
dragOverCode = null;
}
</script>
<div class="ls-root">
{#if codes.length === 0}
<div class="ls-empty">
<div class="ls-empty-glyph" aria-hidden="true">A あ Я</div>
<p class="ls-empty-text">{t('locales.empty')}</p>
</div>
{:else}
<ul class="ls-list" role="list">
{#each codes as code, i (code)}
{@const m = meta(code)}
{@const isPrimary = i === 0}
{@const isShipped = SHIPPED.has(code)}
<li
class="ls-row"
class:ls-row-primary={isPrimary}
class:ls-row-dragover={dragOverCode === code}
class:ls-row-dragging={dragCode === code}
draggable="true"
ondragstart={(e) => onDragStart(e, code)}
ondragover={(e) => onDragOver(e, code)}
ondrop={(e) => onDrop(e, code)}
ondragend={onDragEnd}
>
<span class="ls-rail" aria-hidden="true"></span>
<button
type="button"
class="ls-handle"
aria-label={t('locales.reorder')}
title={t('locales.reorder')}
tabindex="-1"
>
<MdiIcon name="mdiDragVertical" size={16} />
</button>
<div class="ls-text">
<div class="ls-native" dir={m.rtl ? 'rtl' : 'ltr'} lang={code}>{m.native}</div>
<div class="ls-meta">
<span class="ls-name">{m.name}</span>
<span class="ls-dot" aria-hidden="true">·</span>
<span class="ls-code">{code}</span>
</div>
</div>
<div class="ls-badges">
{#if isPrimary}
<span class="ls-tag ls-tag-primary">
<MdiIcon name="mdiStar" size={10} />
{t('locales.primary')}
</span>
{/if}
{#if isShipped}
<span class="ls-tag ls-tag-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
{t('locales.shipped')}
</span>
{/if}
</div>
<div class="ls-actions">
{#if !isPrimary}
<button
type="button"
class="ls-icon-btn"
onclick={() => makePrimary(code)}
aria-label={t('locales.makePrimary')}
title={t('locales.makePrimary')}
>
<MdiIcon name="mdiStarOutline" size={14} />
</button>
{/if}
<button
type="button"
class="ls-icon-btn"
onclick={() => moveUp(code)}
disabled={i === 0}
aria-label={t('locales.moveUp')}
title={t('locales.moveUp')}
>
<MdiIcon name="mdiChevronUp" size={14} />
</button>
<button
type="button"
class="ls-icon-btn"
onclick={() => moveDown(code)}
disabled={i === codes.length - 1}
aria-label={t('locales.moveDown')}
title={t('locales.moveDown')}
>
<MdiIcon name="mdiChevronDown" size={14} />
</button>
<button
type="button"
class="ls-icon-btn ls-icon-danger"
onclick={() => remove(code)}
disabled={codes.length <= 1}
aria-label={t('locales.remove')}
title={codes.length <= 1 ? t('locales.removeLast') : t('locales.remove')}
>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- Add zone -->
<div class="ls-add" class:ls-add-open={addOpen}>
{#if !addOpen}
<button type="button" class="ls-add-trigger" onclick={openAdd}>
<MdiIcon name="mdiPlus" size={14} />
<span>{t('locales.add')}</span>
</button>
{:else}
<div class="ls-add-panel">
<div class="ls-add-input-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={addInputEl}
bind:value={addQuery}
onkeydown={onAddKeydown}
onblur={() => setTimeout(() => { if (addOpen && !addQuery) closeAdd(); }, 150)}
placeholder={t('locales.searchPlaceholder')}
class="ls-add-input"
autocomplete="off"
spellcheck="false"
type="text"
/>
<button type="button" class="ls-icon-btn" onclick={closeAdd} aria-label={t('common.cancel')}>
<MdiIcon name="mdiClose" size={14} />
</button>
</div>
<div class="ls-add-list" role="listbox">
{#each suggestions as s, i (s.code)}
<button
type="button"
role="option"
aria-selected={i === highlightIdx}
class="ls-sugg"
class:ls-sugg-hl={i === highlightIdx}
onmouseenter={() => highlightIdx = i}
onmousedown={(e) => { e.preventDefault(); addCode(s.code); }}
>
<span class="ls-sugg-native" dir={s.rtl ? 'rtl' : 'ltr'} lang={s.code}>{s.native}</span>
<span class="ls-sugg-name">{s.name}</span>
<span class="ls-sugg-code">{s.code}</span>
{#if SHIPPED.has(s.code)}
<span class="ls-sugg-shipped" title={t('locales.shippedHint')}>
<MdiIcon name="mdiPackageVariantClosedCheck" size={10} />
</span>
{/if}
</button>
{/each}
{#if canAddCustom}
<button
type="button"
role="option"
aria-selected={highlightIdx === suggestions.length}
class="ls-sugg ls-sugg-custom"
class:ls-sugg-hl={highlightIdx === suggestions.length}
onmouseenter={() => highlightIdx = suggestions.length}
onmousedown={(e) => { e.preventDefault(); addCode(addQuery); }}
>
<MdiIcon name="mdiPlusCircleOutline" size={14} />
<span class="ls-sugg-custom-label">{t('locales.addCustom')}</span>
<span class="ls-sugg-code">{addQuery.trim().toLowerCase()}</span>
</button>
{/if}
{#if suggestions.length === 0 && !canAddCustom}
<div class="ls-sugg-empty">{t('locales.noSuggestions')}</div>
{/if}
</div>
</div>
{/if}
</div>
<p class="ls-hint">
<MdiIcon name="mdiInformationOutline" size={12} />
<span>{t('locales.orderHint')}</span>
</p>
</div>
<style>
.ls-root {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 34rem;
}
/* ---- Empty state -------------------------------------------------- */
.ls-empty {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 1rem 1.125rem;
border: 1px dashed var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 4%, transparent) 0%,
transparent 60%),
var(--color-background);
}
.ls-empty-glyph {
font-family: var(--font-sans);
font-size: 1.5rem;
letter-spacing: 0.1em;
font-weight: 300;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
flex-shrink: 0;
line-height: 1;
}
.ls-empty-text {
margin: 0;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
/* ---- List --------------------------------------------------------- */
.ls-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.ls-row {
position: relative;
display: grid;
grid-template-columns: auto 1fr auto auto;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem 0.625rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
transition: border-color 0.15s, background 0.15s, transform 0.15s;
overflow: hidden;
}
.ls-row:hover {
border-color: color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
}
.ls-row.ls-row-dragging {
opacity: 0.4;
}
.ls-row.ls-row-dragover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-background));
}
.ls-row.ls-row-primary {
background:
linear-gradient(90deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 30%),
var(--color-background);
}
/* Accent rail — pronounced on primary, near-invisible otherwise */
.ls-rail {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 3px;
background: transparent;
transition: background 0.15s;
}
.ls-row.ls-row-primary .ls-rail {
background: var(--color-primary);
}
.ls-handle {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.125rem;
border: none;
background: transparent;
color: var(--color-muted-foreground);
opacity: 0.4;
cursor: grab;
transition: opacity 0.15s;
}
.ls-row:hover .ls-handle {
opacity: 0.9;
}
.ls-handle:active {
cursor: grabbing;
}
.ls-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.ls-native {
font-family: var(--font-sans);
font-size: 1.125rem;
font-weight: 500;
line-height: 1.2;
letter-spacing: -0.005em;
color: var(--color-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.ls-name {
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 500;
font-size: 0.625rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-dot {
opacity: 0.5;
}
.ls-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.ls-badges {
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.ls-tag {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
white-space: nowrap;
}
.ls-tag-primary {
background: var(--color-primary);
color: var(--color-primary-foreground, #fff);
}
.ls-tag-shipped {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
.ls-actions {
display: flex;
align-items: center;
gap: 0.0625rem;
}
.ls-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
padding: 0;
border: none;
background: transparent;
border-radius: 0.25rem;
color: var(--color-muted-foreground);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.ls-icon-btn:hover:not(:disabled) {
background: var(--color-muted);
color: var(--color-foreground);
}
.ls-icon-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.ls-icon-btn.ls-icon-danger:hover:not(:disabled) {
background: color-mix(in srgb, #ef4444 14%, transparent);
color: #ef4444;
}
/* ---- Add zone ----------------------------------------------------- */
.ls-add {
margin-top: 0.125rem;
}
.ls-add-trigger {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
border: 1px dashed var(--color-border);
border-radius: 0.375rem;
background: transparent;
color: var(--color-muted-foreground);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ls-add-trigger:hover {
border-color: var(--color-primary);
border-style: solid;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, transparent);
}
.ls-add-panel {
border: 1px solid var(--color-border);
border-radius: 0.5rem;
background: var(--color-background);
overflow: hidden;
animation: ls-pop 0.15s ease-out;
}
@keyframes ls-pop {
from { opacity: 0; transform: translateY(-2px); }
to { opacity: 1; transform: translateY(0); }
}
.ls-add-input-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
}
.ls-add-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 0.8rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.ls-add-list {
max-height: 14rem;
overflow-y: auto;
scrollbar-width: thin;
}
.ls-sugg {
display: grid;
grid-template-columns: 1fr auto auto auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ls-sugg.ls-sugg-hl {
background: var(--color-muted);
}
.ls-sugg-native {
font-size: 0.9rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ls-sugg-name {
font-size: 0.7rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
}
.ls-sugg-code {
font-family: var(--font-mono);
font-size: 0.7rem;
padding: 0.05rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.ls-sugg.ls-sugg-hl .ls-sugg-code {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
.ls-sugg-shipped {
display: inline-flex;
align-items: center;
color: var(--color-primary);
opacity: 0.85;
}
.ls-sugg-custom {
border-top: 1px dashed var(--color-border);
color: var(--color-primary);
}
.ls-sugg-custom-label {
font-size: 0.75rem;
font-weight: 500;
}
.ls-sugg-empty {
padding: 0.75rem;
font-size: 0.75rem;
text-align: center;
color: var(--color-muted-foreground);
}
/* ---- Hint --------------------------------------------------------- */
.ls-hint {
display: flex;
align-items: flex-start;
gap: 0.3rem;
margin: 0.125rem 0 0;
font-size: 0.7rem;
color: var(--color-muted-foreground);
line-height: 1.4;
}
</style>
@@ -0,0 +1,585 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
let {
value = $bindable<string>('UTC'),
}: {
value: string;
} = $props();
// --- Catalog -----------------------------------------------------------
const timezones = $derived.by<string[]>(() => {
try {
const intl = Intl as unknown as { supportedValuesOf?: (k: string) => string[] };
if (typeof intl.supportedValuesOf === 'function') {
return intl.supportedValuesOf('timeZone');
}
} catch { /* fall through */ }
return ['UTC'];
});
const detectedTz = (() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
catch { return 'UTC'; }
})();
// --- Live clock --------------------------------------------------------
let now = $state(new Date());
let tickHandle: ReturnType<typeof setInterval> | null = null;
onMount(() => {
tickHandle = setInterval(() => { now = new Date(); }, 1000);
});
onDestroy(() => { if (tickHandle) clearInterval(tickHandle); });
function splitTz(tz: string): { region: string; city: string } {
if (!tz || tz === 'UTC' || tz === 'Etc/UTC') return { region: 'UTC', city: 'UTC' };
const parts = tz.split('/');
if (parts.length === 1) return { region: 'Other', city: parts[0].replace(/_/g, ' ') };
const city = parts[parts.length - 1].replace(/_/g, ' ');
const region = parts.slice(0, -1).join(' / ').replace(/_/g, ' ');
return { region, city };
}
function fmtTime(tz: string): string {
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
}).format(now);
} catch { return '--:--:--'; }
}
function fmtDate(tz: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: tz,
weekday: 'short',
day: 'numeric',
month: 'short',
}).format(now);
} catch { return ''; }
}
function fmtOffset(tz: string): string {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz,
timeZoneName: 'shortOffset',
}).formatToParts(now);
const off = parts.find(p => p.type === 'timeZoneName')?.value ?? '';
return off || 'UTC';
} catch { return ''; }
}
// --- Selected state ----------------------------------------------------
const selected = $derived.by(() => {
const s = splitTz(value || 'UTC');
return {
iana: value || 'UTC',
region: s.region,
city: s.city,
time: fmtTime(value || 'UTC'),
date: fmtDate(value || 'UTC'),
offset: fmtOffset(value || 'UTC'),
};
});
// --- Picker ------------------------------------------------------------
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | null>(null);
let panelEl = $state<HTMLDivElement | null>(null);
const filtered = $derived.by(() => {
const q = query.trim().toLowerCase().replace(/\s+/g, '_');
if (!q) return timezones;
return timezones.filter(tz => tz.toLowerCase().includes(q));
});
// Group filtered tz list by region prefix for visual hierarchy.
interface Group { region: string; items: string[] }
const groups = $derived.by<Group[]>(() => {
const map = new Map<string, string[]>();
for (const tz of filtered) {
const region = tz.includes('/') ? tz.split('/')[0] : 'Other';
if (!map.has(region)) map.set(region, []);
map.get(region)!.push(tz);
}
const REGION_ORDER = ['UTC', 'Europe', 'America', 'Asia', 'Africa', 'Australia', 'Pacific', 'Atlantic', 'Indian', 'Antarctica', 'Arctic', 'Etc', 'Other'];
return [...map.entries()]
.sort(([a], [b]) => {
const ai = REGION_ORDER.indexOf(a);
const bi = REGION_ORDER.indexOf(b);
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
})
.map(([region, items]) => ({ region, items }));
});
// Flattened index for keyboard navigation.
const flat = $derived<string[]>(groups.flatMap(g => g.items));
function openPicker() {
open = true;
query = '';
highlightIdx = Math.max(0, flat.indexOf(value));
requestAnimationFrame(() => {
inputEl?.focus();
scrollToHighlight();
});
}
function closePicker() {
open = false;
query = '';
}
function selectTz(tz: string) {
value = tz;
closePicker();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') { closePicker(); return; }
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, flat.length - 1);
scrollToHighlight();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollToHighlight();
} else if (e.key === 'Enter') {
e.preventDefault();
if (flat[highlightIdx]) selectTz(flat[highlightIdx]);
}
}
function scrollToHighlight() {
requestAnimationFrame(() => {
panelEl?.querySelector('.tz-opt-hl')?.scrollIntoView({ block: 'nearest' });
});
}
$effect(() => { query; highlightIdx = 0; });
// Close on outside click
function onDocClick(e: MouseEvent) {
if (!open) return;
const target = e.target as Node;
if (panelEl && !panelEl.contains(target)) closePicker();
}
onMount(() => {
document.addEventListener('mousedown', onDocClick);
});
onDestroy(() => {
document.removeEventListener('mousedown', onDocClick);
});
</script>
<div class="tz-root">
<!-- Selected card -->
<button
type="button"
class="tz-card"
class:tz-card-open={open}
onclick={() => (open ? closePicker() : openPicker())}
aria-haspopup="listbox"
aria-expanded={open}
>
<div class="tz-card-left">
<div class="tz-region">{selected.region}</div>
<div class="tz-city">{selected.city}</div>
<div class="tz-sub">
<span class="tz-iana">{selected.iana}</span>
{#if selected.date}
<span class="tz-dot">·</span>
<span class="tz-date">{selected.date}</span>
{/if}
</div>
</div>
<div class="tz-card-right">
<div class="tz-clock">{selected.time}</div>
<div class="tz-offset">{selected.offset}</div>
</div>
<span class="tz-chev" aria-hidden="true">
<MdiIcon name={open ? 'mdiChevronUp' : 'mdiChevronDown'} size={16} />
</span>
</button>
{#if open}
<div class="tz-panel" bind:this={panelEl} role="listbox">
<!-- Search -->
<div class="tz-search-row">
<MdiIcon name="mdiMagnify" size={14} />
<input
bind:this={inputEl}
bind:value={query}
onkeydown={onKeydown}
placeholder={t('timezone.searchPlaceholder')}
class="tz-search"
autocomplete="off"
spellcheck="false"
type="text"
/>
<kbd class="tz-kbd">ESC</kbd>
</div>
<!-- Quick picks -->
{#if !query}
<div class="tz-quick">
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === detectedTz}
onclick={() => selectTz(detectedTz)}
>
<MdiIcon name="mdiCrosshairsGps" size={12} />
<span class="tz-quick-label">{t('timezone.detect')}</span>
<span class="tz-quick-val">{detectedTz}</span>
</button>
<button
type="button"
class="tz-quick-btn"
class:tz-quick-active={value === 'UTC' || value === 'Etc/UTC'}
onclick={() => selectTz('UTC')}
>
<MdiIcon name="mdiEarth" size={12} />
<span class="tz-quick-label">{t('timezone.utc')}</span>
<span class="tz-quick-val">UTC+00</span>
</button>
</div>
{/if}
<!-- Grouped list -->
<div class="tz-list">
{#if filtered.length === 0}
<div class="tz-empty">{t('timezone.noMatches')}</div>
{:else}
{#each groups as g (g.region)}
<div class="tz-group">
<div class="tz-group-head">
<span class="tz-group-name">{g.region}</span>
<span class="tz-group-count">{g.items.length}</span>
</div>
{#each g.items as tz (tz)}
{@const parts = splitTz(tz)}
{@const idx = flat.indexOf(tz)}
{@const hl = idx === highlightIdx}
{@const sel = tz === value}
<button
type="button"
role="option"
aria-selected={sel}
class="tz-opt"
class:tz-opt-hl={hl}
class:tz-opt-sel={sel}
onmouseenter={() => (highlightIdx = idx)}
onclick={() => selectTz(tz)}
>
<span class="tz-opt-city">{parts.city}</span>
<span class="tz-opt-iana">{tz}</span>
<span class="tz-opt-offset">{fmtOffset(tz)}</span>
</button>
{/each}
</div>
{/each}
{/if}
</div>
</div>
{/if}
</div>
<style>
.tz-root {
position: relative;
width: 100%;
max-width: 34rem;
}
/* ---- Selected card ------------------------------------------------ */
.tz-card {
position: relative;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: 0.875rem;
width: 100%;
padding: 0.75rem 1rem 0.75rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.625rem;
background:
linear-gradient(135deg,
color-mix(in srgb, var(--color-primary) 5%, transparent) 0%,
transparent 55%),
var(--color-background);
color: var(--color-foreground);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.tz-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 45%, var(--color-border));
}
.tz-card.tz-card-open {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.tz-card-left {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.tz-region {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
color: color-mix(in srgb, var(--color-primary) 70%, var(--color-muted-foreground));
}
.tz-city {
font-family: var(--font-sans);
font-size: 1.25rem;
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-sub {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.7rem;
color: var(--color-muted-foreground);
min-width: 0;
}
.tz-iana {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-dot { opacity: 0.5; }
.tz-card-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
}
.tz-clock {
font-family: var(--font-mono);
font-size: 1.25rem;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--color-foreground);
line-height: 1;
/* Stable width so seconds ticker doesn't shift layout */
font-variant-numeric: tabular-nums;
}
.tz-offset {
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.1rem 0.375rem;
border-radius: 9999px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.tz-chev {
color: var(--color-muted-foreground);
display: inline-flex;
align-items: center;
}
/* ---- Panel -------------------------------------------------------- */
.tz-panel {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 20;
background: var(--color-card, var(--color-background));
border: 1px solid var(--color-border);
border-radius: 0.625rem;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: 26rem;
animation: tz-pop 0.15s ease-out;
}
@keyframes tz-pop {
from { opacity: 0; transform: translateY(-3px); }
to { opacity: 1; transform: translateY(0); }
}
.tz-search-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
color: var(--color-muted-foreground);
}
.tz-search {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 0.85rem;
color: var(--color-foreground);
padding: 0.125rem 0;
min-width: 0;
}
.tz-kbd {
font-size: 0.55rem;
font-family: var(--font-mono);
padding: 0.1rem 0.3rem;
border-radius: 0.2rem;
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.tz-quick {
display: flex;
gap: 0.375rem;
padding: 0.5rem 0.625rem;
border-bottom: 1px solid var(--color-border);
flex-wrap: wrap;
}
.tz-quick-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 9999px;
background: var(--color-background);
font-size: 0.7rem;
color: var(--color-foreground);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.tz-quick-btn:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
color: var(--color-primary);
}
.tz-quick-active {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-primary);
}
.tz-quick-label {
font-weight: 500;
}
.tz-quick-val {
font-family: var(--font-mono);
font-size: 0.65rem;
opacity: 0.7;
}
.tz-list {
overflow-y: auto;
padding: 0.25rem 0;
scrollbar-width: thin;
}
.tz-empty {
padding: 1rem;
text-align: center;
font-size: 0.8rem;
color: var(--color-muted-foreground);
}
.tz-group {
margin-bottom: 0.125rem;
}
.tz-group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 0.375rem 0.75rem 0.25rem;
font-size: 0.55rem;
text-transform: uppercase;
letter-spacing: 0.12em;
font-weight: 600;
color: var(--color-muted-foreground);
position: sticky;
top: 0;
background: var(--color-card, var(--color-background));
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
z-index: 1;
}
.tz-group-count {
font-family: var(--font-mono);
opacity: 0.6;
}
.tz-opt {
display: grid;
grid-template-columns: 1fr 1fr auto;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.35rem 0.75rem;
border: none;
background: transparent;
color: var(--color-foreground);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.tz-opt.tz-opt-hl {
background: var(--color-muted);
}
.tz-opt.tz-opt-sel {
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.tz-opt-city {
font-size: 0.85rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt.tz-opt-sel .tz-opt-city {
color: var(--color-primary);
}
.tz-opt-iana {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--color-muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tz-opt-offset {
font-family: var(--font-mono);
font-size: 0.65rem;
color: var(--color-muted-foreground);
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
background: var(--color-muted);
white-space: nowrap;
}
.tz-opt.tz-opt-hl .tz-opt-offset {
background: color-mix(in srgb, var(--color-primary) 15%, var(--color-muted));
}
</style>
+52 -7
View File
@@ -523,6 +523,9 @@
"memorySource": "Memory source",
"memorySourceAlbums": "Scan tracked albums",
"memorySourceNative": "Immich native memories",
"quietHours": "Quiet hours",
"quietHoursStart": "Start",
"quietHoursEnd": "End",
"test": "Test",
"confirmDelete": "Delete this tracking config?",
"sortNone": "None",
@@ -668,11 +671,29 @@
"telegram": "Telegram",
"webhookSecret": "Webhook Secret",
"webhookSecretHint": "Secret token to verify webhook requests from Telegram",
"cacheTtl": "Media Cache TTL (hours)",
"cacheTtlHint": "How long to cache uploaded Telegram file_ids before re-uploading",
"cacheTtl": "URL Cache TTL (hours)",
"cacheTtlHint": "How long to keep URL-keyed Telegram file_ids (e.g. shared links). Set 0 to disable TTL. The asset cache uses content hashing (thumbhash) and ignores this.",
"cacheMaxEntries": "Cache Max Entries",
"cacheMaxEntriesHint": "Upper bound per cache (URL and asset). Oldest entries are evicted first (LRU). Default 5000.",
"cacheStats": "Cache contents",
"cacheStatsHint": "Size shown is the total bytes of media originally uploaded to Telegram for cached entries — i.e. approximate re-upload bandwidth the cache is saving. The cache file itself is only a few KB; the media lives on Telegram's servers.",
"cacheStatsUrl": "URL cache",
"cacheStatsAsset": "Asset cache",
"cacheStatsEntries": "entries",
"cacheStatsEmpty": "empty",
"cacheStatsOldest": "oldest",
"cacheStatsNewest": "newest",
"clearCache": "Clear Media Cache",
"clearCacheHint": "Delete cached Telegram file_ids. Next send will re-upload media.",
"clearCacheConfirmTitle": "Clear Telegram cache?",
"clearCacheConfirm": "This removes all cached Telegram file_ids. Subsequent notifications will re-upload media, which may take longer and use more bandwidth.",
"clearCacheConfirmBtn": "Clear cache",
"clearCacheDone": "Telegram cache cleared",
"timezone": "Timezone",
"timezoneHint": "IANA timezone (e.g. UTC, Europe/Warsaw, America/New_York). Used to interpret HH:MM fields like quiet hours.",
"locales": "Template Languages",
"supportedLocales": "Supported Locales",
"supportedLocalesHint": "Comma-separated locale codes for template editing (e.g. en,ru,de,fr)",
"supportedLocalesHint": "Languages available when authoring notification and command templates. Built-in defaults ship for English and Russian; other languages start empty.",
"saved": "Settings saved"
},
"hints": {
@@ -680,6 +701,7 @@
"scheduledAssets": "Sends random or selected photos from tracked albums on a schedule. Like a daily photo pick.",
"memoryMode": "\"On This Day\" — sends photos taken on this date in previous years. Nostalgic flashbacks.",
"memorySource": "Albums: scans tracked albums for date-matching assets. Native: uses Immich's built-in memories (covers entire library, optionally filtered by tracked albums).",
"quietHours": "Suppress all notifications during this HH:MM window (interpreted in the app timezone). Overnight windows like 22:0007:00 are supported.",
"favoritesOnly": "Only include assets marked as favorites.",
"maxAssets": "Maximum number of asset details to include in a single notification message.",
"periodicStartDate": "The reference date for calculating periodic intervals. Summaries are sent every N days from this date.",
@@ -796,17 +818,40 @@
"selectBot": "Select bot...",
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"scopeAll": "derived from notification routing",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"scopeTitle": "Album Scope Override for This Bot",
"scopeDescription": "By default this bot's commands see only the albums that actually deliver notifications to the chats it speaks to (computed from your notification trackers). Set an explicit override here to widen or narrow that set for every chat this bot serves.",
"scopeInherit": "Inherit: derive from notification routing",
"noCollections": "No albums available."
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
},
"timezone": {
"searchPlaceholder": "Search cities or IANA codes…",
"detect": "Detect",
"utc": "UTC",
"noMatches": "No timezones match"
},
"locales": {
"empty": "No languages selected. Add one below to start authoring templates.",
"add": "Add language",
"searchPlaceholder": "Search or type a code (e.g. de-CH)…",
"addCustom": "Add custom code",
"noSuggestions": "No matches. Type a valid locale code (23 letters).",
"primary": "Primary",
"shipped": "Built-in",
"shippedHint": "Default notification & command templates ship for this language.",
"makePrimary": "Make primary",
"moveUp": "Move up",
"moveDown": "Move down",
"remove": "Remove",
"removeLast": "At least one language is required",
"reorder": "Drag to reorder",
"orderHint": "First language is the primary fallback when a translation is missing. Drag to reorder."
},
"snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved",
+52 -7
View File
@@ -523,6 +523,9 @@
"memorySource": "Источник воспоминаний",
"memorySourceAlbums": "Сканировать альбомы",
"memorySourceNative": "Встроенные воспоминания Immich",
"quietHours": "Тихие часы",
"quietHoursStart": "Начало",
"quietHoursEnd": "Конец",
"test": "Тест",
"confirmDelete": "Удалить эту конфигурацию отслеживания?",
"sortNone": "Нет",
@@ -668,11 +671,29 @@
"telegram": "Telegram",
"webhookSecret": "Секрет вебхука",
"webhookSecretHint": "Секретный токен для проверки запросов вебхука от Telegram",
"cacheTtl": "TTL кэша медиа (часы)",
"cacheTtlHint": "Сколько хранить кэш Telegram file_id перед повторной загрузкой",
"cacheTtl": "TTL URL-кэша (часы)",
"cacheTtlHint": "Сколько хранить Telegram file_id, привязанные к URL (напр. публичные ссылки). 0 — отключить TTL. Кэш ассетов использует хэширование содержимого (thumbhash) и не зависит от этой настройки.",
"cacheMaxEntries": "Макс. записей в кэше",
"cacheMaxEntriesHint": "Верхний предел записей в каждом кэше (URL и ассеты). При превышении удаляются самые старые (LRU). По умолчанию 5000.",
"cacheStats": "Содержимое кэша",
"cacheStatsHint": "Показываемый размер — это суммарный объём медиа, который был изначально загружен в Telegram для закэшированных записей, т.е. приблизительный объём повторных загрузок, который экономит кэш. Сам файл кэша занимает лишь несколько КБ; медиа хранится на серверах Telegram.",
"cacheStatsUrl": "Кэш URL",
"cacheStatsAsset": "Кэш ассетов",
"cacheStatsEntries": "записей",
"cacheStatsEmpty": "пусто",
"cacheStatsOldest": "самая старая",
"cacheStatsNewest": "самая свежая",
"clearCache": "Очистить кэш медиа",
"clearCacheHint": "Удалить кэшированные Telegram file_id. При следующей отправке медиа будут загружены заново.",
"clearCacheConfirmTitle": "Очистить кэш Telegram?",
"clearCacheConfirm": "Это удалит все кэшированные Telegram file_id. Следующие уведомления будут повторно загружать медиа, что может занять больше времени и трафика.",
"clearCacheConfirmBtn": "Очистить кэш",
"clearCacheDone": "Кэш Telegram очищен",
"timezone": "Часовой пояс",
"timezoneHint": "Часовой пояс IANA (например UTC, Europe/Warsaw, America/New_York). Используется для интерпретации полей HH:MM, таких как тихие часы.",
"locales": "Языки шаблонов",
"supportedLocales": "Поддерживаемые локали",
"supportedLocalesHint": "Коды локалей через запятую для редактирования шаблонов (например en,ru,de,fr)",
"supportedLocalesHint": "Языки, доступные для редактирования шаблонов уведомлений и команд. Встроенные шаблоны поставляются для английского и русского; другие языки начинают с пустых.",
"saved": "Настройки сохранены"
},
"hints": {
@@ -680,6 +701,7 @@
"scheduledAssets": "Отправляет случайные или выбранные фото из альбомов по расписанию. Как ежедневная подборка фото.",
"memoryMode": "\"В этот день\" — отправляет фото, сделанные в этот день в прошлые годы. Ностальгические воспоминания.",
"memorySource": "Альбомы: сканирует отслеживаемые альбомы по дате. Встроенные: использует воспоминания Immich (вся библиотека, с фильтрацией по альбомам).",
"quietHours": "Подавляет все уведомления в указанном HH:MM окне (по часовому поясу приложения). Поддерживаются окна через полночь, например 22:0007:00.",
"favoritesOnly": "Включать только ассеты, отмеченные как избранные.",
"maxAssets": "Максимальное количество ассетов в одном уведомлении.",
"periodicStartDate": "Опорная дата для расчёта интервалов. Сводки отправляются каждые N дней от этой даты.",
@@ -796,17 +818,40 @@
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"scopeAll": "из маршрутизации уведомлений",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"scopeTitle": "Переопределение области альбомов для этого бота",
"scopeDescription": "По умолчанию команды этого бота видят только альбомы, уведомления которых приходят в его чаты (вычисляется из ваших трекеров уведомлений). Задайте явный список здесь, чтобы расширить или сузить этот набор для всех чатов данного бота.",
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
},
"timezone": {
"searchPlaceholder": "Поиск по городам или IANA-кодам…",
"detect": "Определить",
"utc": "UTC",
"noMatches": "Нет совпадений"
},
"locales": {
"empty": "Языки не выбраны. Добавьте язык ниже, чтобы начать редактирование шаблонов.",
"add": "Добавить язык",
"searchPlaceholder": "Найти или ввести код (например de-CH)…",
"addCustom": "Добавить свой код",
"noSuggestions": "Ничего не найдено. Введите код локали (2–3 буквы).",
"primary": "Основной",
"shipped": "Встроенный",
"shippedHint": "Для этого языка есть встроенные шаблоны уведомлений и команд.",
"makePrimary": "Сделать основным",
"moveUp": "Выше",
"moveDown": "Ниже",
"remove": "Удалить",
"removeLast": "Должен быть хотя бы один язык",
"reorder": "Перетащите для изменения порядка",
"orderHint": "Первый язык используется как основной при отсутствии перевода. Перетаскивайте, чтобы изменить порядок."
},
"snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён",
+8
View File
@@ -88,6 +88,14 @@ export const immichDescriptor: ProviderDescriptor = {
{ key: 'memory_source', label: 'trackingConfig.memorySource', type: 'grid-select', gridItems: 'memorySourceItems', gridColumns: 2, defaultValue: 'albums' },
],
},
{
key: 'quietHours', legend: 'trackingConfig.quietHours', legendHint: 'hints.quietHours',
enabledField: 'quiet_hours_enabled', enabledDefault: false,
fields: [
{ key: 'quiet_hours_start', label: 'trackingConfig.quietHoursStart', type: 'number', defaultValue: '22:00' },
{ key: 'quiet_hours_end', label: 'trackingConfig.quietHoursEnd', type: 'number', defaultValue: '07:00' },
],
},
],
collectionMeta: {
+3
View File
@@ -192,6 +192,9 @@ export interface TrackingConfig {
memory_favorite_only: boolean;
memory_asset_type: string;
memory_min_rating: number;
quiet_hours_enabled: boolean;
quiet_hours_start: string | null;
quiet_hours_end: string | null;
created_at: string;
}
+63 -40
View File
@@ -226,24 +226,15 @@
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]);
// "More" panel items — everything not in the bottom bar
const mobileMoreItems = $derived<NavItem[]>([
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer' },
{ href: '/bots?tab=telegram', key: 'nav.bots', icon: 'mdiRobot' },
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline' },
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog' },
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit' },
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiConsoleLine' },
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox' },
...(auth.isAdmin ? [
{ href: '/settings', key: 'nav.settings', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
] : []),
]);
// "More" panel mirrors the full desktop sidebar tree so every subnode is
// reachable on mobile (previously it was a flat hand-picked list that
// hid all target types, bot channels, and several nested pages).
let mobileMoreOpen = $state(false);
function closeMobileMore() {
mobileMoreOpen = false;
}
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
@@ -538,7 +529,7 @@
</aside>
<!-- Mobile bottom nav -->
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0; backdrop-filter: blur(12px);">
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
{#each mobileNavItems as item}
<a href={item.href} aria-label={t(item.key)}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
@@ -558,40 +549,69 @@
</button>
</nav>
<!-- Mobile "More" panel -->
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
{#if mobileMoreOpen}
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
onclick={() => mobileMoreOpen = false} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: 3.25rem; left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: 60vh; overflow-y: auto;"
onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="grid grid-cols-3 gap-2">
{#each mobileMoreItems as item}
<a href={item.href}
onclick={() => mobileMoreOpen = false}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(item.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={item.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(item.key)}</span>
</a>
<div class="space-y-3">
{#each navEntries as entry}
{#if isGroup(entry)}
<div>
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
style="color: var(--color-muted-foreground);">
<MdiIcon name={entry.icon} size={13} />
<span>{t(entry.key)}</span>
</div>
<div class="grid grid-cols-3 gap-2">
{#each entry.children as child}
<a href={child.href} onclick={closeMobileMore}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={child.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
{/if}
</a>
{/each}
</div>
</div>
{:else}
<a href={entry.href} onclick={closeMobileMore}
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={entry.icon} size={18} />
<span class="text-sm flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]}
<span class="nav-badge">{navCounts[entry.countKey]}</span>
{/if}
</a>
{/if}
{/each}
<button onclick={() => { mobileMoreOpen = false; logout(); }}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={20} />
<span class="text-xs text-center leading-tight">{t('nav.logout')}</span>
</button>
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
<button onclick={() => { closeMobileMore(); logout(); }}
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={18} />
<span class="text-sm">{t('nav.logout')}</span>
</button>
</div>
</div>
</div>
{/if}
<!-- Main content -->
<main class="flex-1 overflow-auto pb-16 md:pb-0">
<main class="flex-1 overflow-auto md:pb-0"
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
{#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
{@render children()}
@@ -611,19 +631,22 @@
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3">
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
readonly aria-hidden="true" tabindex="-1"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" bind:value={pwdCurrent} required
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" bind:value={pwdNew} required minlength="8"
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
<input id="pwd-confirm" type="password" bind:value={pwdConfirm} required minlength="8"
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
+124 -7
View File
@@ -9,25 +9,66 @@
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import LocaleSelector from '$lib/components/LocaleSelector.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
interface CacheBucketStats {
count: number;
total_size_bytes: number;
oldest: string | null;
newest: string | null;
}
interface CacheStats {
url: CacheBucketStats;
asset: CacheBucketStats;
}
let loaded = $state(false);
let saving = $state(false);
let clearingCache = $state(false);
let confirmClearCache = $state(false);
let error = $state('');
let settings = $state({
external_url: '',
telegram_webhook_secret: '',
telegram_cache_ttl_hours: '48',
telegram_cache_ttl_hours: '720',
telegram_asset_cache_max_entries: '5000',
supported_locales: 'en,ru',
timezone: 'UTC',
});
let cacheStats = $state<CacheStats | null>(null);
async function loadCacheStats() {
try {
cacheStats = await api<CacheStats>('/settings/telegram-cache/stats');
} catch { cacheStats = null; }
}
onMount(async () => {
try {
settings = await api('/settings');
await loadCacheStats();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { loaded = true; }
});
function formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
}
function formatTs(iso: string | null): string {
if (!iso) return '—';
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
return isNaN(d.getTime()) ? iso : d.toLocaleString();
}
async function save() {
saving = true; error = '';
try {
@@ -36,6 +77,17 @@
} catch (err: any) { error = err.message; snackError(err.message); }
saving = false;
}
async function clearTelegramCache() {
confirmClearCache = false;
clearingCache = true;
try {
await api('/settings/telegram-cache/clear', { method: 'POST' });
snackSuccess(t('settings.clearCacheDone'));
await loadCacheStats();
} catch (err: any) { snackError(err.message); }
clearingCache = false;
}
</script>
<PageHeader title={t('settings.title')} description={t('settings.description')} />
@@ -57,6 +109,10 @@
<input bind:value={settings.external_url} placeholder="https://notify.example.com"
class="w-full max-w-md px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</div>
<div>
<label class="block text-xs font-medium mb-2">{t('settings.timezone')}<Hint text={t('settings.timezoneHint')} /></label>
<TimezoneSelector bind:value={settings.timezone} />
</div>
</div>
</Card>
@@ -69,14 +125,68 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.webhookSecret')}<Hint text={t('settings.webhookSecretHint')} /></label>
<input bind:value={settings.telegram_webhook_secret} type="password" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
<form onsubmit={(e) => e.preventDefault()} autocomplete="off">
<input bind:value={settings.telegram_webhook_secret} type="password" autocomplete="off" placeholder={t('providers.optional')}
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)] font-mono" />
</form>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheTtl')}<Hint text={t('settings.cacheTtlHint')} /></label>
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="1" max="720"
<input bind:value={settings.telegram_cache_ttl_hours} type="number" min="0" max="8760"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('settings.cacheMaxEntries')}<Hint text={t('settings.cacheMaxEntriesHint')} /></label>
<input bind:value={settings.telegram_asset_cache_max_entries} type="number" min="100" max="100000"
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="mt-4 pt-4 border-t border-[var(--color-border)]">
<div class="text-xs font-medium mb-2 flex items-center" style="color: var(--color-muted-foreground);">
{t('settings.cacheStats')}<Hint text={t('settings.cacheStatsHint')} />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-3">
{#each [
{ label: t('settings.cacheStatsUrl'), data: cacheStats?.url },
{ label: t('settings.cacheStatsAsset'), data: cacheStats?.asset },
] as bucket}
<div class="px-3 py-2 rounded-md border border-[var(--color-border)] bg-[var(--color-background)] text-xs">
<div class="flex items-baseline justify-between gap-2">
<span class="font-medium">{bucket.label}</span>
{#if bucket.data && bucket.data.count > 0}
<span>
<span class="font-mono">{bucket.data.count}</span>
<span style="color: var(--color-muted-foreground);"> {t('settings.cacheStatsEntries')}</span>
{#if bucket.data.total_size_bytes > 0}
<span style="color: var(--color-muted-foreground);"> · </span>
<span class="font-mono">{formatBytes(bucket.data.total_size_bytes)}</span>
{/if}
</span>
{:else}
<span style="color: var(--color-muted-foreground);">{t('settings.cacheStatsEmpty')}</span>
{/if}
</div>
{#if bucket.data && bucket.data.count > 0 && (bucket.data.oldest || bucket.data.newest)}
<div class="mt-1 flex flex-wrap gap-x-3 gap-y-0.5" style="color: var(--color-muted-foreground);">
{#if bucket.data.oldest}
<span>{t('settings.cacheStatsOldest')}: <span class="font-mono">{formatTs(bucket.data.oldest)}</span></span>
{/if}
{#if bucket.data.newest}
<span>{t('settings.cacheStatsNewest')}: <span class="font-mono">{formatTs(bucket.data.newest)}</span></span>
{/if}
</div>
{/if}
</div>
{/each}
</div>
<div class="flex items-center gap-3 flex-wrap">
<button type="button" onclick={() => confirmClearCache = true} disabled={clearingCache}
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] bg-[var(--color-background)] hover:bg-[var(--color-muted)] disabled:opacity-50">
<MdiIcon name="mdiDeleteSweep" size={16} />
{clearingCache ? t('common.loading') : t('settings.clearCache')}
</button>
<span class="text-xs" style="color: var(--color-muted-foreground);">{t('settings.clearCacheHint')}</span>
</div>
</div>
</Card>
@@ -88,9 +198,8 @@
</h3>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium mb-1">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<input bind:value={settings.supported_locales} placeholder="en,ru,de,fr"
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" />
<label class="block text-xs font-medium mb-2">{t('settings.supportedLocales')}<Hint text={t('settings.supportedLocalesHint')} /></label>
<LocaleSelector bind:value={settings.supported_locales} />
</div>
</div>
</Card>
@@ -99,4 +208,12 @@
{saving ? t('common.loading') : t('common.save')}
</Button>
</div>
<ConfirmModal open={confirmClearCache}
title={t('settings.clearCacheConfirmTitle')}
message={t('settings.clearCacheConfirm')}
confirmLabel={t('settings.clearCacheConfirmBtn')}
confirmIcon="mdiDeleteSweep"
onconfirm={clearTelegramCache}
oncancel={() => confirmClearCache = false} />
{/if}
@@ -181,9 +181,12 @@
{:else if field.type === 'grid-select' && field.gridItems}
<IconGridSelect items={gridItemSources[field.gridItems]()} bind:value={form[field.key]} columns={field.gridColumns ?? 2} compact />
{:else}
<input type={field.key.includes('date') ? 'date' : field.key.includes('times') ? 'text' : 'number'}
<input type={field.key.includes('date') ? 'date'
: field.key.startsWith('quiet_hours_') ? 'time'
: field.key.includes('times') ? 'text'
: 'number'}
bind:value={form[field.key]} min={field.min} max={field.max}
placeholder={field.key.includes('times') ? String(field.defaultValue ?? '') : ''}
placeholder={field.key.includes('times') || field.key.startsWith('quiet_hours_') ? String(field.defaultValue ?? '') : ''}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{/if}
</div>
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-core"
version = "0.2.0"
version = "0.2.4"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12"
dependencies = [
@@ -294,10 +294,24 @@ class NotificationDispatcher:
await self._preload_asset_data(assets, media_assets, session, max_size)
default_message = self._render_message(event, target, target.locale)
# Asset cache (when in thumbhash mode) invalidates entries when the
# asset's visual content changes. The resolver maps asset id → its
# current thumbhash. Providers that expose thumbhash put it in
# ``asset.extra["thumbhash"]`` (currently Immich).
thumbhash_map = {
asset.id: asset.extra.get("thumbhash")
for asset in event.added_assets
if asset.extra.get("thumbhash")
}
thumbhash_resolver = (
(lambda key: thumbhash_map.get(key)) if thumbhash_map else None
)
client = TelegramClient(
session, bot_token,
url_cache=self._url_cache,
asset_cache=self._asset_cache,
thumbhash_resolver=thumbhash_resolver,
)
for receiver in target.receivers:
@@ -11,56 +11,69 @@ from notify_bridge_core.storage import StorageBackend
_LOGGER = logging.getLogger(__name__)
DEFAULT_TELEGRAM_CACHE_TTL = 48 * 60 * 60
DEFAULT_MAX_ENTRIES = 5000
class TelegramFileCache:
"""Cache for Telegram file_ids to avoid re-uploading media.
Supports two validation modes:
- TTL mode (default): entries expire after a configured time-to-live
- Thumbhash mode: entries validated by comparing stored thumbhash with current
"""
Two complementary invalidation strategies, usable together or separately:
- TTL: entries expire after ``ttl_seconds``. Set to 0 to disable TTL
(cache essentially forever, subject only to the size cap).
- Thumbhash mode: entries are validated on read by comparing the stored
thumbhash with the one the caller supplies; a mismatch drops the entry.
Intended for content-addressable assets (e.g. Immich) where re-uploads
should be triggered by visual change, not elapsed time.
THUMBHASH_MAX_ENTRIES = 2000
``max_entries`` always applies as an LRU size cap (by ``cached_at``).
"""
def __init__(
self,
backend: StorageBackend,
ttl_seconds: int = DEFAULT_TELEGRAM_CACHE_TTL,
use_thumbhash: bool = False,
max_entries: int = DEFAULT_MAX_ENTRIES,
) -> None:
self._backend = backend
self._data: dict[str, Any] | None = None
self._ttl_seconds = ttl_seconds
self._use_thumbhash = use_thumbhash
self._max_entries = max_entries
async def async_load(self) -> None:
self._data = await self._backend.load() or {"files": {}}
await self._cleanup_expired()
async def _cleanup_expired(self) -> None:
if self._use_thumbhash:
files = self._data.get("files", {}) if self._data else {}
if len(files) > self.THUMBHASH_MAX_ENTRIES:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self.THUMBHASH_MAX_ENTRIES]:
del files[key]
await self._backend.save(self._data)
return
if not self._data or "files" not in self._data:
return
files = self._data["files"]
changed = False
now = datetime.now(timezone.utc)
expired = [
url for url, entry in self._data["files"].items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
if expired:
# TTL sweep — only when TTL validation is active (i.e. no thumbhash
# mode and a positive TTL). In thumbhash mode we rely entirely on
# content validation; in "TTL disabled" mode (ttl_seconds <= 0) we
# cache forever, subject only to the size cap.
if not self._use_thumbhash and self._ttl_seconds > 0:
now = datetime.now(timezone.utc)
expired = [
url for url, entry in files.items()
if entry.get("cached_at") and
(now - datetime.fromisoformat(entry["cached_at"])).total_seconds() > self._ttl_seconds
]
for key in expired:
del self._data["files"][key]
del files[key]
changed = True
# LRU cap — always enforced. Evicts oldest-cached entries first.
if self._max_entries > 0 and len(files) > self._max_entries:
sorted_keys = sorted(files, key=lambda k: files[k].get("cached_at", ""))
for key in sorted_keys[: len(files) - self._max_entries]:
del files[key]
changed = True
if changed:
await self._backend.save(self._data)
def get(self, key: str, thumbhash: str | None = None) -> dict[str, Any] | None:
@@ -77,7 +90,7 @@ class TelegramFileCache:
if stored and stored != thumbhash:
del self._data["files"][key]
return None
else:
elif self._ttl_seconds > 0:
cached_at_str = entry.get("cached_at")
if cached_at_str:
age = (datetime.now(timezone.utc) - datetime.fromisoformat(cached_at_str)).total_seconds()
@@ -152,3 +165,32 @@ class TelegramFileCache:
async def async_remove(self) -> None:
await self._backend.remove()
self._data = None
def stats(self) -> dict[str, Any]:
"""Return summary stats about the current cache contents.
Includes the number of cached entries, total tracked size in bytes
(only counts entries with a recorded ``size``), and the oldest /
newest ``cached_at`` timestamps (ISO strings, or ``None`` if empty).
"""
files = self._data.get("files", {}) if self._data else {}
count = len(files)
total_size = 0
oldest: str | None = None
newest: str | None = None
for entry in files.values():
size = entry.get("size")
if isinstance(size, int):
total_size += size
cached_at = entry.get("cached_at")
if cached_at:
if oldest is None or cached_at < oldest:
oldest = cached_at
if newest is None or cached_at > newest:
newest = cached_at
return {
"count": count,
"total_size_bytes": total_size,
"oldest": oldest,
"newest": newest,
}
@@ -300,6 +300,16 @@ class TelegramClient:
# Retry without parse_mode on parse errors
desc = str(result.get("description", ""))
if "parse" in desc.lower():
# Log loudly: a parse failure means the template author (or
# an asset field) is producing malformed HTML. Silent
# fallback hides bugs and makes XSS-via-unescaped-field
# harder to spot. Do not log the full payload — it may
# contain secrets.
_LOGGER.warning(
"Telegram rejected parse_mode=%s (%r); retrying as plain text. "
"Check template output for unescaped characters.",
payload.get("parse_mode"), desc,
)
payload.pop("parse_mode", None)
async with self._session.post(telegram_url, json=payload) as retry_resp:
retry_result = await retry_resp.json()
@@ -321,6 +321,12 @@ def collect_scheduled_assets(
asset_album_map: dict[str, tuple[str, str]] = {} # asset_id → (album_id, public_url)
collections_extra: list[dict[str, Any]] = []
# limit=0 is the periodic-summary test path — the caller only needs
# per-album stats (name/url/counts), not a sample of assets. Skip the
# expensive ``filter_assets`` + sampling loop entirely; on a 50k-asset
# album the serial scan-then-discard pattern wasted seconds per test.
stats_only = limit <= 0
for album_id, album in albums.items():
links = shared_links.get(album_id, [])
album_public_url = get_public_url(external_url, links) or ""
@@ -336,6 +342,9 @@ def collect_scheduled_assets(
"owner": album.owner,
})
if stats_only:
continue
filtered = filter_assets(
list(album.assets.values()),
favorite_only=favorite_only,
@@ -348,6 +357,9 @@ def collect_scheduled_assets(
asset_album_map[asset.id] = (album_id, album_public_url)
all_eligible.append(asset)
if stats_only:
return [], collections_extra
# Random sample
if len(all_eligible) > limit:
selected = random.sample(all_eligible, limit)
@@ -3,14 +3,47 @@
from __future__ import annotations
import logging
import re
from typing import Any
import aiohttp
from ...notifications.ssrf import UnsafeURLError, validate_outbound_url
from .models import ImmichAlbumData, SharedLinkInfo
_LOGGER = logging.getLogger(__name__)
# Cap user-controlled Immich search parameters so a low-privileged command
# listener (e.g. an Immich ``/search`` command) cannot DoS the upstream.
MAX_SEARCH_QUERY_LEN = 256
MAX_SEARCH_PERSON_IDS = 50
# User-facing error bodies — Immich responses may leak internal paths,
# hostnames, or headers injected by intermediary proxies. These helpers keep
# only a short, scrubbed summary; full bodies are logged server-side only.
_REDACTED_BODY_MAX = 120
_SECRET_PATTERN = re.compile(
r"(?i)(bearer\s+\S+|x-api-key[:=]\s*\S+|authorization[:=]\s*\S+|cookie[:=]\s*\S+|"
r"password[:=]?\s*\S+|token[:=]?\s*[A-Za-z0-9._\-]+)"
)
def _redact_body(text: str) -> str:
"""Return a short, credential-scrubbed snippet safe to surface to UI callers.
Immich error responses are admin-configurable (via reverse proxies, custom
error pages) and may echo request headers or environment leak. Stripping
anything that looks like a credential + capping length keeps us from
persisting secrets into ``ActionExecution.error`` / ``EventLog.details``
(both of which are returned through the dashboard API).
"""
if not text:
return ""
cleaned = _SECRET_PATTERN.sub("[redacted]", text)
if len(cleaned) > _REDACTED_BODY_MAX:
return cleaned[:_REDACTED_BODY_MAX] + "..."
return cleaned
class ImmichClient:
"""Async client for the Immich API."""
@@ -25,6 +58,19 @@ class ImmichClient:
self._url = url.rstrip("/")
self._api_key = api_key
self._external_domain: str | None = None
# SSRF guard — admin-set Immich URLs are loaded from provider config
# which can be mutated via PATCH /api/providers or imported via
# prepare-restore, so we revalidate at construction time rather than
# trusting DB state. Homelab deployments pointing at RFC1918 targets
# must set ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` in the runtime env.
if self._url:
try:
validate_outbound_url(self._url)
except UnsafeURLError as err:
raise UnsafeURLError(
f"Refusing to build ImmichClient for unsafe URL {self._url!r}: {err}. "
"If this is a LAN/homelab Immich, set NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1."
) from err
@property
def url(self) -> str:
@@ -36,6 +82,14 @@ class ImmichClient:
@external_domain.setter
def external_domain(self, value: str | None) -> None:
# Mirror the constructor's SSRF guard. Set
# ``NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1`` for LAN/homelab targets.
if value:
try:
validate_outbound_url(value)
except UnsafeURLError as err:
_LOGGER.warning("Ignoring unsafe external_domain %r: %s", value, err)
return
self._external_domain = value
@property
@@ -237,22 +291,15 @@ class ImmichClient:
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": limit}
# Cap user-controlled inputs — a low-privileged Telegram listener can
# craft arbitrarily long queries to DoS the upstream Immich.
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"query": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids
try:
async with self._session.post(
f"{self._url}/api/search/smart",
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
return []
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
return await self._search_items(
f"{self._url}/api/search/smart", payload, limit, "smart",
)
async def search_metadata(
self,
@@ -261,21 +308,61 @@ class ImmichClient:
limit: int = 10,
page: int = 1,
) -> list[dict[str, Any]]:
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": limit}
query = (query or "")[:MAX_SEARCH_QUERY_LEN]
payload: dict[str, Any] = {"originalFileName": query, "page": max(1, page), "size": min(max(1, limit), 100)}
if album_ids:
payload["albumIds"] = album_ids
payload["albumIds"] = album_ids[:MAX_SEARCH_PERSON_IDS]
return await self._search_items(
f"{self._url}/api/search/metadata", payload, limit, "metadata",
)
async def _search_items(
self,
url: str,
payload: dict[str, Any],
limit: int,
kind: str,
) -> list[dict[str, Any]]:
"""Shared POST-and-extract-items helper with error logging.
Returns an empty list on any error; previously these paths swallowed
non-200s silently, making "/search always returns no results" on
misbehaving Immich deployments impossible to diagnose without a
network trace. Logging keeps the empty-list contract but tells the
operator *why* it's empty.
"""
try:
async with self._session.post(
f"{self._url}/api/search/metadata",
url,
headers=self._json_headers,
json=payload,
) as response:
if response.status == 200:
data = await response.json()
items = data.get("assets", {}).get("items", [])
return items[:limit]
except aiohttp.ClientError:
pass
if response.status != 200:
body_snip = await response.text()
_LOGGER.warning(
"Immich %s search non-200: HTTP %s body=%s",
kind, response.status, _redact_body(body_snip),
)
return []
data = await response.json()
# Modern Immich: {"assets": {"items": [...], ...}}
assets_block = data.get("assets")
if isinstance(assets_block, dict):
items = assets_block.get("items", []) or []
elif isinstance(assets_block, list):
# Older/alternate shape — flat list of assets.
items = assets_block
else:
_LOGGER.warning(
"Immich %s search returned unexpected shape: keys=%s",
kind, list(data.keys())[:5],
)
items = []
return items[:limit]
except aiohttp.ClientError as err:
_LOGGER.warning("Immich %s search transport error: %s", kind, err)
except Exception as err: # noqa: BLE001 — don't crash caller on unexpected JSON
_LOGGER.warning("Immich %s search parse error: %s", kind, err)
return []
async def search_by_person(
@@ -289,7 +376,7 @@ class ImmichClient:
to return an empty list on current servers.
"""
payload: dict[str, Any] = {
"personIds": [person_id],
"personIds": [person_id][:MAX_SEARCH_PERSON_IDS],
"page": 1,
"size": max(1, min(limit, 100)),
}
@@ -373,9 +460,17 @@ class ImmichClient:
if isinstance(parsed, dict):
return parsed
return {"raw": body_text}
# Log full body server-side (for operators), surface only a
# redacted snippet to the caller — this string ends up in
# ActionExecution.error / EventLog.details which are returned
# through the dashboard API.
_LOGGER.warning(
"add_assets_to_album failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError(
f"Failed to add assets to album {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
f"HTTP {response.status} {_redact_body(body_text)}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error adding assets to album: {err}") from err
@@ -399,9 +494,13 @@ class ImmichClient:
if response.status in (200, 201, 204):
return
body_text = await response.text()
_LOGGER.warning(
"set_album_thumbnail failed: HTTP %s body=%s",
response.status, body_text[:512],
)
raise ImmichApiError(
f"Failed to set album thumbnail for {album_id}: "
f"HTTP {response.status} body={body_text[:512]}"
f"HTTP {response.status} {_redact_body(body_text)}"
)
except aiohttp.ClientError as err:
raise ImmichApiError(f"Error setting album thumbnail: {err}") from err
@@ -1,4 +1,3 @@
📊 Status
Trackers: {{ trackers_active }}/{{ trackers_total }} active
Albums: {{ total_albums }}
Last event: {{ last_event }}
Last event: {{ last_event }}
@@ -1,4 +1,3 @@
📊 Статус
Трекеры: {{ trackers_active }}/{{ trackers_total }} активных
Альбомы: {{ total_albums }}
Последнее событие: {{ last_event }}
Последнее событие: {{ last_event }}
@@ -2,16 +2,67 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from notify_bridge_core.models.events import ServiceEvent
_LOGGER = logging.getLogger(__name__)
# Per-target maximum video size (bytes). None = no limit.
_MAX_VIDEO_SIZE_BY_TARGET: dict[str, int] = {
"telegram": 50 * 1024 * 1024, # 50 MB — Telegram Bot API hard limit
}
# Keys that must NEVER flow into the Jinja2 template context, even if a
# provider stuffs them into ``event.extra`` (webhooks, Immich metadata, etc.).
# Templates that could reach a Telegram/Discord/etc. chat would otherwise
# expose operator credentials if a template author simply did ``{{ api_key }}``.
# Case-insensitive substring match — any ``extra`` key containing one of these
# tokens is dropped before the merge.
_SENSITIVE_EXTRA_TOKENS: tuple[str, ...] = (
"api_key",
"apikey",
"token",
"secret",
"password",
"passwd",
"hashed_",
"authorization",
"cookie",
"session_id",
"bearer",
"private_key",
"access_key",
)
def _is_sensitive_key(key: str) -> bool:
lowered = str(key).lower()
return any(tok in lowered for tok in _SENSITIVE_EXTRA_TOKENS)
def _safe_merge_extras(ctx: dict[str, Any], extras: dict[str, Any]) -> None:
"""Merge provider ``extras`` into ``ctx``, dropping sensitive keys.
Dropped keys are logged once per event (DEBUG) so operators can spot
leaking providers without flooding the log.
"""
if not extras:
return
dropped: list[str] = []
for key, value in extras.items():
if _is_sensitive_key(key):
dropped.append(key)
continue
ctx[key] = value
if dropped:
_LOGGER.debug(
"Dropped %d sensitive key(s) from template context: %s",
len(dropped), ", ".join(sorted(dropped)),
)
def build_template_context(
event: ServiceEvent,
@@ -61,8 +112,9 @@ def build_template_context(
"preview_url": asset.preview_url or "",
"full_url": asset.full_url or "",
}
# Flatten extras into asset dict for template access
asset_dict.update(asset.extra)
# Flatten extras into asset dict for template access — same
# sensitive-key filtering applied as the top-level merge.
_safe_merge_extras(asset_dict, asset.extra)
asset_dict.setdefault("oversized", False)
asset_dict.setdefault("file_size", None)
asset_dict.setdefault("playback_size", None)
@@ -138,8 +190,11 @@ def build_template_context(
if len(locations) == 1 and "" not in locations:
ctx["common_location"] = locations.pop()
# Provider-specific extras merged at top level
ctx.update(event.extra)
# Provider-specific extras merged at top level. Sensitive keys (tokens,
# secrets, auth headers) are dropped — see ``_SENSITIVE_EXTRA_TOKENS``.
# Without this, a template author could exfiltrate provider credentials
# via ``{{ api_key }}`` in an outgoing notification body.
_safe_merge_extras(ctx, event.extra)
# Ensure URL variables always exist (avoid Jinja2 undefined errors)
ctx.setdefault("public_url", "")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "notify-bridge-server"
version = "0.2.0"
version = "0.2.4"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12"
dependencies = [
@@ -20,17 +20,27 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
_SETTING_KEYS = {
"external_url": "NOTIFY_BRIDGE_EXTERNAL_URL",
"telegram_webhook_secret": "NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET",
"telegram_cache_ttl_hours": None, # no env fallback, default 48
"telegram_cache_ttl_hours": None, # URL cache TTL; 0 disables TTL
"telegram_asset_cache_max_entries": None, # LRU cap for both caches
"supported_locales": None, # comma-separated locale codes
"timezone": "NOTIFY_BRIDGE_TIMEZONE", # IANA tz (e.g. "Europe/Warsaw"); empty = UTC
}
_DEFAULTS = {
"external_url": "",
"telegram_webhook_secret": "",
"telegram_cache_ttl_hours": "48",
# 720h = 30d. URL cache only; asset cache uses thumbhash validation
# (content-addressable) and ignores TTL entirely.
"telegram_cache_ttl_hours": "720",
"telegram_asset_cache_max_entries": "5000",
"supported_locales": "en,ru",
"timezone": "UTC",
}
# Settings whose changes require dropping in-memory Telegram caches so the
# next dispatch rebuilds them with the new parameters. Files are preserved.
_CACHE_SETTING_KEYS = {"telegram_cache_ttl_hours", "telegram_asset_cache_max_entries"}
async def get_setting(session: AsyncSession, key: str) -> str:
"""Read a setting from DB, falling back to env var then default."""
@@ -49,7 +59,9 @@ class SettingsUpdate(BaseModel):
external_url: str | None = None
telegram_webhook_secret: str | None = None
telegram_cache_ttl_hours: str | None = None
telegram_asset_cache_max_entries: str | None = None
supported_locales: str | None = None
timezone: str | None = None
@router.get("")
@@ -77,6 +89,7 @@ async def update_settings(
"""Update app settings (admin). Re-registers webhooks when base URL changes."""
old_base_url = await get_setting(session, "external_url")
old_secret = await get_setting(session, "telegram_webhook_secret")
old_cache_values = {k: await get_setting(session, k) for k in _CACHE_SETTING_KEYS}
for key in _SETTING_KEYS:
value = getattr(body, key, None)
@@ -90,6 +103,17 @@ async def update_settings(
session.add(row)
await session.commit()
# Drop in-memory caches if any cache-tuning setting actually changed, so
# the next dispatch rebuilds them with the new parameters. Files survive.
cache_changed = False
for key in _CACHE_SETTING_KEYS:
if await get_setting(session, key) != old_cache_values[key]:
cache_changed = True
break
if cache_changed:
from ..services.watcher import reset_telegram_caches_in_memory
await reset_telegram_caches_in_memory()
new_base_url = await get_setting(session, "external_url")
new_secret = await get_setting(session, "telegram_webhook_secret")
@@ -108,6 +132,25 @@ async def update_settings(
return result
@router.get("/telegram-cache/stats")
async def telegram_cache_stats(
user: User = Depends(require_admin),
):
"""Return counts and sizes for the Telegram file_id caches."""
from ..services.watcher import get_telegram_cache_stats
return await get_telegram_cache_stats()
@router.post("/telegram-cache/clear")
async def clear_telegram_cache(
user: User = Depends(require_admin),
):
"""Clear the Telegram file_id cache (URL and asset) from disk and memory."""
from ..services.watcher import clear_telegram_caches
result = await clear_telegram_caches()
return result
@router.get("/locales")
async def get_supported_locales(
user: User = Depends(get_current_user),
@@ -1,13 +1,15 @@
"""Configuration backup/restore API (admin only)."""
import asyncio
import hashlib
import json
import logging
import os
import signal
from datetime import datetime, timezone
from urllib.parse import urlparse
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, File, Query
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, UploadFile, File, Query
from fastapi.responses import JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -28,6 +30,11 @@ PENDING_RESTORE_PATH_KEY = "pending_restore_path"
PENDING_RESTORE_CONFLICT_KEY = "pending_restore_conflict_mode"
PENDING_RESTORE_UPLOADED_AT_KEY = "pending_restore_uploaded_at"
PENDING_RESTORE_UPLOADED_BY_KEY = "pending_restore_uploaded_by"
# SHA256 of the staged pending_restore.json, written atomically with the file.
# The startup hook refuses to apply if the on-disk file's hash does not match —
# defends against anyone dropping a tampered file into data/ between prepare
# and restart.
PENDING_RESTORE_SHA256_KEY = "pending_restore_sha256"
def _pending_restore_path():
@@ -44,6 +51,69 @@ router = APIRouter(prefix="/api/backup", tags=["backup"])
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
async def _read_upload_bounded(file: UploadFile, max_bytes: int = MAX_UPLOAD_SIZE) -> bytes:
"""Read an UploadFile into memory, failing fast if it exceeds ``max_bytes``.
Rejects on ``content_length`` header up-front when available; always
stream-reads with a running byte counter so we never allocate more than
the limit even when the header is missing or lies.
"""
# Fast path: reject on header before we allocate anything.
cl = file.headers.get("content-length") if hasattr(file, "headers") else None
if cl:
try:
if int(cl) > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
except ValueError:
pass
chunks: list[bytes] = []
total = 0
while True:
chunk = await file.read(64 * 1024)
if not chunk:
break
total += len(chunk)
if total > max_bytes:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
chunks.append(chunk)
return b"".join(chunks)
def _check_same_origin(request: Request) -> None:
"""Reject cross-origin admin-write POSTs (CSRF defense).
Bearer tokens in ``localStorage`` plus cookie-less CORS mean a malicious
page cannot technically submit our Authorization header from a victim's
session, BUT browser extensions and misconfigured CORS policies routinely
break this assumption. For endpoints whose blast radius is restart/RCE-
equivalent (restore apply), we additionally require the request to come
from our own origin.
"""
host = request.headers.get("host", "").lower()
if not host:
raise HTTPException(status_code=400, detail="Missing Host header")
def _host_of(u: str | None) -> str:
if not u:
return ""
try:
return (urlparse(u).netloc or "").lower()
except Exception: # noqa: BLE001
return ""
origin_host = _host_of(request.headers.get("origin"))
referer_host = _host_of(request.headers.get("referer"))
# At least one of Origin/Referer must be present and match Host.
# Legitimate browser requests to this endpoint always ship Origin.
same = (origin_host and origin_host == host) or (referer_host and referer_host == host)
if not same:
raise HTTPException(
status_code=403,
detail="Cross-origin request rejected",
)
def _backup_dir():
return app_config.data_dir / "backups"
@@ -104,9 +174,7 @@ async def validate_config(
user: User = Depends(require_admin),
):
"""Validate a backup file without importing."""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
content = await _read_upload_bounded(file)
try:
raw = json.loads(content)
@@ -129,9 +197,7 @@ async def import_config(
session: AsyncSession = Depends(get_session),
):
"""Import configuration from a backup file."""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
content = await _read_upload_bounded(file)
try:
raw = json.loads(content)
@@ -167,6 +233,7 @@ async def _clear_pending_restore_markers(session: AsyncSession) -> None:
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
):
row = await session.get(AppSetting, key)
if row:
@@ -185,9 +252,7 @@ async def prepare_restore(
Validates the uploaded file, writes it to ``data/pending_restore.json``,
and persists marker settings so startup will apply it atomically.
"""
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail="File too large (max 10 MB)")
content = await _read_upload_bounded(file)
try:
raw = json.loads(content)
@@ -205,15 +270,25 @@ async def prepare_restore(
pending_path.parent.mkdir(parents=True, exist_ok=True)
# Atomic write: write to tmp then rename, so a crash mid-write never
# leaves a truncated pending_restore.json that would break startup apply.
payload = json.dumps(raw).encode("utf-8")
digest = hashlib.sha256(payload).hexdigest()
tmp_path = pending_path.with_suffix(pending_path.suffix + ".tmp")
tmp_path.write_text(json.dumps(raw), encoding="utf-8")
tmp_path.write_bytes(payload)
os.replace(tmp_path, pending_path)
# Best-effort tighten perms so a non-root local user cannot swap the file
# for one they control between prepare and restart. On Windows this is a
# no-op; on POSIX we restrict to owner-only rw.
try:
os.chmod(pending_path, 0o600)
except OSError:
pass
now_iso = datetime.now(timezone.utc).isoformat()
await _set_app_setting(session, PENDING_RESTORE_PATH_KEY, str(pending_path))
await _set_app_setting(session, PENDING_RESTORE_CONFLICT_KEY, conflict_mode.value)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_AT_KEY, now_iso)
await _set_app_setting(session, PENDING_RESTORE_UPLOADED_BY_KEY, user.username)
await _set_app_setting(session, PENDING_RESTORE_SHA256_KEY, digest)
await session.commit()
return {
@@ -292,6 +367,7 @@ def _is_supervised() -> bool:
@router.post("/apply-restart")
async def apply_and_restart(
request: Request,
background_tasks: BackgroundTasks,
user: User = Depends(require_admin),
session: AsyncSession = Depends(get_session),
@@ -299,7 +375,11 @@ async def apply_and_restart(
"""Trigger a graceful exit so the supervisor respawns and applies the pending restore.
Only allowed when a pending restore is staged AND the process is supervised.
Requires same-origin Origin/Referer — this endpoint's blast radius is a
full config replace + restart, so an admin token alone (vulnerable to
XSS-driven CSRF) is not enough.
"""
_check_same_origin(request)
path_row = await session.get(AppSetting, PENDING_RESTORE_PATH_KEY)
if not path_row or not path_row.value:
raise HTTPException(status_code=409, detail="No pending restore to apply")
@@ -138,13 +138,11 @@ async def get_command_variables(
# --- Immich-specific ---
immich = {
"status": {
"description": "/status tracker summary",
"description": "/status tracker summary (scoped to this chat)",
"variables": {
**common_vars,
"trackers_active": "Number of active trackers",
"trackers_total": "Total tracker count",
"total_albums": "Total tracked albums",
"last_event": "Last event timestamp string",
"total_albums": "Tracked albums visible to this chat",
"last_event": "Last event timestamp string (scoped to this chat's albums)",
},
},
"albums": {
@@ -54,6 +54,9 @@ class TrackingConfigCreate(BaseModel):
memory_favorite_only: bool = False
memory_asset_type: str = "all"
memory_min_rating: int = 0
quiet_hours_enabled: bool = False
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackingConfigUpdate(BaseModel):
@@ -93,6 +96,9 @@ class TrackingConfigUpdate(BaseModel):
memory_favorite_only: bool | None = None
memory_asset_type: str | None = None
memory_min_rating: int | None = None
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
@@ -4,6 +4,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import func
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -81,6 +82,12 @@ async def update_user(
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Track whether the identity that JWTs encode has changed. Any such change
# must bump ``token_version`` so already-issued tokens are rejected — a
# user demoted admin→user must not keep admin in their cached JWT until
# expiry, and a rename should invalidate prior sessions too.
identity_changed = False
if body.username is not None and body.username != user.username:
new_username = body.username.strip()
if not new_username:
@@ -89,21 +96,51 @@ async def update_user(
if dup.first():
raise HTTPException(status_code=409, detail="Username already exists")
user.username = new_username
identity_changed = True
if body.role is not None and body.role != user.role:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role")
# Prevent demoting the last admin
# Prevent demoting the last admin. Done via a COUNT to avoid loading
# every admin row; more importantly, re-checked *after* the role
# change is staged (TOCTOU guard — two concurrent demotes can each
# see admin_count=2 and both proceed, dropping to 0).
if user.role == "admin" and body.role != "admin":
admins = (await session.exec(
select(User).where(User.role == "admin")
)).all()
if len(admins) <= 1:
admin_count = (await session.exec(
select(func.count(User.id)).where(User.role == "admin")
)).one()
if isinstance(admin_count, tuple):
admin_count = admin_count[0]
if (admin_count or 0) <= 1:
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role
identity_changed = True
if identity_changed:
user.token_version = (user.token_version or 1) + 1
session.add(user)
await session.commit()
try:
await session.commit()
except Exception:
await session.rollback()
raise
# Final defense against admin-count race: if we just demoted the last admin
# due to a concurrent demote landing between our check and commit, undo.
if body.role is not None and body.role != "admin":
admin_count_after = (await session.exec(
select(func.count(User.id)).where(User.role == "admin")
)).one()
if isinstance(admin_count_after, tuple):
admin_count_after = admin_count_after[0]
if (admin_count_after or 0) < 1:
# Roll the user back to admin and re-commit.
user.role = "admin"
session.add(user)
await session.commit()
raise HTTPException(status_code=409, detail="Refused: would remove the last admin")
await session.refresh(user)
return {"id": user.id, "username": user.username, "role": user.role}
@@ -126,6 +163,9 @@ async def reset_user_password(
if len(body.new_password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
user.hashed_password = bcrypt.hashpw(body.new_password.encode(), bcrypt.gensalt()).decode()
# Invalidate all prior JWTs issued for this user — matches the self-serve
# password-change path in auth/routes.py.
user.token_version = (user.token_version or 1) + 1
session.add(user)
await session.commit()
return {"success": True}
@@ -27,7 +27,11 @@ from ..database.models import (
ServiceProvider,
WebhookPayloadLog,
)
from ..services.dispatch_helpers import event_allowed_by_config, load_link_data
from ..services.dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -144,6 +148,8 @@ async def _dispatch_webhook_event(
if not link_data:
continue
app_tz = await get_app_timezone(session)
# Log event
extra_details = {k: v for k, v in event.extra.items() if k in detail_keys}
session.add(EventLog(
@@ -164,7 +170,7 @@ async def _dispatch_webhook_event(
# Dispatch to targets
dispatcher = NotificationDispatcher()
target_configs = _build_target_configs(event, link_data, provider_config)
target_configs = _build_target_configs(event, link_data, provider_config, app_tz)
if target_configs:
results = await dispatcher.dispatch(event, target_configs)
for r in results:
@@ -513,12 +519,13 @@ def _build_target_configs(
event: ServiceEvent,
link_data: list[dict[str, Any]],
provider_config: dict[str, Any],
app_tz: str = "UTC",
) -> list[TargetConfig]:
"""Build TargetConfig objects for dispatch, applying tracking config filters."""
target_configs: list[TargetConfig] = []
for ld in link_data:
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc):
if tc and not event_allowed_by_config(event, tc, app_tz):
continue
tmpl = ld["template_config"]
@@ -56,6 +56,7 @@ class ProviderCommandHandler(ABC):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle a provider-specific command for a single tracker.
@@ -71,6 +72,13 @@ class ProviderCommandHandler(ABC):
bot: The Telegram bot instance.
tracker: The command tracker being dispatched.
config: The command config for this tracker.
listener: The listener row for this (tracker, bot) pair.
allowed_album_ids: Precomputed album scope for this (bot, chat)
pair. Resolved by the dispatcher from the listener override
(if set) or the notification-routing graph. ``None`` means
"no scope restriction" (rarely the right default for album
providers empty set is the common case).
page: 1-based page number for paginated commands (/search, /find).
Returns:
A CommandResponse, or None if unhandled.
@@ -8,7 +8,14 @@ from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..database.engine import get_engine
from ..database.models import EventLog, NotificationTracker, ServiceProvider
from ..database.models import (
EventLog,
NotificationTarget,
NotificationTracker,
NotificationTrackerTarget,
ServiceProvider,
TargetReceiver,
)
_LOGGER = logging.getLogger(__name__)
@@ -20,25 +27,125 @@ async def get_trackers_for_provider(provider_id: int) -> list[NotificationTracke
return await _get_notification_trackers_for_providers({provider_id})
async def get_last_event_str(tracker_ids: list[int]) -> str:
async def get_last_event_str(
tracker_ids: list[int],
*,
allowed_album_ids: set[str] | None = None,
) -> str:
"""Get formatted timestamp of most recent event for given trackers.
Returns a 'YYYY-MM-DD HH:MM' string, or '-' if no events exist.
When ``allowed_album_ids`` is provided, only events whose
``collection_id`` is in the set are considered matches the per-chat
scope applied via ``CommandTrackerListener.allowed_album_ids``.
"""
if not tracker_ids:
return "-"
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(1)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(1))
last_event = result.first()
return last_event.created_at.strftime("%Y-%m-%d %H:%M") if last_event else "-"
async def resolve_chat_album_scope(
*,
provider_id: int,
bot_id: int,
chat_id: str,
) -> set[str]:
"""Compute the album scope for a (provider, bot, chat) triple.
Walks the notification-routing graph: find every notification tracker for
``provider_id`` that ultimately delivers to a Telegram receiver matching
this ``(bot_id, chat_id)``, then union their ``collection_ids``. The
result is the set of albums this specific chat legitimately sees
notifications for which is the natural "allowed albums" for commands
issued in that chat.
Returns:
set of album ids. Empty set = "no tracker routes to this chat"
caller should treat as "show nothing" (defense in depth); otherwise
a bot's chats would leak the provider's full album catalog.
Notes:
- Only enabled ``TargetReceiver`` rows are considered.
- Both direct Telegram targets and broadcast targets that fan out
to a Telegram child target are resolved.
- Explicit ``CommandTrackerListener.allowed_album_ids`` override is
NOT applied here that's the dispatcher's job. This helper is
the "derived" fallback.
"""
engine = get_engine()
async with AsyncSession(engine) as session:
# 1. Telegram receivers in this chat (directly or via broadcast).
direct_rows = (await session.exec(
select(TargetReceiver, NotificationTarget)
.join(
NotificationTarget,
TargetReceiver.target_id == NotificationTarget.id,
)
.where(
TargetReceiver.enabled == True, # noqa: E712
NotificationTarget.type == "telegram",
)
)).all()
target_ids: set[int] = set()
for recv, target in direct_rows:
rc_chat = str(recv.config.get("chat_id", "") or "")
rc_bot = target.config.get("bot_id")
if rc_chat == str(chat_id) and rc_bot == bot_id:
target_ids.add(target.id)
# Follow broadcast parents: any broadcast target whose
# child_target_ids includes one of our direct Telegram target_ids
# also counts as "routes to this chat".
broadcast_rows = (await session.exec(
select(NotificationTarget).where(NotificationTarget.type == "broadcast")
)).all()
for b in broadcast_rows:
children = set(b.config.get("child_target_ids", []) or [])
disabled = set(b.config.get("disabled_child_ids", []) or [])
if (children - disabled) & target_ids:
target_ids.add(b.id)
if not target_ids:
return set()
# 2. Trackers pointing at those targets.
tracker_target_rows = (await session.exec(
select(NotificationTrackerTarget).where(
NotificationTrackerTarget.target_id.in_(target_ids)
)
)).all()
tracker_ids = {tt.tracker_id for tt in tracker_target_rows}
if not tracker_ids:
return set()
# 3. Filter trackers by provider and collect collection_ids.
trackers = (await session.exec(
select(NotificationTracker).where(
NotificationTracker.id.in_(tracker_ids),
NotificationTracker.provider_id == provider_id,
)
)).all()
scope: set[str] = set()
for tr in trackers:
for aid in (tr.collection_ids or []):
if aid:
scope.add(aid)
return scope
def get_tracked_collection_ids(
provider: ServiceProvider,
trackers: list[NotificationTracker],
@@ -79,6 +79,7 @@ class GiteaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Gitea has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -286,6 +286,8 @@ async def handle_command(
page = max(1, count_override)
count_override = None
from .command_utils import resolve_chat_album_scope
responses: list[CommandResponse] = []
for tracker, config, provider, listener in ctx_tuples:
if len(responses) >= _MAX_RESPONSES_PER_COMMAND:
@@ -303,10 +305,27 @@ async def handle_command(
count = min(count_override or config.default_count or 5, 20)
response_mode = config.response_mode or "media"
# Resolve the album scope for this (provider, bot, chat) triple.
# - Explicit ``listener.allowed_album_ids`` override wins as-is.
# - Otherwise derive from notification routing: only albums that
# already deliver notifications to this chat are queryable from
# it. Prevents commands leaking the full album catalog into
# chats that were never set up to receive from those trackers.
if listener is not None and listener.allowed_album_ids is not None:
allowed_album_ids: set[str] = set(listener.allowed_album_ids)
else:
allowed_album_ids = await resolve_chat_album_scope(
provider_id=provider.id,
bot_id=bot.id,
chat_id=chat_id,
)
result = await handler.handle(
cmd, args, count, locale, response_mode,
provider, tracker_templates, bot, tracker, config,
listener=listener, page=page,
listener=listener,
allowed_album_ids=allowed_album_ids,
page=page,
)
if result is not None:
responses.append(result)
@@ -348,12 +367,23 @@ async def send_reply(
bot_token: str, chat_id: str, text: str, reply_to_message_id: int | None = None,
session: aiohttp.ClientSession | None = None,
) -> None:
"""Send a text reply via TelegramClient."""
"""Send a text reply via TelegramClient.
Command responses are listings (albums, people, events, ...) that embed
multiple links; Telegram's default behavior of rendering a preview of
the first URL is almost never what the user wants and clashes with the
"Disable link previews" toggle operators set on their Telegram target.
We always pass ``disable_web_page_preview=True`` here.
"""
if session is None:
from ..services.http_session import get_http_session
session = await get_http_session()
client = TelegramClient(session, bot_token)
result = await client.send_message(chat_id, text, reply_to_message_id=reply_to_message_id)
result = await client.send_message(
chat_id, text,
reply_to_message_id=reply_to_message_id,
disable_web_page_preview=True,
)
if not result.get("success"):
_LOGGER.warning("Telegram reply failed: %s", result.get("error"))
@@ -17,7 +17,10 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_albums(
provider: ServiceProvider, locale: str,
provider: ServiceProvider,
locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
if not trackers:
@@ -31,6 +34,13 @@ async def _cmd_albums(
if aid not in seen:
seen.add(aid)
album_ids.append(aid)
# Intersect with the dispatcher-resolved scope (listener override, else
# derived from notification routing for this chat). Without this,
# /albums leaks the full tracked-album list into chats never wired up.
if allowed_album_ids is not None:
album_ids = [aid for aid in album_ids if aid in allowed_album_ids]
if not album_ids:
return {"albums": []}
@@ -27,6 +27,8 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_events(
provider: ServiceProvider,
count: int, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
tracker_ids = [t.id for t in trackers]
@@ -35,12 +37,14 @@ async def _cmd_events(
engine = get_engine()
async with AsyncSession(engine) as session:
result = await session.exec(
query = (
select(EventLog)
.where(EventLog.tracker_id.in_(tracker_ids))
.order_by(EventLog.created_at.desc())
.limit(count)
)
if allowed_album_ids is not None:
query = query.where(EventLog.collection_id.in_(list(allowed_album_ids)))
result = await session.exec(query.limit(count))
events = result.all()
events_data = [
@@ -25,17 +25,31 @@ _LOGGER = logging.getLogger(__name__)
async def _cmd_status(
provider: ServiceProvider, locale: str,
*,
allowed_album_ids: set[str] | None = None,
) -> dict[str, Any]:
trackers = await get_trackers_for_provider(provider.id)
active = sum(1 for t in trackers if t.enabled)
total = len(trackers)
total_albums = sum(len(t.collection_ids or []) for t in trackers)
# Count only albums visible to this chat. Without the scope filter,
# /status in a restricted chat leaks the full album count across the
# provider. ``None`` = no filter; empty set = show nothing.
total_albums = 0
for t in trackers:
for aid in (t.collection_ids or []):
if allowed_album_ids is None or aid in allowed_album_ids:
total_albums += 1
# Last-event timestamp is already scoped — see get_last_event_str, which
# filters EventLog by collection_id against allowed_album_ids.
tracker_ids = [t.id for t in trackers]
last_str = await get_last_event_str(tracker_ids)
last_str = await get_last_event_str(
tracker_ids, allowed_album_ids=allowed_album_ids,
)
# Tracker counts (``trackers_active`` / ``trackers_total``) are a
# per-provider aggregate — they'd leak info about trackers this chat
# has no visibility into once we've scoped everything else. Omitted.
return {
"trackers_active": active, "trackers_total": total,
"total_albums": total_albums, "last_event": last_str,
}
@@ -80,16 +94,17 @@ class ImmichCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
if cmd == "status":
ctx = await _cmd_status(provider, locale)
ctx = await _cmd_status(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "status", locale, ctx))
if cmd == "albums":
ctx = await _cmd_albums(provider, locale)
ctx = await _cmd_albums(provider, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "albums", locale, ctx))
if cmd == "events":
ctx = await _cmd_events(provider, count, locale)
ctx = await _cmd_events(provider, count, locale, allowed_album_ids=allowed_album_ids)
return CommandResponse(text=_render_cmd_template(cmd_templates, "events", locale, ctx))
if cmd == "people":
ctx = await _cmd_people(provider, locale)
@@ -99,7 +114,7 @@ class ImmichCommandHandler(ProviderCommandHandler):
return await _cmd_immich(
cmd, args, count, locale, response_mode,
provider, cmd_templates,
listener=listener, page=page,
allowed_album_ids=allowed_album_ids, page=page,
)
return None
@@ -109,7 +124,7 @@ async def _cmd_immich(
response_mode: str, provider: ServiceProvider,
cmd_templates: dict[str, dict[str, str]],
*,
listener: CommandTrackerListener | None = None,
allowed_album_ids: set[str] | None = None,
page: int = 1,
) -> CommandResponse | None:
"""Handle commands that need Immich API access and may return media."""
@@ -123,10 +138,12 @@ async def _cmd_immich(
seen.add(aid)
all_album_ids.append(aid)
# Per-chat album scope: intersect with listener.allowed_album_ids when set.
if listener is not None and listener.allowed_album_ids is not None:
allowed = set(listener.allowed_album_ids)
all_album_ids = [aid for aid in all_album_ids if aid in allowed]
# Intersect with the scope resolved by the dispatcher (from the listener
# override if set, otherwise from the notification-routing graph for this
# chat). ``None`` = no filter (rare); empty set = show nothing (common
# when the chat has no tracker routing).
if allowed_album_ids is not None:
all_album_ids = [aid for aid in all_album_ids if aid in allowed_album_ids]
ext_domain = (provider.config.get("external_domain") or provider.config.get("url", "")).rstrip("/")
@@ -31,7 +31,7 @@ async def cmd_search(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "search", "query": ""})
assets = await client.search_smart(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "search", args, locale, response_mode, client, cmd_templates)
@@ -46,7 +46,7 @@ async def cmd_find(
if not args:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "find", "query": ""})
assets = await client.search_metadata(args, album_ids=all_album_ids, limit=count, page=page)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "find", args, locale, response_mode, client, cmd_templates)
@@ -68,7 +68,7 @@ async def cmd_person(
if not person_id:
return _render_cmd_template(cmd_templates, "no_results", locale, {"command": "person", "query": args})
assets = await client.search_by_person(person_id, limit=count)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "person", args, locale, response_mode, client, cmd_templates)
@@ -84,5 +84,5 @@ async def cmd_place(
assets = await client.search_smart(
f"photos taken in {args}", album_ids=all_album_ids, limit=count
)
_enrich_assets(assets, asset_public_urls or {})
assets = _enrich_assets(assets, asset_public_urls or {})
return _format_assets(assets, "place", args, locale, response_mode, client, cmd_templates)
@@ -54,6 +54,7 @@ class NutCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (NUT has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -71,6 +71,7 @@ class PlankaCommandHandler(ProviderCommandHandler):
config: CommandConfig,
*,
listener: Any = None,
allowed_album_ids: set[str] | None = None, # noqa: ARG002 — unused (Planka has no album model)
page: int = 1,
) -> CommandResponse | None:
fn = _TEXT_COMMANDS.get(cmd)
@@ -92,6 +92,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
await conn.execute(text(sql))
logger.info("Added %s column to event_log table", col)
# Explicit indexes on the dashboard-query columns. SQLModel's
# ``index=True`` is emitted by ``create_all`` on *new* installs,
# but ALTER TABLE ADD COLUMN doesn't create them on upgrades —
# so the first boot after upgrade would leave these unindexed
# and status.py ``WHERE user_id=...`` would table-scan. The
# indexes are redundant-but-safe once create_all also runs.
for idx_name, col in [
("ix_event_log_user_id", "user_id"),
("ix_event_log_action_id", "action_id"),
("ix_event_log_provider_id", "provider_id"),
]:
await conn.execute(
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON event_log ({col})")
)
# Backfill user_id from notification_tracker for legacy rows.
# Safe to run repeatedly: only touches rows where user_id is still NULL.
await conn.execute(text("""
@@ -250,6 +265,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
)
logger.info("Added track_webhook_received column to tracking_config table")
# Add quiet hours to tracking_config if missing.
# Start/end are nullable HH:MM strings; quiet_hours_enabled gates them.
if await _has_table(conn, "tracking_config"):
if not await _has_column(conn, "tracking_config", "quiet_hours_enabled"):
await conn.execute(
text("ALTER TABLE tracking_config ADD COLUMN quiet_hours_enabled INTEGER DEFAULT 0")
)
logger.info("Added quiet_hours_enabled column to tracking_config table")
for col_name in ("quiet_hours_start", "quiet_hours_end"):
if not await _has_column(conn, "tracking_config", col_name):
await conn.execute(
text(f"ALTER TABLE tracking_config ADD COLUMN {col_name} TEXT")
)
logger.info("Added %s column to tracking_config table", col_name)
# Drop legacy template content columns from template_config
# (template content moved to template_slot child rows)
if await _has_table(conn, "template_config"):
@@ -204,6 +204,13 @@ class TrackingConfig(SQLModel, table=True):
memory_asset_type: str = Field(default="all")
memory_min_rating: int = Field(default=0)
# Quiet hours — HH:MM strings interpreted in the app-level timezone
# (AppSetting "timezone"). Gated by quiet_hours_enabled so an empty window
# still represents "explicitly disabled" vs "not yet configured".
quiet_hours_enabled: bool = Field(default=False)
quiet_hours_start: str | None = Field(default=None)
quiet_hours_end: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_utcnow)
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
from datetime import datetime, time, timezone
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -29,12 +30,32 @@ from ..database.models import (
_LOGGER = logging.getLogger(__name__)
def in_quiet_hours(start: str | None, end: str | None) -> bool:
"""Check if the current UTC time is within the quiet hours window."""
def _resolve_zoneinfo(tz_name: str | None) -> ZoneInfo:
"""Resolve an IANA tz string to a ZoneInfo, falling back to UTC on any error."""
if not tz_name:
return ZoneInfo("UTC")
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
_LOGGER.warning("Unknown timezone %r; falling back to UTC", tz_name)
return ZoneInfo("UTC")
def in_quiet_hours(
start: str | None,
end: str | None,
tz_name: str | None = "UTC",
) -> bool:
"""Check if the current time (in the given timezone) is within the quiet window.
HH:MM strings are interpreted in the supplied timezone. If either bound is
missing, quiet hours are disabled.
"""
if not start or not end:
return False
try:
now = datetime.now(timezone.utc).time()
tz = _resolve_zoneinfo(tz_name)
now = datetime.now(timezone.utc).astimezone(tz).time()
t_start = time.fromisoformat(start)
t_end = time.fromisoformat(end)
if t_start <= t_end:
@@ -46,8 +67,25 @@ def in_quiet_hours(start: str | None, end: str | None) -> bool:
return False
def event_allowed_by_config(event: ServiceEvent, tc: TrackingConfig) -> bool:
"""Check if an event type is allowed by the tracking config's flags."""
async def get_app_timezone(session: AsyncSession) -> str:
"""Load the app-level timezone from AppSetting (falls back to UTC)."""
from ..api.app_settings import get_setting
value = await get_setting(session, "timezone")
return value or "UTC"
def event_allowed_by_config(
event: ServiceEvent,
tc: TrackingConfig,
tz_name: str | None = "UTC",
) -> bool:
"""Check if an event is allowed by the tracking config's flags + quiet hours."""
# Quiet hours gate every event type when enabled.
if tc.quiet_hours_enabled and in_quiet_hours(
tc.quiet_hours_start, tc.quiet_hours_end, tz_name
):
return False
event_type = event.event_type.value
flag_map = {
# Immich events
@@ -1,5 +1,6 @@
"""Notification sender — unified send logic for all paths (dispatch + test)."""
import asyncio
import logging
from typing import Any
@@ -11,6 +12,10 @@ from ..database.models import NotificationTarget, TargetReceiver
_LOGGER = logging.getLogger(__name__)
# Cap on concurrent per-receiver test sends. Keeps us under Telegram's per-bot
# rate limit (~30 msg/s) while still saving ~N×RTT on multi-chat broadcasts.
_TEST_SEND_CONCURRENCY = 5
_TEST_MESSAGES: dict[str, dict[str, str]] = {
"en": {
"telegram": "\u2705 Test message from <b>Notify Bridge</b>",
@@ -358,19 +363,29 @@ async def _send_telegram_test_per_receiver(
http = await get_http_session()
client = TelegramClient(http, bot_token)
results: list[dict] = []
for r in recv_rows:
# Parallelize per-receiver sends with a small semaphore — broadcast to
# N chats now takes ~ceil(N / concurrency) × RTT instead of N × RTT,
# matching the dispatcher's bounded-concurrency pattern. Capped below
# Telegram's rate limit so we don't trigger 429s on large fleets.
sem = asyncio.Semaphore(_TEST_SEND_CONCURRENCY)
async def _send_one(r: TargetReceiver) -> dict | None:
chat_id = str(r.config.get("chat_id", ""))
if not chat_id:
continue
return None
explicit = getattr(r, "locale", "") or ""
locale = explicit or chat_locale_map.get(chat_id) or default_locale
message = _get_test_message(locale[:2].lower(), "telegram")
results.append(await client.send_message(
chat_id=chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
))
async with sem:
return await client.send_message(
chat_id=chat_id,
text=message,
disable_web_page_preview=bool(disable_preview),
)
raw = await asyncio.gather(*(_send_one(r) for r in recv_rows))
results = [r for r in raw if r is not None]
return _aggregate(results)
@@ -10,10 +10,19 @@ If the apply fails, the pending file is kept so the operator can inspect it
and markers are updated to record the last error. On success, the staged file
is archived under data/applied_restores/<timestamp>.json and markers are
cleared.
Integrity checks on startup:
- The on-disk file's SHA256 must match ``PENDING_RESTORE_SHA256_KEY``
(written atomically with the staged file). Protects against tampering
between prepare and restart.
- The pending path must resolve *inside* ``app_config.data_dir``. Protects
against a rogue AppSetting pointing at an arbitrary file.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import shutil
@@ -24,11 +33,13 @@ from sqlmodel.ext.asyncio.session import AsyncSession
from ..api.backup import (
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_PATH_KEY,
PENDING_RESTORE_SHA256_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
_applied_restores_dir,
_pending_restore_path,
)
from ..config import settings as app_config
from ..database.engine import get_engine
from ..database.models import AppSetting
from .backup_schema import BackupFile, ConflictMode
@@ -49,6 +60,23 @@ async def apply_pending_restore_if_any() -> None:
return
pending_path = _pending_restore_path()
# Defensive: ensure the hard-coded path still lives inside data_dir.
# If future refactors let this be read from AppSetting, this check
# blocks arbitrary-file reads.
try:
resolved = pending_path.resolve()
data_root = app_config.data_dir.resolve()
resolved.relative_to(data_root)
except (ValueError, OSError):
_LOGGER.error(
"Pending-restore path %s is outside data_dir %s — refusing to apply",
pending_path, app_config.data_dir,
)
await _record_error(session, "Pending path outside data_dir")
await session.commit()
return
if not pending_path.exists():
_LOGGER.warning(
"Pending-restore marker present but file missing at %s — clearing marker",
@@ -62,9 +90,42 @@ async def apply_pending_restore_if_any() -> None:
conflict_mode = ConflictMode(conflict_row.value) if conflict_row and conflict_row.value else ConflictMode.SKIP
uploaded_by_row = await session.get(AppSetting, PENDING_RESTORE_UPLOADED_BY_KEY)
uploaded_by = uploaded_by_row.value if uploaded_by_row else "admin"
sha_row = await session.get(AppSetting, PENDING_RESTORE_SHA256_KEY)
expected_sha = (sha_row.value or "").strip().lower() if sha_row else ""
try:
raw = json.loads(pending_path.read_text(encoding="utf-8"))
raw_bytes = await asyncio.to_thread(pending_path.read_bytes)
except OSError as err:
_LOGGER.exception("Pending-restore file unreadable")
await _record_error(session, f"Unreadable backup: {err}")
await session.commit()
return
# Integrity: reject unless hash matches what prepare-restore stored.
# An attacker with write access to data/ (swapped file, bind-mount
# abuse) does not also have write access to the DB.
if not expected_sha:
_LOGGER.error("Pending-restore marker has no SHA256; refusing to apply")
await _record_error(session, "Missing integrity marker")
await session.commit()
return
actual_sha = hashlib.sha256(raw_bytes).hexdigest()
if actual_sha != expected_sha:
_LOGGER.error(
"Pending-restore SHA256 mismatch (expected %s, got %s) — refusing to apply",
expected_sha, actual_sha,
)
await _record_error(
session,
"Integrity check failed: on-disk backup SHA256 does not match the hash "
"recorded at prepare time. File may have been tampered with; cancel and "
"re-upload.",
)
await session.commit()
return
try:
raw = json.loads(raw_bytes.decode("utf-8"))
backup = BackupFile.model_validate(raw)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore file unreadable")
@@ -88,8 +149,14 @@ async def apply_pending_restore_if_any() -> None:
result = await import_backup(session, admin_row.id, backup, conflict_mode)
except Exception as err: # noqa: BLE001
_LOGGER.exception("Pending-restore apply failed")
await _record_error(session, str(err))
await session.commit()
# Discard any partial inserts the importer made before raising —
# committing partial state would let a crafted failing backup
# selectively mutate entities. The error-record commit below
# happens on a *fresh* session.
await session.rollback()
async with AsyncSession(engine) as fresh:
await _record_error(fresh, str(err))
await fresh.commit()
return
# Archive the file
@@ -136,6 +203,7 @@ async def _clear_markers(session: AsyncSession) -> None:
PENDING_RESTORE_CONFLICT_KEY,
PENDING_RESTORE_UPLOADED_AT_KEY,
PENDING_RESTORE_UPLOADED_BY_KEY,
PENDING_RESTORE_SHA256_KEY,
):
row = await session.get(AppSetting, key)
if row:
@@ -166,30 +166,41 @@ async def _refresh_telegram_chat_titles() -> None:
refreshed = 0
errors = 0
# Bucket results first, then fetch all rows in one IN-query instead of
# per-row ``session.get`` — otherwise a 50-chat fleet issues 50 extra
# SELECTs before commit.
successes: dict[int, dict] = {}
for chat_id, info, err in results:
if err is not None or info is None:
errors += 1
if err:
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
continue
if chat_id is not None:
successes[chat_id] = info
async with AsyncSession(engine) as session:
for chat_id, info, err in results:
if err is not None or info is None:
errors += 1
if err:
_LOGGER.debug("getChat failed for chat row %s: %s", chat_id, err)
continue
merged = await session.get(TelegramChat, chat_id)
if not merged:
continue
title = info.get("title") or (
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
)
changed = False
if title and merged.title != title:
merged.title = title
changed = True
new_username = info.get("username")
if new_username is not None and merged.username != new_username:
merged.username = new_username
changed = True
if changed:
session.add(merged)
refreshed += 1
if successes:
rows = (await session.exec(
select(TelegramChat).where(TelegramChat.id.in_(list(successes.keys())))
)).all()
for merged in rows:
info = successes.get(merged.id)
if not info:
continue
title = info.get("title") or (
(info.get("first_name", "") + " " + info.get("last_name", "")).strip()
)
changed = False
if title and merged.title != title:
merged.title = title
changed = True
new_username = info.get("username")
if new_username is not None and merged.username != new_username:
merged.username = new_username
changed = True
if changed:
session.add(merged)
refreshed += 1
await session.commit()
_LOGGER.info(
"Telegram chat title refresh: %s updated, %s errors", refreshed, errors
@@ -250,14 +250,23 @@ async def _build_immich_event(
collection_ids, limit, asset_type, favorite_only, min_rating,
)
# Album-based path: use shared collect_scheduled_assets
# Album-based path: use shared collect_scheduled_assets.
# Fetch albums + shared links in parallel — on a 20-album tracker the old
# serial ``await`` loop took ~2 × 20 × RTT, now it's one round-trip.
import asyncio as _asyncio
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
album_results, link_results = await _asyncio.gather(
_asyncio.gather(*album_tasks, return_exceptions=True),
_asyncio.gather(*link_tasks, return_exceptions=True),
)
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if album:
albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id)
for album_id, album, links in zip(collection_ids, album_results, link_results):
if isinstance(album, Exception) or album is None:
continue
albums[album_id] = album
shared_links[album_id] = links if not isinstance(links, Exception) else []
assets, collections_extra = collect_scheduled_assets(
albums, shared_links, ext_domain,
@@ -320,13 +329,21 @@ async def _build_immich_periodic_event(
ext_domain = provider_config.get("external_domain") or provider_config.get("url", "")
# Parallel fetch — see _build_immich_event above for the same rationale.
import asyncio as _asyncio
album_tasks = [immich.client.get_album(aid) for aid in collection_ids]
link_tasks = [immich.client.get_shared_links(aid) for aid in collection_ids]
album_results, link_results = await _asyncio.gather(
_asyncio.gather(*album_tasks, return_exceptions=True),
_asyncio.gather(*link_tasks, return_exceptions=True),
)
albums: dict[str, ImmichAlbumData] = {}
shared_links: dict[str, list[SharedLinkInfo]] = {}
for album_id in collection_ids:
album = await immich.client.get_album(album_id)
if album:
albums[album_id] = album
shared_links[album_id] = await immich.client.get_shared_links(album_id)
for album_id, album, links in zip(collection_ids, album_results, link_results):
if isinstance(album, Exception) or album is None:
continue
albums[album_id] = album
shared_links[album_id] = links if not isinstance(links, Exception) else []
# limit=0 → returns ([], collections_extra) with full per-album stats.
_assets, collections_extra = collect_scheduled_assets(
@@ -21,7 +21,11 @@ from ..database.models import (
NotificationTrackerState,
ServiceProvider,
)
from .dispatch_helpers import event_allowed_by_config, load_link_data
from .dispatch_helpers import (
event_allowed_by_config,
get_app_timezone,
load_link_data,
)
_LOGGER = logging.getLogger(__name__)
@@ -31,8 +35,34 @@ _asset_cache: TelegramFileCache | None = None
_cache_lock = asyncio.Lock()
async def _load_cache_settings() -> tuple[int, int]:
"""Return (url_ttl_seconds, asset_max_entries) from app settings.
Defaults apply when the settings rows are missing. Reads in a short-lived
session to avoid coupling to the caller's transaction.
"""
from ..api.app_settings import get_setting
async with AsyncSession(get_engine()) as session:
ttl_hours_str = await get_setting(session, "telegram_cache_ttl_hours")
max_entries_str = await get_setting(session, "telegram_asset_cache_max_entries")
try:
ttl_hours = int(ttl_hours_str) if ttl_hours_str else 720
except ValueError:
ttl_hours = 720
try:
max_entries = int(max_entries_str) if max_entries_str else 5000
except ValueError:
max_entries = 5000
return ttl_hours * 3600, max_entries
async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFileCache | None]:
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR."""
"""Lazily initialize shared Telegram file caches using NOTIFY_BRIDGE_DATA_DIR.
The URL cache runs in TTL mode (URLs aren't content-addressable); the asset
cache runs in thumbhash mode so entries invalidate on visual change rather
than age. Both honor an LRU size cap from settings.
"""
global _url_cache, _asset_cache
if _url_cache is not None:
return _url_cache, _asset_cache
@@ -46,16 +76,91 @@ async def _get_telegram_caches() -> tuple[TelegramFileCache | None, TelegramFile
if not data_dir:
return None, None
cache_dir = Path(data_dir) / "cache"
url_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_url_cache.json"))
asset_cache = TelegramFileCache(JsonFileBackend(cache_dir / "telegram_asset_cache.json"))
ttl_seconds, max_entries = await _load_cache_settings()
url_cache = TelegramFileCache(
JsonFileBackend(cache_dir / "telegram_url_cache.json"),
ttl_seconds=ttl_seconds,
max_entries=max_entries,
)
asset_cache = TelegramFileCache(
JsonFileBackend(cache_dir / "telegram_asset_cache.json"),
use_thumbhash=True,
max_entries=max_entries,
)
await url_cache.async_load()
await asset_cache.async_load()
_url_cache = url_cache
_asset_cache = asset_cache
_LOGGER.info("Initialized Telegram file caches in %s", cache_dir)
_LOGGER.info(
"Initialized Telegram caches in %s (url ttl=%ds, max_entries=%d, asset thumbhash mode)",
cache_dir, ttl_seconds, max_entries,
)
return _url_cache, _asset_cache
async def reset_telegram_caches_in_memory() -> None:
"""Drop in-memory cache refs without touching files on disk.
Used after settings changes so the next dispatch re-initializes caches
with fresh parameters. Contrast with ``clear_telegram_caches`` which also
deletes cached file_ids.
"""
global _url_cache, _asset_cache
async with _cache_lock:
_url_cache = None
_asset_cache = None
_LOGGER.info("Reset Telegram cache refs in memory (files preserved)")
async def get_telegram_cache_stats() -> dict[str, Any]:
"""Return stats for the URL and asset Telegram caches.
Loads caches lazily if they haven't been touched by a dispatch yet.
Returns zero-counts when ``NOTIFY_BRIDGE_DATA_DIR`` is not configured.
"""
url_cache, asset_cache = await _get_telegram_caches()
empty = {"count": 0, "total_size_bytes": 0, "oldest": None, "newest": None}
return {
"url": url_cache.stats() if url_cache else empty,
"asset": asset_cache.stats() if asset_cache else empty,
}
async def clear_telegram_caches() -> dict[str, Any]:
"""Delete both Telegram file caches from disk and reset in-memory state.
Next dispatch re-initializes the caches via `_get_telegram_caches()`.
Returns a summary with the paths that were removed.
"""
global _url_cache, _asset_cache
async with _cache_lock:
removed: list[str] = []
for cache, label in ((_url_cache, "url"), (_asset_cache, "asset")):
if cache is not None:
await cache.async_remove()
removed.append(label)
# Also remove files from disk in case caches were never initialized
# in this process (data_dir set but dispatch never ran).
import os
from pathlib import Path
data_dir = os.environ.get("NOTIFY_BRIDGE_DATA_DIR")
if data_dir:
cache_dir = Path(data_dir) / "cache"
for name in ("telegram_url_cache.json", "telegram_asset_cache.json"):
path = cache_dir / name
if path.exists():
try:
path.unlink()
except OSError as e:
_LOGGER.warning("Failed to remove %s: %s", path, e)
_url_cache = None
_asset_cache = None
_LOGGER.info("Cleared Telegram file caches: %s", removed or "none in memory")
return {"cleared": True, "removed": removed}
async def check_tracker(tracker_id: int) -> dict[str, Any]:
"""Poll a tracker's provider for changes and dispatch notifications."""
engine = get_engine()
@@ -85,7 +190,10 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
}
# Load tracker-target links
link_data = await load_link_data(session, tracker_id, check_quiet_hours=True)
link_data = await load_link_data(session, tracker_id)
# Load app-level timezone for quiet-hours evaluation.
app_tz = await get_app_timezone(session)
# Snapshot the data we need
provider_type = provider.type
@@ -236,7 +344,7 @@ async def check_tracker(tracker_id: int) -> dict[str, Any]:
for ld in link_data:
# Apply per-link event filtering from tracking config
tc = ld["tracking_config"]
if tc and not event_allowed_by_config(event, tc):
if tc and not event_allowed_by_config(event, tc, app_tz):
_LOGGER.info(" Skipped by tracking config filter")
continue
+3
View File
@@ -25,6 +25,9 @@ fi
# Start backend
export NOTIFY_BRIDGE_DATA_DIR=./test-data
export NOTIFY_BRIDGE_SECRET_KEY=test-secret-key-minimum-32-chars
# Dev targets (homelab Immich / Gitea / etc.) live on RFC1918 ranges; the SSRF
# guard rejects private addresses by default, which would make trackers fail.
export NOTIFY_BRIDGE_ALLOW_PRIVATE_URLS=1
nohup "$PYTHON" -m uvicorn notify_bridge_server.main:app \
--host 0.0.0.0 --port 8420 > .backend.log 2>&1 &