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
|
// 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
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user