Files
iistwin/server/routes/rag.routes.ts
Ильяс Султанов 5750a3106f security(auth): anti-enumeration, returnTo-валидация, лимит попыток логина
Шаг 2.1 плана production-готовности:
- единый 401 при любом отказе логина + constant-time bcrypt
- forgot-password: идентичный ответ независимо от существования email
- sanitizeReturnTo (open redirect fix в /api/documents/generate-link)
- 10 неудачных попыток → блок 15 мин (in-memory, аудит)
- DATABASE_SSL_REJECT_UNAUTHORIZED env (дефолт true)
- доделка 0.7: статичные тексты в llm-providers/rag/finance-di2
- 8 новых тестов (104/104)
2026-09-08 00:46:57 +03:00

353 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string | number | boolean | null | undefined> = {};
// 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();
console.error(`[RAG] Ollama embeddings error (${response.status}):`, text.slice(0, 200));
return res.json({ success: false, error: `Ошибка Ollama при генерации эмбеддинга (${response.status})` });
}
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();
console.error(`[RAG] embeddings API error (${response.status}):`, text.slice(0, 300));
return res.json({ success: false, error: `Ошибка API (${response.status})` });
}
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: "Ошибка соединения с сервисом эмбеддингов" });
}
}
);
export default router;