From 59bd802bf72221aff3a242852a1b323531022eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 29 Sep 2026 22:50:40 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=81=D0=BE=D1=87=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D0=B0=20=D0=BE=D1=82=D1=87=D1=91=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=98=D0=98:=20recharts=20=D0=B2=20custom?= =?UTF-8?q?=20pages=20+=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=91=D0=94=20=D1=81=20read-only=20=D1=88=D0=BB=D1=8E=D0=B7?= =?UTF-8?q?=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JsComponentRenderer: ctx.libs.charts (recharts) — графики в кастомных страницах и JS-вкладках без redeploy; справка get_js_coding_reference обновлена - Внешние БД (миграция 0088): вкладка «Настройки → Внешние БД» — реестр подключений (пароль AES-256-GCM, env EXTERNAL_DB_SECRET, по API не отдаётся), тест подключения, allowlist таблиц на подключение - Read-only шлюз POST /api/external-db/:id/query (finance.manage): только SELECT, без мульти-выражений, обёртка с LIMIT 5000, statement_timeout 10с, проверка таблиц по allowlist, аудит в external_db_query_log (кто/SQL/строки/мс) - Назначение: ИИ-агенты строят отчёты и виджеты поверх витрин (напр. НДС 1С) без новых серверных эндпоинтов и пересборки --- .../settings/ExternalDbSettings.tsx | 292 ++++++++++++++++ .../components/tabs/JsComponentRenderer.tsx | 5 +- client/src/pages/Settings.tsx | 9 + migrations/0088_external_db_connections.sql | 51 +++ server/mcp.ts | 4 + server/routes/external-db.routes.ts | 324 ++++++++++++++++++ server/routes/index.ts | 2 + 7 files changed, 686 insertions(+), 1 deletion(-) create mode 100644 client/src/components/settings/ExternalDbSettings.tsx create mode 100644 migrations/0088_external_db_connections.sql create mode 100644 server/routes/external-db.routes.ts diff --git a/client/src/components/settings/ExternalDbSettings.tsx b/client/src/components/settings/ExternalDbSettings.tsx new file mode 100644 index 0000000..7c35c29 --- /dev/null +++ b/client/src/components/settings/ExternalDbSettings.tsx @@ -0,0 +1,292 @@ +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiRequest } from "@/lib/queryClient"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Badge } from "@/components/ui/badge"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { Plus, Pencil, Trash2, PlugZap, Database } from "lucide-react"; + +interface Conn { + id: number; + name: string; + host: string; + port: number; + database: string; + username: string; + ssl: boolean; + allowedTables: string[]; + isActive: boolean; +} + +interface FormState { + name: string; + host: string; + port: string; + database: string; + username: string; + password: string; + ssl: boolean; + allowedTables: string; +} + +const EMPTY_FORM: FormState = { + name: "", host: "", port: "5432", database: "", username: "", password: "", ssl: false, allowedTables: "", +}; + +export function ExternalDbSettings() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [testing, setTesting] = useState(null); + + const listQuery = useQuery({ + queryKey: ["/api/external-db/connections"], + queryFn: async () => { + const r = await fetch("/api/external-db/connections"); + if (!r.ok) throw new Error(`${r.status}: ${await r.text() || r.statusText}`); + return r.json(); + }, + }); + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["/api/external-db/connections"] }); + + const saveMutation = useMutation({ + mutationFn: async () => { + const payload = { + name: form.name, + host: form.host, + port: Number(form.port) || 5432, + database: form.database, + username: form.username, + ssl: form.ssl, + allowedTables: form.allowedTables.split(/[\s,]+/).map(s => s.trim().toLowerCase()).filter(Boolean), + ...(form.password ? { password: form.password } : {}), + }; + if (editing) { + await apiRequest("PATCH", `/api/external-db/connections/${editing.id}`, payload); + } else { + await apiRequest("POST", "/api/external-db/connections", { ...payload, password: form.password }); + } + }, + onSuccess: () => { + toast({ title: editing ? "Подключение обновлено" : "Подключение создано" }); + setDialogOpen(false); + invalidate(); + }, + onError: (e: any) => toast({ title: e?.message || "Ошибка сохранения", variant: "destructive" }), + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => apiRequest("DELETE", `/api/external-db/connections/${id}`), + onSuccess: () => { toast({ title: "Подключение удалено" }); invalidate(); }, + onError: (e: any) => toast({ title: e?.message || "Ошибка удаления", variant: "destructive" }), + }); + + const toggleMutation = useMutation({ + mutationFn: async (c: Conn) => apiRequest("PATCH", `/api/external-db/connections/${c.id}`, { isActive: !c.isActive }), + onSuccess: () => invalidate(), + onError: (e: any) => toast({ title: e?.message || "Ошибка", variant: "destructive" }), + }); + + async function testConnection(id: number) { + setTesting(id); + try { + const r = await fetch(`/api/external-db/connections/${id}/test`, { method: "POST" }); + const res = await r.json(); + if (res.ok) toast({ title: `Подключение успешно (${res.durationMs} мс)` }); + else toast({ title: res.error || "Ошибка подключения", variant: "destructive" }); + } catch (e: any) { + toast({ title: e?.message || "Ошибка подключения", variant: "destructive" }); + } finally { + setTesting(null); + } + } + + function openEdit(c: Conn) { + setEditing(c); + setForm({ + name: c.name, host: c.host, port: String(c.port), database: c.database, + username: c.username, password: "", ssl: c.ssl, + allowedTables: (c.allowedTables ?? []).join(", "), + }); + setDialogOpen(true); + } + + function openCreate() { + setEditing(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + } + + const set = (k: keyof FormState) => (e: any) => setForm(f => ({ ...f, [k]: e.target ? e.target.value : e })); + + return ( + + +
+
+ + + Внешние базы данных + + + Read-only подключения для отчётов и виджетов. Запросы через шлюз: только SELECT, + только таблицы из списка, лимит 5000 строк. Каждый запрос пишется в журнал. + +
+ +
+
+ + {(listQuery.data ?? []).length > 0 ? ( + + + + Название + Сервер / БД + Пользователь + Разрешённые таблицы + Активно + Действия + + + + {(listQuery.data ?? []).map((c) => ( + + {c.name} + + {c.host}:{c.port}/{c.database}{c.ssl ? " · SSL" : ""} + + {c.username} + + {(c.allowedTables ?? []).length > 0 ? ( + + {c.allowedTables.join(", ")} + + ) : ( + шлюз закрыт + )} + + + toggleMutation.mutate(c)} + disabled={toggleMutation.isPending} + /> + + +
+ + + +
+
+
+ ))} +
+
+ ) : ( +

+ Подключений нет. Добавьте read-only доступ к внешней БД для отчётов и виджетов. +

+ )} +

+ Запросы из отчётов: POST /api/external-db/{"{id}"}/query {"{ sql }"} — доступен пользователям + с правом «Финансы». Рекомендуется заводить во внешней БД пользователя с GRANT SELECT только + на нужные таблицы (шлюз — второй рубеж, не замена прав БД). +

+ + + + + {editing ? "Изменить подключение" : "Новое подключение"} + + Пароль хранится в зашифрованном виде и по API не отдаётся. + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +

+ Только эти таблицы доступны через read-only шлюз. Пусто = шлюз закрыт. +

+
+
+ setForm(f => ({ ...f, ssl: v }))} /> + +
+
+ + + + +
+
+
+
+ ); +} diff --git a/client/src/components/tabs/JsComponentRenderer.tsx b/client/src/components/tabs/JsComponentRenderer.tsx index a77ebde..701fe9d 100644 --- a/client/src/components/tabs/JsComponentRenderer.tsx +++ b/client/src/components/tabs/JsComponentRenderer.tsx @@ -1,5 +1,6 @@ import { Component, useState, useEffect, ComponentType, ReactNode } from 'react'; import * as ReactLib from 'react'; +import * as ChartsLib from 'recharts'; import type { TransformResult } from '@babel/standalone'; import { useLocation } from 'wouter'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card'; @@ -104,6 +105,8 @@ interface CtxObject { libs: { React: typeof ReactLib; ui: CtxUiLib; + // Графики для отчётов/виджетов (recharts: BarChart, Line, ComposedChart, ReferenceArea, ...) + charts: typeof ChartsLib; SafeHtml: typeof SafeHtmlRenderer; useEvents: typeof useEvents; // Чат задачи — чтобы кастомные layout'ы могли встроить его (пропсы: { taskId: number }) @@ -270,7 +273,7 @@ function buildCtx( }; return { - libs: { React: ReactLib, ui, SafeHtml: SafeHtmlRenderer, useEvents, TaskChat }, + libs: { React: ReactLib, ui, charts: ChartsLib, SafeHtml: SafeHtmlRenderer, useEvents, TaskChat }, request: async ({ url, method = 'GET', data }: CtxRequestOptions): Promise => { const res = await apiRequest(method.toUpperCase(), url, data); return res.json(); diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 742795a..d5a2eeb 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -20,6 +20,7 @@ import { Link } from 'wouter'; import { BotsContent } from '@/pages/Bots'; import { AutomationsContent } from '@/pages/Automations'; import { AppearanceSettings } from '@/pages/AppearanceSettings'; +import { ExternalDbSettings } from '@/components/settings/ExternalDbSettings'; import { ErrorLogsTab } from '@/components/settings/ErrorLogsTab'; import { Badge } from '@/components/ui/badge'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; @@ -1768,6 +1769,10 @@ const Settings = () => { API Docs + + + Внешние БД + Журнал ошибок @@ -2008,6 +2013,10 @@ const Settings = () => { + + + +
diff --git a/migrations/0088_external_db_connections.sql b/migrations/0088_external_db_connections.sql new file mode 100644 index 0000000..368b2cd --- /dev/null +++ b/migrations/0088_external_db_connections.sql @@ -0,0 +1,51 @@ +-- Внешние подключения к БД (read-only шлюз для отчётов/виджетов, в т.ч. ИИ-агентами). +-- Пароль хранится в AES-256-GCM (server/routes/external-db.routes.ts), по API не отдаётся. +-- allowed_tables — jsonb-массив имён таблиц (lowercase), доступных через шлюз; +-- пустой массив = шлюз закрыт (админ обязан явно указать таблицы). + +CREATE TABLE IF NOT EXISTS external_db_connections ( + id SERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name TEXT NOT NULL, + host TEXT NOT NULL, + port INTEGER NOT NULL DEFAULT 5432, + database TEXT NOT NULL, + username TEXT NOT NULL, + password_encrypted TEXT NOT NULL, + ssl BOOLEAN NOT NULL DEFAULT false, + allowed_tables JSONB NOT NULL DEFAULT '[]', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS external_db_connections_org_idx ON external_db_connections (organization_id); + +-- Журнал запросов шлюза: кто, какой SQL, сколько строк, сколько мс +CREATE TABLE IF NOT EXISTS external_db_query_log ( + id BIGSERIAL PRIMARY KEY, + organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES external_db_connections(id) ON DELETE CASCADE, + user_id INTEGER, + bot_id INTEGER, + sql_text TEXT NOT NULL, + row_count INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS external_db_query_log_org_idx ON external_db_query_log (organization_id, created_at DESC); + +-- Tenant isolation +ALTER TABLE external_db_connections ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_iso ON external_db_connections; +CREATE POLICY tenant_iso ON external_db_connections USING ( + current_setting('app.is_superadmin', true) = 'true' + OR organization_id = NULLIF(current_setting('app.current_org_id', true), '')::int +); + +ALTER TABLE external_db_query_log ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_iso ON external_db_query_log; +CREATE POLICY tenant_iso ON external_db_query_log USING ( + current_setting('app.is_superadmin', true) = 'true' + OR organization_id = NULLIF(current_setting('app.current_org_id', true), '')::int +); diff --git a/server/mcp.ts b/server/mcp.ts index 70f5105..912525b 100644 --- a/server/mcp.ts +++ b/server/mcp.ts @@ -1907,6 +1907,7 @@ RULES for tab component code: description: `Create a new custom JS page that appears in the sidebar. MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: - Only use ctx.libs.ui shadcn components. Ant Design (ui.Tag/Row/Col/Statistic/Spin) does NOT exist. +- ctx.libs.charts — recharts для графиков (BarChart, Line, ComposedChart, ReferenceArea, ResponsiveContainer и т.д.). - Navigate to tasks: ctx.navigate('/forms//tasks/' + taskId). Route /tasks/:id does NOT exist. - ctx.request() returns { success, tasks/users/forms/... } — use .tasks not .data. - ctx.forms[formId] has pre-loaded tasks — never re-fetch them. @@ -1958,6 +1959,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: description: `Update properties or code of an existing custom JS page. MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules: - Only use ctx.libs.ui shadcn components. Ant Design (ui.Tag/Row/Col/Statistic/Spin) does NOT exist. +- ctx.libs.charts — recharts для графиков (BarChart, Line, ComposedChart, ReferenceArea, ResponsiveContainer и т.д.). - Navigate to tasks: ctx.navigate('/forms//tasks/' + taskId). Route /tasks/:id does NOT exist — it causes 404. - ctx.request() returns { success, tasks/users/forms/... } — use .tasks not .data. - ctx.forms[formId] has pre-loaded tasks — never re-fetch them with ctx.request. @@ -2048,6 +2050,8 @@ ctx = { libs: { React, // full React library (useState, useEffect, useRef, useMemo, etc.) ui: { ... }, // shadcn/ui components (see full list below) + charts, // recharts for graphs (BarChart, Bar, Line, ComposedChart, XAxis, YAxis, + // CartesianGrid, Tooltip, Legend, ResponsiveContainer, ReferenceArea, ...) }, // For CUSTOM PAGES only: diff --git a/server/routes/external-db.routes.ts b/server/routes/external-db.routes.ts new file mode 100644 index 0000000..d8e40fc --- /dev/null +++ b/server/routes/external-db.routes.ts @@ -0,0 +1,324 @@ +// Внешние подключения к БД: реестр (admin) + read-only SQL-шлюз (finance.manage). +// Назначение: отчёты и виджеты (в т.ч. построенные ИИ-агентами через custom pages) +// читают внешние витрины данных без новых серверных эндпоинтов и без redeploy. +// +// Безопасность: +// - пароль шифруется AES-256-GCM, по API не отдаётся (только маска); +// - шлюз: только SELECT, без мульти-выражений, allowlist таблиц на подключение, +// принудительный LIMIT (обёртка подзапросом), statement_timeout 10 с; +// - каждый запрос пишется в external_db_query_log (кто/когда/SQL/строки/мс); +// - рекомендуется заводить во внешней БД пользователя с реальным GRANT SELECT +// только на нужные таблицы (defence in depth — шлюз не заменяет права БД). + +import type { Express, Response } from "express"; +import { Router } from "express"; +import { Pool, type PoolConfig } from "pg"; +import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto"; +import { db } from "../db"; +import { sql } from "drizzle-orm"; +import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; + +const MAX_ROWS = 5000; +const STATEMENT_TIMEOUT_MS = 10000; + +function encryptionKey(): Buffer { + const secret = process.env.EXTERNAL_DB_SECRET || process.env.API_KEY_HMAC_SECRET || ""; + if (!secret) { + console.warn("[external-db] EXTERNAL_DB_SECRET не задан — используется небезопасный dev-ключ"); + } + return createHash("sha256").update(secret || "iistwin-external-db-dev-key").digest(); +} + +function encryptPassword(plain: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv); + const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString("base64")}.${tag.toString("base64")}.${enc.toString("base64")}`; +} + +function decryptPassword(stored: string): string { + const [ivB64, tagB64, dataB64] = stored.split("."); + if (!ivB64 || !tagB64 || !dataB64) throw new Error("Некорректный формат зашифрованного пароля"); + const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), Buffer.from(ivB64, "base64")); + decipher.setAuthTag(Buffer.from(tagB64, "base64")); + return Buffer.concat([decipher.update(Buffer.from(dataB64, "base64")), decipher.final()]).toString("utf8"); +} + +interface ConnRow { + id: number; + organization_id: number; + name: string; + host: string; + port: number; + database: string; + username: string; + password_encrypted: string; + ssl: boolean; + allowed_tables: string[]; + is_active: boolean; + created_at: string; + updated_at: string; +} + +const pools = new Map(); + +function getPool(conn: ConnRow): Pool { + let pool = pools.get(conn.id); + if (!pool) { + const cfg: PoolConfig = { + host: conn.host, + port: conn.port, + database: conn.database, + user: conn.username, + password: decryptPassword(conn.password_encrypted), + ssl: conn.ssl ? { rejectUnauthorized: false } : false, + max: 3, + connectionTimeoutMillis: 8000, + statement_timeout: STATEMENT_TIMEOUT_MS, + } as PoolConfig; + pool = new Pool(cfg); + pool.on("error", (err) => console.error(`[external-db] pool ${conn.id} error:`, err.message)); + pools.set(conn.id, pool); + } + return pool; +} + +function dropPool(connId: number): void { + const pool = pools.get(connId); + if (pool) { + pools.delete(connId); + pool.end().catch(() => {}); + } +} + +// --- Валидация SQL для read-only шлюза --- + +// Вырезаем строковые литералы, чтобы ключевые слова внутри них не ломали проверки +function stripStringLiterals(sqlText: string): string { + return sqlText.replace(/'([^']|'')*'/g, "''"); +} + +function validateReadOnly(rawSql: unknown): string { + if (typeof rawSql !== "string" || !rawSql.trim()) { + throw new Error("SQL запрос не задан"); + } + const cleaned = rawSql.trim().replace(/;+\s*$/, ""); + if (!/^select\b/i.test(cleaned)) { + throw new Error("Только SELECT-запросы"); + } + if (/;\s*\S/.test(cleaned)) { + throw new Error("Не допускается несколько выражений в одном запросе"); + } + const noStrings = stripStringLiterals(cleaned); + const forbidden = /\b(insert|update|delete|drop|alter|truncate|grant|revoke|create|copy|call|execute|do|merge|vacuum|cluster|reindex|refresh)\b/i; + if (forbidden.test(noStrings)) { + throw new Error("Запрещённое ключевое слово в запросе"); + } + return cleaned; +} + +function extractTableNames(sqlText: string): string[] { + const names = new Set(); + const re = /\b(?:from|join)\s+([a-zA-Z_][\w$]*(?:\.[a-zA-Z_][\w$]*)?)/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(sqlText)) !== null) { + const parts = m[1].split("."); + names.add(parts[parts.length - 1].toLowerCase()); + } + return [...names]; +} + +function toPublicConn(row: ConnRow) { + return { + id: row.id, + name: row.name, + host: row.host, + port: row.port, + database: row.database, + username: row.username, + hasPassword: true, + ssl: row.ssl, + allowedTables: row.allowed_tables ?? [], + isActive: row.is_active, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export function registerExternalDbRoutes(app: Express): void { + const r = Router(); + app.use("/api/external-db", r); + + // Управление подключениями — только admin приложения + r.use("/connections", authenticateToken, requireAppAdmin); + + r.get("/connections", async (req: AuthenticatedRequest, res: Response) => { + try { + const orgId = req.user!.organizationId; + const rows = await db.execute(sql` + SELECT * FROM external_db_connections + WHERE organization_id = ${orgId} + ORDER BY id + `); + res.json((rows.rows as unknown as ConnRow[]).map(toPublicConn)); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Ошибка базы данных" }); + } + }); + + r.post("/connections", async (req: AuthenticatedRequest, res: Response) => { + try { + const orgId = req.user!.organizationId; + const { name, host, port, database, username, password, ssl, allowedTables } = req.body ?? {}; + if (!name || !host || !database || !username || !password) { + return res.status(400).json({ error: "Обязательные поля: name, host, database, username, password" }); + } + const tables = sanitizeTables(allowedTables); + const inserted = await db.execute(sql` + INSERT INTO external_db_connections + (organization_id, name, host, port, database, username, password_encrypted, ssl, allowed_tables) + VALUES + (${orgId}, ${String(name)}, ${String(host)}, ${Number(port) || 5432}, ${String(database)}, + ${String(username)}, ${encryptPassword(String(password))}, ${!!ssl}, ${JSON.stringify(tables)}::jsonb) + RETURNING * + `); + res.status(201).json(toPublicConn(inserted.rows[0] as unknown as ConnRow)); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Ошибка базы данных" }); + } + }); + + r.patch("/connections/:id", async (req: AuthenticatedRequest, res: Response) => { + try { + const orgId = req.user!.organizationId; + const id = parseInt(req.params.id, 10); + const b = req.body ?? {}; + const existing = await findConn(orgId, id); + if (!existing) return res.status(404).json({ error: "Подключение не найдено" }); + + const sets: ReturnType[] = []; + if (b.name !== undefined) sets.push(sql`name = ${String(b.name)}`); + if (b.host !== undefined) sets.push(sql`host = ${String(b.host)}`); + if (b.port !== undefined) sets.push(sql`port = ${Number(b.port) || 5432}`); + if (b.database !== undefined) sets.push(sql`database = ${String(b.database)}`); + if (b.username !== undefined) sets.push(sql`username = ${String(b.username)}`); + if (b.password) sets.push(sql`password_encrypted = ${encryptPassword(String(b.password))}`); + if (b.ssl !== undefined) sets.push(sql`ssl = ${!!b.ssl}`); + if (b.allowedTables !== undefined) sets.push(sql`allowed_tables = ${JSON.stringify(sanitizeTables(b.allowedTables))}::jsonb`); + if (b.isActive !== undefined) sets.push(sql`is_active = ${!!b.isActive}`); + if (sets.length === 0) return res.status(400).json({ error: "Нет полей для обновления" }); + + const updated = await db.execute(sql` + UPDATE external_db_connections + SET ${sql.join(sets, sql`, `)}, updated_at = NOW() + WHERE id = ${id} AND organization_id = ${orgId} + RETURNING * + `); + dropPool(id); + res.json(toPublicConn(updated.rows[0] as unknown as ConnRow)); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Ошибка базы данных" }); + } + }); + + r.delete("/connections/:id", async (req: AuthenticatedRequest, res: Response) => { + try { + const orgId = req.user!.organizationId; + const id = parseInt(req.params.id, 10); + await db.execute(sql` + DELETE FROM external_db_connections WHERE id = ${id} AND organization_id = ${orgId} + `); + dropPool(id); + res.json({ success: true }); + } catch (err) { + console.error(err); + res.status(500).json({ error: "Ошибка базы данных" }); + } + }); + + r.post("/connections/:id/test", async (req: AuthenticatedRequest, res: Response) => { + try { + const orgId = req.user!.organizationId; + const id = parseInt(req.params.id, 10); + const conn = await findConn(orgId, id); + if (!conn) return res.status(404).json({ error: "Подключение не найдено" }); + const started = Date.now(); + await getPool(conn).query("SELECT 1"); + res.json({ ok: true, durationMs: Date.now() - started }); + } catch (err: any) { + res.json({ ok: false, error: err?.message || "Ошибка подключения" }); + } + }); + + // --- Read-only шлюз (finance.manage) --- + r.post("/:id/query", authenticateToken, requirePermission("finance.manage"), async (req: AuthenticatedRequest, res: Response) => { + const started = Date.now(); + const orgId = req.user!.organizationId; + const id = parseInt(req.params.id, 10); + const sqlText = req.body?.sql; + let rowCount = 0; + let errorText: string | null = null; + let conn: ConnRow | null = null; + try { + conn = await findConn(orgId, id); + if (!conn) return res.status(404).json({ error: "Подключение не найдено" }); + if (!conn.is_active) return res.status(403).json({ error: "Подключение отключено" }); + + const cleaned = validateReadOnly(sqlText); + const allowed = (conn.allowed_tables ?? []).map((t) => String(t).toLowerCase()); + if (allowed.length === 0) { + return res.status(403).json({ error: "Для подключения не задан список разрешённых таблиц" }); + } + const usedTables = extractTableNames(stripStringLiterals(cleaned)); + const denied = usedTables.filter((t) => !allowed.includes(t)); + if (denied.length > 0) { + return res.status(403).json({ error: `Таблицы не в allowlist: ${denied.join(", ")}` }); + } + + const wrapped = `SELECT * FROM (${cleaned}) AS __gateway_q LIMIT ${MAX_ROWS}`; + const result = await getPool(conn).query(wrapped); + rowCount = result.rowCount ?? result.rows.length; + res.json({ columns: result.fields.map((f) => f.name), rows: result.rows, rowCount, truncated: rowCount >= MAX_ROWS }); + } catch (err: any) { + errorText = err?.message || String(err); + if (!res.headersSent) { + res.status(400).json({ error: errorText }); + } + } finally { + if (conn) { + const botId = (req as any).bot?.id ?? null; + db.execute(sql` + INSERT INTO external_db_query_log + (organization_id, connection_id, user_id, bot_id, sql_text, row_count, duration_ms, error) + VALUES + (${orgId}, ${id}, ${req.user!.id}, ${botId}, + ${String(sqlText ?? "").slice(0, 4000)}, ${rowCount}, ${Date.now() - started}, ${errorText}) + `).catch((e) => console.error("[external-db] audit log error:", e.message)); + } + } + }); + + async function findConn(orgId: number, id: number): Promise { + const rows = await db.execute(sql` + SELECT * FROM external_db_connections WHERE id = ${id} AND organization_id = ${orgId} + `); + return (rows.rows[0] as unknown as ConnRow) ?? null; + } +} + +function sanitizeTables(input: unknown): string[] { + if (!Array.isArray(input)) return []; + return [...new Set(input.map((t) => String(t).trim().toLowerCase()).filter(Boolean))]; +} + +function requireAppAdmin(req: AuthenticatedRequest, res: Response, next: () => void): void { + if (req.user?.appRole !== "admin") { + res.status(403).json({ error: "Требуется роль администратора" }); + return; + } + next(); +} diff --git a/server/routes/index.ts b/server/routes/index.ts index b5b046c..c2c7d73 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -44,6 +44,7 @@ import { registerMedScheduleRoutes } from "../medschedule/routes"; import { registerMedScheduleBotRoutes } from "../medschedule/bot.routes"; import { registerGpsRoutes } from "../gps/routes"; import { registerVpnRoutes } from "../vpn"; +import { registerExternalDbRoutes } from "./external-db.routes"; import { FIELD_TYPE_DEFINITIONS } from "../../shared/field-types"; export { publishNotificationSSE }; @@ -127,6 +128,7 @@ export async function registerRoutes(app: Express): Promise { // GPS-модуль: публичный /api/gps/ingest регистрируется внутри до authenticateToken registerGpsRoutes(app); registerVpnRoutes(app); + registerExternalDbRoutes(app); const httpServer = createServer(app); return httpServer;