mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-30 20:21:09 +08:00
feat: refactor API calls to use fetchApi for CSRF token handling in Unsplash and Settings pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
'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', {
|
||||
return fetchApi<{ url: string; filename: string }>('/api/background/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': file.type,
|
||||
'X-Filename': encodeURIComponent(file.name),
|
||||
},
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,29 +1307,19 @@ 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();
|
||||
if (data.primaryModel || data.secondaryModel || data.availableModels?.length) {
|
||||
const updates: Partial<{ primaryModel: string; secondaryModel: string; availableModels: string[] }> = {};
|
||||
if (data.primaryModel) updates.primaryModel = data.primaryModel;
|
||||
if (data.secondaryModel) updates.secondaryModel = data.secondaryModel;
|
||||
if (data.availableModels?.length) updates.availableModels = data.availableModels;
|
||||
updateCliTool(toolId, updates);
|
||||
toast.success(`Models loaded from settings: ${data.primaryModel || 'default'}`, {
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
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;
|
||||
if (data.secondaryModel) updates.secondaryModel = data.secondaryModel;
|
||||
if (data.availableModels?.length) updates.availableModels = data.availableModels;
|
||||
updateCliTool(toolId, updates);
|
||||
toast.success(`Models loaded from settings: ${data.primaryModel || 'default'}`, {
|
||||
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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user