import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { encrypt, decrypt } from "../crypto"; import { testOllamaConnection } from "../services/llm-provider"; const router = Router(); /** * Validate a base URL for use as an AI provider endpoint. * - Must use http:// or https:// scheme. * - When ALLOW_PRIVATE_RAG_URLS env var is set to "true", private/loopback addresses * are permitted (useful for self-hosted Ollama on the same server). * - Otherwise, private RFC-1918 ranges and loopback are blocked to prevent SSRF. */ function validateBaseUrl(raw: string): { ok: true; url: string } | { ok: false; error: string } { let parsed: URL; try { parsed = new URL(raw.trim()); } catch { return { ok: false, error: "Некорректный URL (неверный формат)" }; } if (!["http:", "https:"].includes(parsed.protocol)) { return { ok: false, error: "URL должен начинаться с http:// или https://" }; } // Normalize: remove trailing slash but preserve any path prefix (important for openai_compatible proxies) const normalizedUrl = parsed.href.replace(/\/+$/, ""); if (process.env.ALLOW_PRIVATE_RAG_URLS === "true") { return { ok: true, url: normalizedUrl }; } const host = parsed.hostname.toLowerCase(); const privatePatterns = [ /^localhost$/, /^127\./, /^0\.0\.0\.0$/, /^::1$/, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, /^fc00:/i, /^fd[0-9a-f]{2}:/i, /^fe80:/i, ]; for (const re of privatePatterns) { if (re.test(host)) { return { ok: false, error: `Частные/локальные адреса запрещены (${host}). Установите переменную среды ALLOW_PRIVATE_RAG_URLS=true для локального Ollama.`, }; } } return { ok: true, url: normalizedUrl }; } function maskApiKey(key: string): string { if (!key || key.length <= 4) return "****"; return `***${key.slice(-4)}`; } router.get( "/api/rag/settings", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const settings = await storage.getRagSettings(req.organizationId!); if (!settings) { return res.json({ success: true, settings: { provider: "openai", apiKey: null, apiKeyMasked: null, baseUrl: null, embeddingModel: "text-embedding-3-small", chatModel: null, maxChunkChars: null, summarizationEnabled: true, embeddingProviderId: null, chatProviderId: null, }, }); } return res.json({ success: true, settings: { provider: settings.provider, apiKey: null, apiKeyMasked: settings.apiKey ? maskApiKey(decrypt(settings.apiKey)) : null, baseUrl: settings.baseUrl, embeddingModel: settings.embeddingModel, chatModel: settings.chatModel ?? null, maxChunkChars: settings.maxChunkChars ?? null, summarizationEnabled: settings.summarizationEnabled ?? true, embeddingProviderId: settings.embeddingProviderId ?? null, chatProviderId: settings.chatProviderId ?? null, }, }); } catch (err) { console.error("[RAG] GET settings error:", err); return res.status(500).json({ success: false, error: "Ошибка получения настроек" }); } } ); router.patch( "/api/rag/settings", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { provider, apiKey, baseUrl, embeddingModel, chatModel, maxChunkChars, summarizationEnabled, embeddingProviderId, chatProviderId } = req.body; const updates: Record = {}; // Fetch existing settings for effective-state validation const existingSettings = await storage.getRagSettings(req.organizationId!); if (provider !== undefined) { const allowed = ["openai", "openai_compatible", "ollama"]; if (!allowed.includes(provider)) { return res.status(400).json({ success: false, error: "Недопустимый провайдер" }); } updates.provider = provider; } if (apiKey !== undefined) { updates.apiKey = apiKey && apiKey.trim() ? encrypt(apiKey.trim()) : null; } if (baseUrl !== undefined) { if (baseUrl && baseUrl.trim()) { const check = validateBaseUrl(baseUrl); if (!check.ok) return res.status(400).json({ success: false, error: check.error }); updates.baseUrl = check.url; } else { updates.baseUrl = null; } } if (embeddingModel !== undefined) { if (!embeddingModel || !embeddingModel.trim()) { // Allowed to be empty when effective embeddingProviderId is set (server resolves to first enabled model) const effectiveEmbProvForEmpty = embeddingProviderId != null ? (embeddingProviderId || null) : (existingSettings?.embeddingProviderId ?? null); if (!effectiveEmbProvForEmpty) { return res.status(400).json({ success: false, error: "Модель эмбеддингов не может быть пустой без провайдера из реестра" }); } updates.embeddingModel = null; } else { updates.embeddingModel = embeddingModel.trim(); } } if (chatModel !== undefined) { updates.chatModel = chatModel && chatModel.trim() ? chatModel.trim() : null; } if (summarizationEnabled !== undefined) { updates.summarizationEnabled = Boolean(summarizationEnabled); } if (maxChunkChars !== undefined) { if (maxChunkChars === null || maxChunkChars === "") { updates.maxChunkChars = null; } else { const n = parseInt(String(maxChunkChars), 10); if (isNaN(n) || n < 100 || n > 100000) { return res.status(400).json({ success: false, error: "maxChunkChars должен быть от 100 до 100000" }); } updates.maxChunkChars = n; } } if (embeddingProviderId !== undefined) { if (embeddingProviderId) { const n = Number(embeddingProviderId); if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный embeddingProviderId" }); updates.embeddingProviderId = n; } else { updates.embeddingProviderId = null; } } if (chatProviderId !== undefined) { if (chatProviderId) { const n = Number(chatProviderId); if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный chatProviderId" }); updates.chatProviderId = n; } else { updates.chatProviderId = null; } } // Validate models against effective provider IDs (existing + incoming) const effectiveEmbProvId = (updates.embeddingProviderId !== undefined ? updates.embeddingProviderId : existingSettings?.embeddingProviderId) as number | null | undefined; // Mirror service resolution: chatProviderId ?? embeddingProviderId (shared "same provider" mode) const rawChatProvId = (updates.chatProviderId !== undefined ? updates.chatProviderId : existingSettings?.chatProviderId) as number | null | undefined; const effectiveChatProvId = (rawChatProvId ?? effectiveEmbProvId) as number | null | undefined; const effectiveEmbModel = (updates.embeddingModel !== undefined ? updates.embeddingModel : existingSettings?.embeddingModel) as string | null | undefined; const effectiveChatModel = (updates.chatModel !== undefined ? updates.chatModel : existingSettings?.chatModel) as string | null | undefined; if (effectiveEmbProvId) { const embProv = await storage.getLlmProvider(effectiveEmbProvId, req.organizationId!); if (!embProv || !embProv.isActive) { return res.status(400).json({ success: false, error: "Провайдер эмбеддингов не найден или неактивен" }); } if (effectiveEmbModel && embProv.enabledModels && embProv.enabledModels.length > 0 && !embProv.enabledModels.includes(effectiveEmbModel)) { return res.status(400).json({ success: false, error: `Модель "${effectiveEmbModel}" не входит в список разрешённых моделей провайдера` }); } } if (effectiveChatProvId) { const chatProv = await storage.getLlmProvider(effectiveChatProvId, req.organizationId!); if (!chatProv || !chatProv.isActive) { return res.status(400).json({ success: false, error: "Провайдер суммаризации не найден или неактивен" }); } if (effectiveChatModel && chatProv.enabledModels && chatProv.enabledModels.length > 0 && !chatProv.enabledModels.includes(effectiveChatModel)) { return res.status(400).json({ success: false, error: `Модель "${effectiveChatModel}" не входит в список разрешённых моделей провайдера` }); } } const saved = await storage.upsertRagSettings(req.organizationId!, updates); return res.json({ success: true, settings: { provider: saved.provider, apiKey: null, apiKeyMasked: saved.apiKey ? maskApiKey(decrypt(saved.apiKey)) : null, baseUrl: saved.baseUrl, embeddingModel: saved.embeddingModel, chatModel: saved.chatModel ?? null, maxChunkChars: saved.maxChunkChars ?? null, summarizationEnabled: saved.summarizationEnabled ?? true, embeddingProviderId: saved.embeddingProviderId ?? null, chatProviderId: saved.chatProviderId ?? null, }, }); } catch (err) { console.error("[RAG] PATCH settings error:", err); return res.status(500).json({ success: false, error: "Ошибка сохранения настроек" }); } } ); router.post( "/api/rag/settings/test", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { provider, apiKey, baseUrl, embeddingModel } = req.body; let resolvedApiKey: string | null = apiKey && apiKey.trim() ? apiKey.trim() : null; if (!resolvedApiKey) { const saved = await storage.getRagSettings(req.organizationId!); if (saved?.apiKey) resolvedApiKey = decrypt(saved.apiKey); } if (!resolvedApiKey) { resolvedApiKey = process.env.OPENAI_API_KEY ?? null; } const resolvedProvider = provider ?? "openai"; const resolvedModel = (embeddingModel ?? "text-embedding-3-small").trim(); // Build and validate the API base URL let apiBase: string; if (resolvedProvider === "ollama") { const rawBase = baseUrl?.trim() ?? "http://localhost:11434"; const check = validateBaseUrl(rawBase); if (!check.ok) return res.json({ success: false, error: check.error }); apiBase = check.url; } else if (resolvedProvider === "openai_compatible" && baseUrl?.trim()) { const check = validateBaseUrl(baseUrl.trim()); if (!check.ok) return res.json({ success: false, error: check.error }); apiBase = check.url; } else { apiBase = "https://api.openai.com"; } if (resolvedProvider === "ollama") { // Check reachability and model availability via /api/tags, then do a test embedding const tagsResult = await testOllamaConnection(apiBase, resolvedModel); if (!tagsResult.ok) { return res.json({ success: false, error: tagsResult.error }); } // Do an actual embedding to confirm the model works const url = `${apiBase}/api/embeddings`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: resolvedModel, prompt: "test" }), signal: AbortSignal.timeout(15000), }); if (!response.ok) { const text = await response.text(); return res.json({ success: false, error: `Ошибка Ollama при генерации эмбеддинга (${response.status}): ${text.slice(0, 200)}` }); } return res.json({ success: true, message: "Подключение успешно, модель доступна" }); } if (!resolvedApiKey) { return res.json({ success: false, error: "API ключ не задан и переменная OPENAI_API_KEY не установлена" }); } const url = `${apiBase}/v1/embeddings`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${resolvedApiKey}`, }, body: JSON.stringify({ input: "test", model: resolvedModel }), signal: AbortSignal.timeout(10000), }); if (!response.ok) { const text = await response.text(); return res.json({ success: false, error: `Ошибка API (${response.status}): ${text.slice(0, 300)}` }); } return res.json({ success: true, message: "Подключение успешно" }); } catch (err: any) { const msg = err?.message ?? String(err); console.error("[RAG] test connection error:", msg); return res.json({ success: false, error: `Ошибка соединения: ${msg.slice(0, 200)}` }); } } ); export default router;