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
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// ============================================================================
// 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();
}
import { fetchApi } from '@/lib/api';
// ============================================================================
// Types

View File

@@ -21,10 +21,7 @@ export interface UnsplashSearchResult {
totalPages: number;
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
import { fetchApi } from './api';
/**
* Search Unsplash photos via backend proxy.
@@ -40,16 +37,7 @@ export async function searchUnsplash(
per_page: String(perPage),
});
const response = await fetch(`/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();
return fetchApi<UnsplashSearchResult>(`/api/unsplash/search?${params}`);
}
/**
@@ -57,46 +45,22 @@ export async function searchUnsplash(
* Sends raw binary to avoid base64 overhead.
*/
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,
'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,
});
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).
*/
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
await fetch('/api/unsplash/download', {
await fetchApi('/api/unsplash/download', {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ downloadLocation }),
});
}

View File

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