From 307871cae585a734123673b9225aa6a6a46a6027 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 25 Mar 2026 22:07:03 +0300 Subject: [PATCH] feat: Google Photos provider backend + API hardening - Add Google Photos provider: client, models, change detector, capabilities - Add notification templates (en/ru) for all GP event slots - Add command templates (en/ru) for GP bot commands - Register GP in slot/command loaders, capabilities, and seeds - Harden provider API: validate OAuth credentials on create/update - Add internal URL rewriting for asset fetches (LAN optimization) - Fix template renderer to handle missing variables gracefully - Improve webhook command routing for multi-provider support - Add provider health check endpoint and watcher improvements --- frontend/src/lib/api.ts | 57 ++-- .../src/lib/components/SearchPalette.svelte | 3 +- .../command-template-configs/+page.svelte | 18 +- frontend/src/routes/providers/+page.svelte | 4 +- .../src/notify_bridge_core/providers/base.py | 1 + .../providers/capabilities.py | 56 ++++ .../providers/google_photos/__init__.py | 17 ++ .../providers/google_photos/client.py | 275 ++++++++++++++++++ .../providers/google_photos/constants.py | 26 ++ .../providers/google_photos/models.py | 108 +++++++ .../providers/google_photos/provider.py | 226 ++++++++++++++ .../en/google_photos/albums.jinja2 | 8 + .../en/google_photos/desc_albums.jinja2 | 1 + .../en/google_photos/desc_help.jinja2 | 1 + .../en/google_photos/desc_latest.jinja2 | 1 + .../en/google_photos/desc_random.jinja2 | 1 + .../en/google_photos/desc_search.jinja2 | 1 + .../en/google_photos/desc_status.jinja2 | 1 + .../en/google_photos/help.jinja2 | 4 + .../en/google_photos/latest.jinja2 | 4 + .../en/google_photos/no_results.jinja2 | 1 + .../en/google_photos/random.jinja2 | 4 + .../en/google_photos/rate_limited.jinja2 | 1 + .../en/google_photos/search.jinja2 | 4 + .../en/google_photos/start.jinja2 | 2 + .../en/google_photos/status.jinja2 | 6 + .../en/google_photos/usage_latest.jinja2 | 1 + .../en/google_photos/usage_random.jinja2 | 1 + .../en/google_photos/usage_search.jinja2 | 1 + .../templates/command_defaults/loader.py | 10 + .../ru/google_photos/albums.jinja2 | 8 + .../ru/google_photos/desc_albums.jinja2 | 1 + .../ru/google_photos/desc_help.jinja2 | 1 + .../ru/google_photos/desc_latest.jinja2 | 1 + .../ru/google_photos/desc_random.jinja2 | 1 + .../ru/google_photos/desc_search.jinja2 | 1 + .../ru/google_photos/desc_status.jinja2 | 1 + .../ru/google_photos/help.jinja2 | 4 + .../ru/google_photos/latest.jinja2 | 4 + .../ru/google_photos/no_results.jinja2 | 1 + .../ru/google_photos/random.jinja2 | 4 + .../ru/google_photos/rate_limited.jinja2 | 1 + .../ru/google_photos/search.jinja2 | 4 + .../ru/google_photos/start.jinja2 | 2 + .../ru/google_photos/status.jinja2 | 6 + .../ru/google_photos/usage_latest.jinja2 | 1 + .../ru/google_photos/usage_random.jinja2 | 1 + .../ru/google_photos/usage_search.jinja2 | 1 + .../defaults/en/gp_assets_added.jinja2 | 12 + .../defaults/en/gp_assets_removed.jinja2 | 1 + .../defaults/en/gp_collection_deleted.jinja2 | 1 + .../defaults/en/gp_collection_renamed.jinja2 | 1 + .../defaults/en/gp_sharing_changed.jinja2 | 1 + .../templates/defaults/loader.py | 7 + .../defaults/ru/gp_assets_added.jinja2 | 12 + .../defaults/ru/gp_assets_removed.jinja2 | 1 + .../defaults/ru/gp_collection_deleted.jinja2 | 1 + .../defaults/ru/gp_collection_renamed.jinja2 | 1 + .../defaults/ru/gp_sharing_changed.jinja2 | 1 + .../notify_bridge_core/templates/renderer.py | 2 +- .../api/command_template_configs.py | 4 +- .../src/notify_bridge_server/api/providers.py | 190 +++++++----- .../src/notify_bridge_server/api/targets.py | 44 +-- .../api/template_configs.py | 4 +- .../notify_bridge_server/api/template_vars.py | 10 +- .../src/notify_bridge_server/api/users.py | 7 +- .../src/notify_bridge_server/api/webhooks.py | 6 +- .../notify_bridge_server/commands/webhook.py | 3 +- .../server/src/notify_bridge_server/config.py | 4 +- .../notify_bridge_server/database/seeds.py | 14 + .../server/src/notify_bridge_server/main.py | 25 +- .../notify_bridge_server/services/__init__.py | 13 + .../notify_bridge_server/services/watcher.py | 46 ++- 73 files changed, 1154 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/notify_bridge_core/providers/google_photos/__init__.py create mode 100644 packages/core/src/notify_bridge_core/providers/google_photos/client.py create mode 100644 packages/core/src/notify_bridge_core/providers/google_photos/constants.py create mode 100644 packages/core/src/notify_bridge_core/providers/google_photos/models.py create mode 100644 packages/core/src/notify_bridge_core/providers/google_photos/provider.py create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/albums.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_albums.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/usage_latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/usage_random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/en/google_photos/usage_search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/albums.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_albums.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/desc_status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/help.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/no_results.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/rate_limited.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/start.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/status.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/usage_latest.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/usage_random.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/command_defaults/ru/google_photos/usage_search.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gp_assets_added.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gp_assets_removed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gp_collection_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gp_collection_renamed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/en/gp_sharing_changed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gp_assets_added.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gp_assets_removed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gp_collection_deleted.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gp_collection_renamed.jinja2 create mode 100644 packages/core/src/notify_bridge_core/templates/defaults/ru/gp_sharing_changed.jinja2 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 833ce2d..14a4267 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -55,9 +55,11 @@ async function doRefreshAccessToken(): Promise { return false; } +const DEFAULT_TIMEOUT_MS = 30_000; + export async function api( path: string, - options: RequestInit = {} + options: RequestInit & { timeoutMs?: number } = {} ): Promise { const token = getToken(); const headers: Record = { @@ -68,31 +70,40 @@ export async function api( headers['Authorization'] = `Bearer ${token}`; } - let res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + const { timeoutMs, ...fetchOptions } = options; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs ?? DEFAULT_TIMEOUT_MS); + const signal = options.signal ?? controller.signal; - // Try token refresh on 401 - if (res.status === 401 && token) { - const refreshed = await refreshAccessToken(); - if (refreshed) { - headers['Authorization'] = `Bearer ${getToken()}`; - res = await fetch(`${API_BASE}${path}`, { ...options, headers }); + try { + let res = await fetch(`${API_BASE}${path}`, { ...fetchOptions, headers, signal }); + + // Try token refresh on 401 + if (res.status === 401 && token) { + const refreshed = await refreshAccessToken(); + if (refreshed) { + headers['Authorization'] = `Bearer ${getToken()}`; + res = await fetch(`${API_BASE}${path}`, { ...fetchOptions, headers, signal }); + } } - } - if (res.status === 401) { - clearTokens(); - if (typeof window !== 'undefined') { - window.location.href = '/login'; + if (res.status === 401) { + clearTokens(); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + throw new Error('Unauthorized'); } - throw new Error('Unauthorized'); + + if (res.status === 204) return undefined as T; + + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + + return res.json(); + } finally { + clearTimeout(timeout); } - - if (res.status === 204) return undefined as T; - - if (!res.ok) { - const err = await res.json().catch(() => ({ detail: res.statusText })); - throw new Error(err.detail || `HTTP ${res.status}`); - } - - return res.json(); } diff --git a/frontend/src/lib/components/SearchPalette.svelte b/frontend/src/lib/components/SearchPalette.svelte index 40ba56f..cf6f348 100644 --- a/frontend/src/lib/components/SearchPalette.svelte +++ b/frontend/src/lib/components/SearchPalette.svelte @@ -136,6 +136,7 @@ }); const flatResults = $derived(results); + const flatIndexMap = $derived(new Map(flatResults.map((item, idx) => [item, idx]))); async function openPalette() { open = true; @@ -239,7 +240,7 @@ {group.label} {#each group.items as item, i} - {@const flatIdx = flatResults.indexOf(item)} + {@const flatIdx = flatIndexMap.get(item) ?? -1}