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
This commit is contained in:
2026-03-25 22:07:03 +03:00
parent 337276113d
commit 307871cae5
73 changed files with 1154 additions and 144 deletions
+34 -23
View File
@@ -55,9 +55,11 @@ async function doRefreshAccessToken(): Promise<boolean> {
return false;
}
const DEFAULT_TIMEOUT_MS = 30_000;
export async function api<T = any>(
path: string,
options: RequestInit = {}
options: RequestInit & { timeoutMs?: number } = {}
): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
@@ -68,31 +70,40 @@ export async function api<T = any>(
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();
}
@@ -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}
</div>
{#each group.items as item, i}
{@const flatIdx = flatResults.indexOf(item)}
{@const flatIdx = flatIndexMap.get(item) ?? -1}
<button
class="sp-item"
class:sp-active={flatIdx === activeIndex}