feat: refactor API calls to use fetchApi for CSRF token handling in Unsplash and Settings pages

This commit is contained in:
catlog22
2026-03-30 14:58:26 +08:00
parent da643e65b1
commit 2ca87087f1
3 changed files with 25 additions and 151 deletions

View File

@@ -4,72 +4,7 @@
// React Query hooks for Skill Hub API // React Query hooks for Skill Hub API
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchApi } from '@/lib/api';
// ============================================================================
// API Helper
// ============================================================================
/**
* Get CSRF token from cookie
*/
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Typed fetch wrapper with CSRF token handling
*/
async function fetchApi<T>(url: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
// Add CSRF token for mutating requests
if (options.method && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method)) {
const csrfToken = getCsrfToken();
if (csrfToken) {
headers.set('X-CSRF-Token', csrfToken);
}
}
// Set content type for JSON requests
if (options.body && typeof options.body === 'string') {
headers.set('Content-Type', 'application/json');
}
const response = await fetch(url, {
...options,
headers,
credentials: 'same-origin',
});
if (!response.ok) {
const error: { message: string; status: number; code?: string } = {
message: response.statusText || 'Request failed',
status: response.status,
};
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
try {
const body = await response.json();
if (body.message) error.message = body.message;
else if (body.error) error.message = body.error;
if (body.code) error.code = body.code;
} catch {
// Silently ignore JSON parse errors
}
}
throw error;
}
// Handle no-content responses
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
// ============================================================================ // ============================================================================
// Types // Types

View File

@@ -21,10 +21,7 @@ export interface UnsplashSearchResult {
totalPages: number; totalPages: number;
} }
function getCsrfToken(): string | null { import { fetchApi } from './api';
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/** /**
* Search Unsplash photos via backend proxy. * Search Unsplash photos via backend proxy.
@@ -40,16 +37,7 @@ export async function searchUnsplash(
per_page: String(perPage), per_page: String(perPage),
}); });
const response = await fetch(`/api/unsplash/search?${params}`, { return fetchApi<UnsplashSearchResult>(`/api/unsplash/search?${params}`);
credentials: 'same-origin',
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Unsplash search failed: ${response.status}`);
}
return response.json();
} }
/** /**
@@ -57,46 +45,22 @@ export async function searchUnsplash(
* Sends raw binary to avoid base64 overhead. * Sends raw binary to avoid base64 overhead.
*/ */
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> { export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
const headers: Record<string, string> = { return fetchApi<{ url: string; filename: string }>('/api/background/upload', {
method: 'POST',
headers: {
'Content-Type': file.type, 'Content-Type': file.type,
'X-Filename': encodeURIComponent(file.name), 'X-Filename': encodeURIComponent(file.name),
}; },
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/background/upload', {
method: 'POST',
headers,
credentials: 'same-origin',
body: file, body: file,
}); });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Upload failed: ${response.status}`);
}
return response.json();
} }
/** /**
* Trigger Unsplash download event (required by API guidelines). * Trigger Unsplash download event (required by API guidelines).
*/ */
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> { export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
const headers: Record<string, string> = { await fetchApi('/api/unsplash/download', {
'Content-Type': 'application/json',
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
await fetch('/api/unsplash/download', {
method: 'POST', method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ downloadLocation }), body: JSON.stringify({ downloadLocation }),
}); });
} }

View File

@@ -59,16 +59,13 @@ import {
useExportSettings, useExportSettings,
useImportSettings, useImportSettings,
} from '@/hooks/useSystemSettings'; } from '@/hooks/useSystemSettings';
import { fetchApi } from '@/lib/api';
import type { ExportedSettings } from '@/lib/api'; import type { ExportedSettings } from '@/lib/api';
import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection'; import { RemoteNotificationSection } from '@/components/settings/RemoteNotificationSection';
import { A2UIPreferencesSection } from '@/components/settings/A2UIPreferencesSection'; import { A2UIPreferencesSection } from '@/components/settings/A2UIPreferencesSection';
import { AgentDefinitionsSection } from '@/components/settings/AgentDefinitionsSection'; import { AgentDefinitionsSection } from '@/components/settings/AgentDefinitionsSection';
// ========== CSRF Token Helper ========== // CSRF tokens are managed by fetchApi from lib/api.ts (token pool pattern)
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
// ========== File Path Input with Native File Picker ========== // ========== File Path Input with Native File Picker ==========
@@ -1310,19 +1307,10 @@ export function SettingsPage() {
// Auto-parse models from settings file // Auto-parse models from settings file
if (settingsFile && SETTINGS_FILE_TOOLS.has(toolId)) { if (settingsFile && SETTINGS_FILE_TOOLS.has(toolId)) {
try { try {
const csrfToken = getCsrfToken(); const data = await fetchApi<{ primaryModel?: string; secondaryModel?: string; availableModels?: string[] }>(
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; '/api/cli/parse-settings',
if (csrfToken) headers['X-CSRF-Token'] = csrfToken; { method: 'POST', body: JSON.stringify({ path: settingsFile }) }
);
const res = await fetch('/api/cli/parse-settings', {
method: 'POST',
headers,
body: JSON.stringify({ path: settingsFile }),
credentials: 'same-origin',
});
if (res.ok) {
const data = await res.json();
if (data.primaryModel || data.secondaryModel || data.availableModels?.length) { if (data.primaryModel || data.secondaryModel || data.availableModels?.length) {
const updates: Partial<{ primaryModel: string; secondaryModel: string; availableModels: string[] }> = {}; const updates: Partial<{ primaryModel: string; secondaryModel: string; availableModels: string[] }> = {};
if (data.primaryModel) updates.primaryModel = data.primaryModel; if (data.primaryModel) updates.primaryModel = data.primaryModel;
@@ -1333,7 +1321,6 @@ export function SettingsPage() {
duration: 3000, duration: 3000,
}); });
} }
}
} catch { } catch {
// Silently fail — file parsing is best-effort // Silently fail — file parsing is best-effort
} }
@@ -1368,23 +1355,11 @@ export function SettingsPage() {
body.effort = config.effort || null; body.effort = config.effort || null;
} }
const csrfToken = getCsrfToken(); await fetchApi(`/api/cli/config/${toolId}`, {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const res = await fetch(`/api/cli/config/${toolId}`, {
method: 'PUT', method: 'PUT',
headers,
body: JSON.stringify(body), body: JSON.stringify(body),
credentials: 'same-origin',
}); });
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
toast.success(formatMessage({ id: 'settings.cliTools.configSaved' }), { toast.success(formatMessage({ id: 'settings.cliTools.configSaved' }), {
description: toolId, description: toolId,
}); });