From f129a3fa68c93fad6afe6fe715440ddacba456f2 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Tue, 26 May 2026 17:25:00 +0800 Subject: [PATCH 1/2] fix: route AI config form tests through backend proxy to avoid CORS The "Test Connection" button in the AI config form always failed with "Network connection failed" for openai-compatible endpoints because: 1. handleTestForm() creates a temp config with id: '' 2. requestText() only routes through backend proxy when config.id is truthy 3. Empty string is falsy, so form tests fell through to direct browser fetch 4. Browser CORS policy blocks cross-origin requests to the API endpoint Fix by accepting inline config (without stored ID) in the backend proxy route and routing form tests through it when the backend is available. Also extracted normalizeReasoningEffort() helper, added input validation for inline config, and added HTTPS warning for non-encrypted connections. Co-Authored-By: Claude Opus 4.7 --- server/src/routes/proxy.ts | 66 +++++++++++++++++++++++++--------- src/services/aiService.ts | 24 +++++++++---- src/services/backendAdapter.ts | 12 +++++++ 3 files changed, 79 insertions(+), 23 deletions(-) diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index cb62bcfc..aeb72402 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -85,31 +85,63 @@ router.post('/api/proxy/github/*', async (req, res) => { } }); +function normalizeReasoningEffort(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value === 'minimal' ? 'low' : value; +} + // POST /api/proxy/ai +// Accepts either { configId, body } (lookup from DB) or { config, body } (inline config for one-time requests) router.post('/api/proxy/ai', async (req, res) => { try { const db = getDb(); - const { configId, body: requestBody } = req.body as { configId: string; body: Record }; - - if (!configId) { - res.status(400).json({ error: 'configId required', code: 'CONFIG_ID_REQUIRED' }); - return; - } + const { configId, config: inlineConfig, body: requestBody } = req.body as { + configId?: string; + config?: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string }; + body: Record; + }; - const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(configId) as Record | undefined; - if (!aiConfig) { - res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + let apiKey: string; + let apiType: string; + let baseUrl: string; + let model: string; + let reasoningEffort: string | null; + + if (inlineConfig && !configId) { + // Inline config path (for form tests without a saved config ID) + apiKey = inlineConfig.apiKey; + apiType = inlineConfig.apiType || 'openai'; + baseUrl = inlineConfig.baseUrl; + model = inlineConfig.model; + reasoningEffort = normalizeReasoningEffort(inlineConfig.reasoningEffort); + if (!baseUrl || !apiKey || !model) { + res.status(400).json({ error: 'baseUrl, apiKey, and model are required', code: 'INVALID_REQUEST' }); + return; + } + // Warn if API key is transmitted over non-HTTPS connection + try { + const parsed = new URL(baseUrl); + if (parsed.protocol !== 'https:') { + console.warn(`[Proxy] ⚠️ AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); + } + } catch { /* invalid URL, will be caught by validateUrl later */ } + } else if (configId) { + // DB lookup path (for saved configs) + const aiConfig = db.prepare('SELECT * FROM ai_configs WHERE id = ?').get(configId) as Record | undefined; + if (!aiConfig) { + res.status(404).json({ error: 'AI config not found', code: 'AI_CONFIG_NOT_FOUND' }); + return; + } + apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); + apiType = (aiConfig.api_type as string) || 'openai'; + baseUrl = aiConfig.base_url as string; + model = aiConfig.model as string; + reasoningEffort = normalizeReasoningEffort(aiConfig.reasoning_effort); + } else { + res.status(400).json({ error: 'configId or config required', code: 'CONFIG_ID_REQUIRED' }); return; } - const apiKey = decrypt(aiConfig.api_key_encrypted as string, config.encryptionKey); - const apiType = (aiConfig.api_type as string) || 'openai'; - const baseUrl = aiConfig.base_url as string; - const model = aiConfig.model as string; - const reasoningEffort = aiConfig.reasoning_effort === 'minimal' - ? 'low' - : aiConfig.reasoning_effort as string | null | undefined; - let targetUrl: string; const headers: Record = { 'Content-Type': 'application/json', diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 26345b6f..b797d897 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -153,8 +153,12 @@ export class AIService { }; let data: Record; - if (backend.isAvailable && this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody) as Record; + if (backend.isAvailable) { + if (this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody) as Record; + } else { + data = await backend.proxyAIRequestWithConfig(this.config, requestBody) as Record; + } } else { const url = buildFinalApiUrl(this.config.baseUrl, apiType); const response = await fetch(url, { @@ -211,8 +215,12 @@ export class AIService { }; let data: unknown; - if (backend.isAvailable && this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody); + if (backend.isAvailable) { + if (this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody); + } else { + data = await backend.proxyAIRequestWithConfig(this.config, requestBody); + } } else { const url = buildApiUrl(this.config.baseUrl, 'v1/messages'); const response = await fetch(url, { @@ -267,8 +275,12 @@ ${options.user}` : options.user; }; let data: unknown; - if (backend.isAvailable && this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody); + if (backend.isAvailable) { + if (this.config.id) { + data = await backend.proxyAIRequest(this.config.id, requestBody); + } else { + data = await backend.proxyAIRequestWithConfig(this.config, requestBody); + } } else { const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; const urlObj = new URL(buildApiUrl(this.config.baseUrl, path)); diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index c06079d6..ac056371 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -227,6 +227,18 @@ class BackendAdapter { return res.json(); } + async proxyAIRequestWithConfig(aiConfig: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string }, body: object): Promise { + if (!this._backendUrl) throw new Error('Backend not available'); + + const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/ai`, { + method: 'POST', + headers: this.getAuthHeaders(), + body: JSON.stringify({ config: aiConfig, body }) + }, 120000); + if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error'); + return res.json(); + } + // === WebDAV Proxy === async proxyWebDAV(configId: string, method: string, path: string, body?: string, headers?: Record): Promise { From 4d42c9ced07084e691cf59b19fa026e182a9b155 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Tue, 26 May 2026 17:40:25 +0800 Subject: [PATCH 2/2] fix: address CodeRabbit review - add HTTPS warning to DB path & forward AbortSignal - Add HTTPS protocol warning to DB-backed AI config path in proxy.ts, matching the existing warning in the inline config branch - Forward caller AbortSignal through backend proxy methods so testConnection's AbortController can cancel in-flight requests - Pass options.signal to all proxyAIRequest/proxyAIRequestWithConfig calls in aiService.ts (OpenAI, Claude, Gemini branches) Co-Authored-By: Claude Opus 4.7 --- server/src/routes/proxy.ts | 6 ++++++ src/services/aiService.ts | 12 ++++++------ src/services/backendAdapter.ts | 21 +++++++++++++++++---- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index aeb72402..2921b089 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -137,6 +137,12 @@ router.post('/api/proxy/ai', async (req, res) => { baseUrl = aiConfig.base_url as string; model = aiConfig.model as string; reasoningEffort = normalizeReasoningEffort(aiConfig.reasoning_effort); + try { + const parsed = new URL(baseUrl); + if (parsed.protocol !== 'https:') { + console.warn(`[Proxy] ⚠️ AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); + } + } catch { /* invalid URL, will be caught by validateUrl later */ } } else { res.status(400).json({ error: 'configId or config required', code: 'CONFIG_ID_REQUIRED' }); return; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index b797d897..9ce310a7 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -155,9 +155,9 @@ export class AIService { let data: Record; if (backend.isAvailable) { if (this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody) as Record; + data = await backend.proxyAIRequest(this.config.id, requestBody, options.signal) as Record; } else { - data = await backend.proxyAIRequestWithConfig(this.config, requestBody) as Record; + data = await backend.proxyAIRequestWithConfig(this.config, requestBody, options.signal) as Record; } } else { const url = buildFinalApiUrl(this.config.baseUrl, apiType); @@ -217,9 +217,9 @@ export class AIService { let data: unknown; if (backend.isAvailable) { if (this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody); + data = await backend.proxyAIRequest(this.config.id, requestBody, options.signal); } else { - data = await backend.proxyAIRequestWithConfig(this.config, requestBody); + data = await backend.proxyAIRequestWithConfig(this.config, requestBody, options.signal); } } else { const url = buildApiUrl(this.config.baseUrl, 'v1/messages'); @@ -277,9 +277,9 @@ ${options.user}` : options.user; let data: unknown; if (backend.isAvailable) { if (this.config.id) { - data = await backend.proxyAIRequest(this.config.id, requestBody); + data = await backend.proxyAIRequest(this.config.id, requestBody, options.signal); } else { - data = await backend.proxyAIRequestWithConfig(this.config, requestBody); + data = await backend.proxyAIRequestWithConfig(this.config, requestBody, options.signal); } } else { const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index ac056371..5a9829a1 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -70,6 +70,17 @@ class BackendAdapter { private async fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 30000): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + // If the caller provides a signal, forward its abort to our internal controller + const callerSignal = options?.signal; + if (callerSignal) { + if (callerSignal.aborted) { + controller.abort(); + } else { + callerSignal.addEventListener('abort', () => controller.abort(), { once: true }); + } + } + try { return await fetch(url, { ...options, signal: controller.signal }); } finally { @@ -215,25 +226,27 @@ class BackendAdapter { // === AI Proxy === - async proxyAIRequest(configId: string, body: object): Promise { + async proxyAIRequest(configId: string, body: object, signal?: AbortSignal): Promise { if (!this._backendUrl) throw new Error('Backend not available'); const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/ai`, { method: 'POST', headers: this.getAuthHeaders(), - body: JSON.stringify({ configId, body }) + body: JSON.stringify({ configId, body }), + signal, }, 120000); if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error'); return res.json(); } - async proxyAIRequestWithConfig(aiConfig: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string }, body: object): Promise { + async proxyAIRequestWithConfig(aiConfig: { apiType?: string; baseUrl: string; apiKey: string; model: string; reasoningEffort?: string }, body: object, signal?: AbortSignal): Promise { if (!this._backendUrl) throw new Error('Backend not available'); const res = await this.fetchWithTimeout(`${this._backendUrl}/proxy/ai`, { method: 'POST', headers: this.getAuthHeaders(), - body: JSON.stringify({ config: aiConfig, body }) + body: JSON.stringify({ config: aiConfig, body }), + signal, }, 120000); if (!res.ok) await this.throwTranslatedError(res, 'AI proxy error'); return res.json();