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:
+34
-23
@@ -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}
|
||||
|
||||
@@ -58,6 +58,12 @@
|
||||
let slotErrorLines = $state<Record<string, number | null>>({});
|
||||
let slotErrorTypes = $state<Record<string, string>>({});
|
||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
/** Clean up validate timers on unmount */
|
||||
onMount(() => {
|
||||
return () => {
|
||||
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
|
||||
};
|
||||
});
|
||||
let varsRef = $state<Record<string, any>>({});
|
||||
let showVarsFor = $state<string | null>(null);
|
||||
let activeLocale = $state<string>('en');
|
||||
@@ -110,10 +116,12 @@
|
||||
return form.slots[slotName]?.[activeLocale] || '';
|
||||
}
|
||||
|
||||
/** Set slot template for current locale. */
|
||||
/** Set slot template for current locale (immutable update). */
|
||||
function setSlotValue(slotName: string, value: string) {
|
||||
if (!form.slots[slotName]) form.slots[slotName] = {};
|
||||
form.slots[slotName][activeLocale] = value;
|
||||
form.slots = {
|
||||
...form.slots,
|
||||
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
|
||||
};
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
@@ -425,9 +433,9 @@
|
||||
<p class="font-medium">{config.name}</p>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
|
||||
{#if config.user_id === 0}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">System</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
|
||||
{/if}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} slots</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
|
||||
</div>
|
||||
{#if config.description}
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
|
||||
|
||||
@@ -56,8 +56,8 @@
|
||||
} catch (err: any) {
|
||||
loadError = err.message || t('providers.loadError');
|
||||
} finally { loaded = true; highlightFromUrl(); }
|
||||
// Ping all providers in background
|
||||
for (const p of providers) {
|
||||
// Ping all providers in background (use unfiltered list)
|
||||
for (const p of allProviders) {
|
||||
health = { ...health, [p.id]: null };
|
||||
api(`/providers/${p.id}/test`, { method: 'POST' })
|
||||
.then((r: any) => { health = { ...health, [p.id]: r.ok }; })
|
||||
|
||||
Reference in New Issue
Block a user