From 6766cd81a1209679e6099a27b4271c7c4a477080 Mon Sep 17 00:00:00 2001 From: eshmeshek Date: Sun, 1 Mar 2026 16:00:26 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=BD=D0=BE=20=D0=BE=D0=BA=D0=BD=D0=BE=20?= =?UTF-8?q?=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B8=D0=BD=D1=82=D0=B0,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=8D?= =?UTF-8?q?=D0=BB=D0=B5=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=B5=D0=B1?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80=D1=83?= =?UTF-8?q?=D0=B6=D0=B0=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20=D1=8D=D0=BD=D0=B4=D0=BF?= =?UTF-8?q?=D0=BE=D0=B8=D0=BD=D1=82=D0=B0,=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B5=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8.=20=D0=98=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B5=D0=BB?= =?UTF-8?q?=D0=BA=D0=B8=D0=B5=20=D0=B1=D0=B0=D0=B3=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/controllers/endpointController.ts | 356 +++++++++++++++++- backend/src/routes/endpoints.ts | 79 ++-- .../src/services/IsolatedScriptExecutor.ts | 247 ++++++++++++ backend/src/services/ScriptExecutor.ts | 209 +++++----- backend/src/services/endpointCrypto.ts | 35 ++ backend/src/types/index.ts | 56 +++ frontend/package-lock.json | 212 +++++++++++ frontend/package.json | 4 +- frontend/src/components/EndpointModal.tsx | 252 ++++++++++++- .../src/components/ImportEndpointModal.tsx | 222 +++++++++++ frontend/src/components/SqlEditor.tsx | 6 +- frontend/src/pages/Endpoints.tsx | 90 ++++- frontend/src/pages/SqlInterface.tsx | 1 + frontend/src/services/api.ts | 26 +- frontend/src/types/index.ts | 54 ++- 15 files changed, 1677 insertions(+), 172 deletions(-) create mode 100644 backend/src/services/IsolatedScriptExecutor.ts create mode 100644 backend/src/services/endpointCrypto.ts create mode 100644 frontend/src/components/ImportEndpointModal.tsx diff --git a/backend/src/controllers/endpointController.ts b/backend/src/controllers/endpointController.ts index 9e80497..0ce6825 100644 --- a/backend/src/controllers/endpointController.ts +++ b/backend/src/controllers/endpointController.ts @@ -2,6 +2,8 @@ import { Response } from 'express'; import { AuthRequest } from '../middleware/auth'; import { mainPool } from '../config/database'; import { v4 as uuidv4 } from 'uuid'; +import { ExportedEndpoint, ExportedScriptQuery } from '../types'; +import { encryptEndpointData, decryptEndpointData } from '../services/endpointCrypto'; export const getEndpoints = async (req: AuthRequest, res: Response) => { try { @@ -314,6 +316,11 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { data: result.rows, rowCount: result.rowCount, executionTime: result.executionTime, + logs: [ + { type: 'info', message: `Query executed in ${result.executionTime}ms, returned ${result.rowCount} rows`, timestamp: Date.now() }, + ], + queries: [], + processedQuery, }); } else if (execType === 'script') { if (!script_language || !script_code) { @@ -338,7 +345,9 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { res.json({ success: true, - data: scriptResult, + data: scriptResult.result, + logs: scriptResult.logs, + queries: scriptResult.queries, }); } else if (execType === 'aql') { if (!database_id) { @@ -370,6 +379,10 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { data: result.rows, rowCount: result.rowCount, executionTime: result.executionTime, + logs: [ + { type: 'info', message: `AQL ${aql_method} ${aql_endpoint} executed in ${result.executionTime}ms`, timestamp: Date.now() }, + ], + queries: [], }); } else { return res.status(400).json({ error: 'Invalid execution_type' }); @@ -378,6 +391,347 @@ export const testEndpoint = async (req: AuthRequest, res: Response) => { res.status(400).json({ success: false, error: error.message, + detail: error.detail || undefined, + hint: error.hint || undefined, + logs: [], + queries: [], }); } }; + +export const exportEndpoint = async (req: AuthRequest, res: Response) => { + try { + const { id } = req.params; + + const endpointResult = await mainPool.query( + 'SELECT * FROM endpoints WHERE id = $1', + [id] + ); + if (endpointResult.rows.length === 0) { + return res.status(404).json({ error: 'Endpoint not found' }); + } + const endpoint = endpointResult.rows[0]; + + // Resolve database_id -> name & type + let databaseName: string | null = null; + let databaseType: string | null = null; + if (endpoint.database_id) { + const dbResult = await mainPool.query( + 'SELECT name, type FROM databases WHERE id = $1', + [endpoint.database_id] + ); + if (dbResult.rows.length > 0) { + databaseName = dbResult.rows[0].name; + databaseType = dbResult.rows[0].type; + } + } + + // Resolve folder_id -> name + let folderName: string | null = null; + if (endpoint.folder_id) { + const folderResult = await mainPool.query( + 'SELECT name FROM folders WHERE id = $1', + [endpoint.folder_id] + ); + if (folderResult.rows.length > 0) { + folderName = folderResult.rows[0].name; + } + } + + // Resolve database_ids in script_queries + const scriptQueries = endpoint.script_queries || []; + const exportedScriptQueries: ExportedScriptQuery[] = []; + for (const sq of scriptQueries) { + let sqDbName: string | undefined; + let sqDbType: string | undefined; + if (sq.database_id) { + const sqDbResult = await mainPool.query( + 'SELECT name, type FROM databases WHERE id = $1', + [sq.database_id] + ); + if (sqDbResult.rows.length > 0) { + sqDbName = sqDbResult.rows[0].name; + sqDbType = sqDbResult.rows[0].type; + } + } + exportedScriptQueries.push({ + name: sq.name, + sql: sq.sql, + database_name: sqDbName, + database_type: sqDbType, + aql_method: sq.aql_method, + aql_endpoint: sq.aql_endpoint, + aql_body: sq.aql_body, + aql_query_params: sq.aql_query_params, + }); + } + + const exportData: ExportedEndpoint = { + _format: 'kabe_v1', + name: endpoint.name, + description: endpoint.description || '', + method: endpoint.method, + path: endpoint.path, + execution_type: endpoint.execution_type || 'sql', + database_name: databaseName, + database_type: databaseType, + sql_query: endpoint.sql_query || '', + parameters: endpoint.parameters || [], + script_language: endpoint.script_language || null, + script_code: endpoint.script_code || null, + script_queries: exportedScriptQueries, + aql_method: endpoint.aql_method || null, + aql_endpoint: endpoint.aql_endpoint || null, + aql_body: endpoint.aql_body || null, + aql_query_params: endpoint.aql_query_params || null, + is_public: endpoint.is_public || false, + enable_logging: endpoint.enable_logging || false, + detailed_response: endpoint.detailed_response || false, + folder_name: folderName, + }; + + const encrypted = encryptEndpointData(exportData); + + const safeFileName = endpoint.name.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_'); + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${safeFileName}.kabe"`); + res.send(encrypted); + } catch (error) { + console.error('Export endpoint error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const importPreview = async (req: AuthRequest, res: Response) => { + try { + const buffer = req.body as Buffer; + if (!buffer || buffer.length === 0) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + let exportData: ExportedEndpoint; + try { + exportData = decryptEndpointData(buffer) as ExportedEndpoint; + } catch (err) { + return res.status(400).json({ error: 'Invalid or corrupted .kabe file' }); + } + + if (exportData._format !== 'kabe_v1') { + return res.status(400).json({ error: 'Unsupported file format version' }); + } + + // Collect all referenced database names + const referencedDatabases: { name: string; type: string }[] = []; + + if (exportData.database_name) { + referencedDatabases.push({ + name: exportData.database_name, + type: exportData.database_type || 'unknown', + }); + } + + for (const sq of exportData.script_queries || []) { + if (sq.database_name && !referencedDatabases.find(d => d.name === sq.database_name)) { + referencedDatabases.push({ + name: sq.database_name, + type: sq.database_type || 'unknown', + }); + } + } + + // Check which databases exist locally + const localDatabases = await mainPool.query( + 'SELECT id, name, type FROM databases WHERE is_active = true' + ); + + const databaseMapping = referencedDatabases.map(ref => { + const found = localDatabases.rows.find( + (db: any) => db.name === ref.name && db.type === ref.type + ); + return { + name: ref.name, + type: ref.type, + found: !!found, + local_id: found?.id || null, + }; + }); + + // Check folder + let folder: { name: string; found: boolean; local_id: string | null } | null = null; + if (exportData.folder_name) { + const folderResult = await mainPool.query( + 'SELECT id FROM folders WHERE name = $1', + [exportData.folder_name] + ); + folder = { + name: exportData.folder_name, + found: folderResult.rows.length > 0, + local_id: folderResult.rows.length > 0 ? folderResult.rows[0].id : null, + }; + } + + // Check if path already exists + const pathCheck = await mainPool.query( + 'SELECT id FROM endpoints WHERE path = $1', + [exportData.path] + ); + + res.json({ + endpoint: { + name: exportData.name, + description: exportData.description, + method: exportData.method, + path: exportData.path, + execution_type: exportData.execution_type, + is_public: exportData.is_public, + enable_logging: exportData.enable_logging, + detailed_response: exportData.detailed_response, + folder_name: exportData.folder_name, + }, + databases: databaseMapping, + all_databases_found: databaseMapping.every(d => d.found), + local_databases: localDatabases.rows.map((db: any) => ({ + id: db.id, + name: db.name, + type: db.type, + })), + folder, + path_exists: pathCheck.rows.length > 0, + }); + } catch (error) { + console.error('Import preview error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const importEndpoint = async (req: AuthRequest, res: Response) => { + try { + const { + file_data, + database_mapping, + folder_id, + override_path, + } = req.body; + + if (!file_data) { + return res.status(400).json({ error: 'No file data provided' }); + } + + const buffer = Buffer.from(file_data, 'base64'); + let exportData: ExportedEndpoint; + try { + exportData = decryptEndpointData(buffer) as ExportedEndpoint; + } catch (err) { + return res.status(400).json({ error: 'Invalid or corrupted .kabe file' }); + } + + // Resolve main database_id + let databaseId: string | null = null; + if (exportData.database_name) { + const mappedId = database_mapping?.[exportData.database_name]; + if (mappedId) { + databaseId = mappedId; + } else { + const dbResult = await mainPool.query( + 'SELECT id FROM databases WHERE name = $1 AND is_active = true', + [exportData.database_name] + ); + if (dbResult.rows.length > 0) { + databaseId = dbResult.rows[0].id; + } else { + return res.status(400).json({ + error: `Database "${exportData.database_name}" not found and no mapping provided` + }); + } + } + } + + // Resolve script_queries database_ids + const resolvedScriptQueries = []; + for (const sq of exportData.script_queries || []) { + let sqDatabaseId: string | undefined; + if (sq.database_name) { + const mappedId = database_mapping?.[sq.database_name]; + if (mappedId) { + sqDatabaseId = mappedId; + } else { + const sqDbResult = await mainPool.query( + 'SELECT id FROM databases WHERE name = $1 AND is_active = true', + [sq.database_name] + ); + if (sqDbResult.rows.length > 0) { + sqDatabaseId = sqDbResult.rows[0].id; + } else { + return res.status(400).json({ + error: `Database "${sq.database_name}" (script query "${sq.name}") not found and no mapping provided` + }); + } + } + } + resolvedScriptQueries.push({ + name: sq.name, + sql: sq.sql, + database_id: sqDatabaseId, + aql_method: sq.aql_method, + aql_endpoint: sq.aql_endpoint, + aql_body: sq.aql_body, + aql_query_params: sq.aql_query_params, + }); + } + + // Resolve folder + let resolvedFolderId: string | null = folder_id || null; + if (!resolvedFolderId && exportData.folder_name) { + const folderResult = await mainPool.query( + 'SELECT id FROM folders WHERE name = $1', + [exportData.folder_name] + ); + if (folderResult.rows.length > 0) { + resolvedFolderId = folderResult.rows[0].id; + } + } + + const finalPath = override_path || exportData.path; + + const result = await mainPool.query( + `INSERT INTO endpoints ( + name, description, method, path, database_id, sql_query, parameters, + folder_id, user_id, is_public, enable_logging, + execution_type, script_language, script_code, script_queries, + aql_method, aql_endpoint, aql_body, aql_query_params, detailed_response + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20) + RETURNING *`, + [ + exportData.name, + exportData.description || '', + exportData.method, + finalPath, + databaseId, + exportData.sql_query || '', + JSON.stringify(exportData.parameters || []), + resolvedFolderId, + req.user!.id, + exportData.is_public || false, + exportData.enable_logging || false, + exportData.execution_type || 'sql', + exportData.script_language || null, + exportData.script_code || null, + JSON.stringify(resolvedScriptQueries), + exportData.aql_method || null, + exportData.aql_endpoint || null, + exportData.aql_body || null, + JSON.stringify(exportData.aql_query_params || {}), + exportData.detailed_response || false, + ] + ); + + res.status(201).json(result.rows[0]); + } catch (error: any) { + console.error('Import endpoint error:', error); + if (error.code === '23505') { + return res.status(400).json({ error: 'Endpoint path already exists' }); + } + res.status(500).json({ error: 'Internal server error' }); + } +}; diff --git a/backend/src/routes/endpoints.ts b/backend/src/routes/endpoints.ts index 5cb5822..a6ac76c 100644 --- a/backend/src/routes/endpoints.ts +++ b/backend/src/routes/endpoints.ts @@ -7,6 +7,9 @@ import { updateEndpoint, deleteEndpoint, testEndpoint, + exportEndpoint, + importPreview, + importEndpoint, } from '../controllers/endpointController'; const router = express.Router(); @@ -36,6 +39,44 @@ router.use(authMiddleware); */ router.get('/', getEndpoints); +// Import routes must be before /:id to avoid "import" being treated as an id +router.post('/import/preview', express.raw({ type: 'application/octet-stream', limit: '10mb' }), importPreview); +router.post('/import', importEndpoint); + +/** + * @swagger + * /api/endpoints/test: + * post: + * tags: [Endpoints] + * summary: Test SQL query + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Query test result + */ +router.post('/test', testEndpoint); + +/** + * @swagger + * /api/endpoints: + * post: + * tags: [Endpoints] + * summary: Create new endpoint + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * responses: + * 201: + * description: Endpoint created + */ +router.post('/', createEndpoint); + /** * @swagger * /api/endpoints/{id}: @@ -58,23 +99,23 @@ router.get('/:id', getEndpoint); /** * @swagger - * /api/endpoints: - * post: + * /api/endpoints/{id}/export: + * get: * tags: [Endpoints] - * summary: Create new endpoint + * summary: Export endpoint as .kabe file * security: * - bearerAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string * responses: - * 201: - * description: Endpoint created + * 200: + * description: Encrypted .kabe file */ -router.post('/', createEndpoint); +router.get('/:id/export', exportEndpoint); /** * @swagger @@ -116,18 +157,4 @@ router.put('/:id', updateEndpoint); */ router.delete('/:id', deleteEndpoint); -/** - * @swagger - * /api/endpoints/test: - * post: - * tags: [Endpoints] - * summary: Test SQL query - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Query test result - */ -router.post('/test', testEndpoint); - export default router; diff --git a/backend/src/services/IsolatedScriptExecutor.ts b/backend/src/services/IsolatedScriptExecutor.ts new file mode 100644 index 0000000..e16cc70 --- /dev/null +++ b/backend/src/services/IsolatedScriptExecutor.ts @@ -0,0 +1,247 @@ +import * as vm from 'vm'; +import { sqlExecutor } from './SqlExecutor'; +import { aqlExecutor } from './AqlExecutor'; +import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types'; +import { databasePoolManager } from './DatabasePoolManager'; + +interface IsolatedScriptContext { + databaseId: string; + scriptQueries: ScriptQuery[]; + requestParams: Record; + endpointParameters: EndpointParameter[]; +} + +export class IsolatedScriptExecutor { + private readonly TIMEOUT_MS = 600000; // 10 minutes + + async execute(code: string, context: IsolatedScriptContext): Promise { + const logs: LogEntry[] = []; + const queries: QueryExecution[] = []; + + // Build captured console proxy + const capturedConsole = { + log: (...args: any[]) => { + logs.push({ type: 'log', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() }); + }, + error: (...args: any[]) => { + logs.push({ type: 'error', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() }); + }, + warn: (...args: any[]) => { + logs.push({ type: 'warn', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() }); + }, + info: (...args: any[]) => { + logs.push({ type: 'info', message: args.map(a => this.stringify(a)).join(' '), timestamp: Date.now() }); + }, + }; + + // Build execQuery function with tracking + const execQuery = async (queryName: string, additionalParams: Record = {}) => { + const startTime = Date.now(); + const query = context.scriptQueries.find(q => q.name === queryName); + + if (!query) { + const entry: QueryExecution = { + name: queryName, + executionTime: Date.now() - startTime, + success: false, + error: `Query '${queryName}' not found`, + }; + queries.push(entry); + throw new Error(`Query '${queryName}' not found`); + } + + const allParams = { ...context.requestParams, ...additionalParams }; + const dbId = (query as any).database_id || context.databaseId; + + if (!dbId) { + const errMsg = `Database ID not found for query '${queryName}'. Please specify database_id in the Script Queries configuration.`; + queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg }); + throw new Error(errMsg); + } + + const dbConfig = await databasePoolManager.getDatabaseConfig(dbId); + if (!dbConfig) { + const errMsg = `Database configuration not found for ID: ${dbId}`; + queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg }); + throw new Error(errMsg); + } + + if (dbConfig.type === 'aql') { + try { + const result = await aqlExecutor.executeAqlQuery(dbId, { + method: query.aql_method || 'GET', + endpoint: query.aql_endpoint || '', + body: query.aql_body || '', + queryParams: query.aql_query_params || {}, + parameters: allParams, + }); + + queries.push({ + name: queryName, + executionTime: Date.now() - startTime, + rowCount: result.rowCount, + success: true, + }); + + return { + success: true, + data: result.rows, + rowCount: result.rowCount, + executionTime: result.executionTime, + }; + } catch (error: any) { + queries.push({ + name: queryName, + executionTime: Date.now() - startTime, + success: false, + error: error.message, + }); + return { success: false, error: error.message, data: [], rowCount: 0 }; + } + } else { + if (!query.sql) { + const errMsg = `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`; + queries.push({ name: queryName, executionTime: Date.now() - startTime, success: false, error: errMsg }); + throw new Error(errMsg); + } + + try { + let processedQuery = query.sql; + const paramValues: any[] = []; + const paramMatches = query.sql.match(/\$\w+/g) || []; + const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))]; + + uniqueParams.forEach((paramName, index) => { + const regex = new RegExp(`\\$${paramName}\\b`, 'g'); + processedQuery = processedQuery.replace(regex, `$${index + 1}`); + const value = allParams[paramName]; + paramValues.push(value !== undefined ? value : null); + }); + + const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues); + + queries.push({ + name: queryName, + sql: query.sql, + executionTime: Date.now() - startTime, + rowCount: result.rowCount, + success: true, + }); + + return { + success: true, + data: result.rows, + rowCount: result.rowCount, + executionTime: result.executionTime, + }; + } catch (error: any) { + queries.push({ + name: queryName, + sql: query.sql, + executionTime: Date.now() - startTime, + success: false, + error: error.message, + }); + return { success: false, error: error.message, data: [], rowCount: 0 }; + } + } + }; + + // Create sandbox with null-prototype base + const sandbox = Object.create(null); + sandbox.params = context.requestParams; + sandbox.console = capturedConsole; + sandbox.execQuery = execQuery; + + // Safe globals + sandbox.JSON = JSON; + sandbox.Date = Date; + sandbox.Math = Math; + sandbox.parseInt = parseInt; + sandbox.parseFloat = parseFloat; + sandbox.Array = Array; + sandbox.Object = Object; + sandbox.String = String; + sandbox.Number = Number; + sandbox.Boolean = Boolean; + sandbox.RegExp = RegExp; + sandbox.Map = Map; + sandbox.Set = Set; + sandbox.Promise = Promise; + sandbox.Error = Error; + sandbox.TypeError = TypeError; + sandbox.RangeError = RangeError; + sandbox.SyntaxError = SyntaxError; + sandbox.isNaN = isNaN; + sandbox.isFinite = isFinite; + sandbox.undefined = undefined; + sandbox.NaN = NaN; + sandbox.Infinity = Infinity; + sandbox.encodeURIComponent = encodeURIComponent; + sandbox.decodeURIComponent = decodeURIComponent; + sandbox.encodeURI = encodeURI; + sandbox.decodeURI = decodeURI; + + // Capped setTimeout/clearTimeout + const timerIds = new Set>(); + sandbox.setTimeout = (fn: Function, ms: number, ...args: any[]) => { + const cappedMs = Math.min(ms || 0, 30000); + const id = setTimeout(() => { + timerIds.delete(id); + fn(...args); + }, cappedMs); + timerIds.add(id); + return id; + }; + sandbox.clearTimeout = (id: ReturnType) => { + timerIds.delete(id); + clearTimeout(id); + }; + + const vmContext = vm.createContext(sandbox); + + // Wrap user code in async IIFE + const wrappedCode = `(async function() { ${code} })()`; + + try { + const script = new vm.Script(wrappedCode, { filename: 'user-script.js' }); + const resultPromise = script.runInContext(vmContext); + + // Race against timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Script execution timeout (10min)')), this.TIMEOUT_MS); + }); + + const result = await Promise.race([resultPromise, timeoutPromise]); + + // Clean up timers + for (const id of timerIds) { + clearTimeout(id); + } + timerIds.clear(); + + return { result, logs, queries }; + } catch (error: any) { + // Clean up timers + for (const id of timerIds) { + clearTimeout(id); + } + timerIds.clear(); + + throw new Error(`JavaScript execution error: ${error.message}`); + } + } + + private stringify(value: any): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } +} + +export const isolatedScriptExecutor = new IsolatedScriptExecutor(); diff --git a/backend/src/services/ScriptExecutor.ts b/backend/src/services/ScriptExecutor.ts index c022387..7eb3a40 100644 --- a/backend/src/services/ScriptExecutor.ts +++ b/backend/src/services/ScriptExecutor.ts @@ -1,8 +1,9 @@ import { spawn } from 'child_process'; import { sqlExecutor } from './SqlExecutor'; import { aqlExecutor } from './AqlExecutor'; -import { ScriptQuery, EndpointParameter } from '../types'; +import { ScriptQuery, EndpointParameter, LogEntry, QueryExecution, IsolatedExecutionResult } from '../types'; import { databasePoolManager } from './DatabasePoolManager'; +import { isolatedScriptExecutor } from './IsolatedScriptExecutor'; interface ScriptContext { databaseId: string; @@ -13,122 +14,19 @@ interface ScriptContext { export class ScriptExecutor { /** - * Выполняет JavaScript скрипт + * Выполняет JavaScript скрипт через изолированный VM контекст */ - async executeJavaScript(code: string, context: ScriptContext): Promise { - try { - // Создаем функцию execQuery, доступную в скрипте - const execQuery = async (queryName: string, additionalParams: Record = {}) => { - const query = context.scriptQueries.find(q => q.name === queryName); - if (!query) { - throw new Error(`Query '${queryName}' not found`); - } - - const allParams = { ...context.requestParams, ...additionalParams }; - const dbId = (query as any).database_id || context.databaseId; - - if (!dbId) { - throw new Error(`Database ID not found for query '${queryName}'. Query database_id: ${(query as any).database_id}, Context databaseId: ${context.databaseId}. Please specify database_id in the Script Queries configuration for query '${queryName}'.`); - } - - // Получаем конфигурацию базы данных для определения типа - const dbConfig = await databasePoolManager.getDatabaseConfig(dbId); - if (!dbConfig) { - throw new Error(`Database configuration not found for ID: ${dbId}`); - } - - // Проверяем тип базы данных и выполняем соответствующий запрос - if (dbConfig.type === 'aql') { - // AQL запрос - try { - const result = await aqlExecutor.executeAqlQuery(dbId, { - method: query.aql_method || 'GET', - endpoint: query.aql_endpoint || '', - body: query.aql_body || '', - queryParams: query.aql_query_params || {}, - parameters: allParams, - }); - - return { - success: true, - data: result.rows, - rowCount: result.rowCount, - executionTime: result.executionTime, - }; - } catch (error: any) { - // Возвращаем ошибку как объект, а не бросаем исключение - return { - success: false, - error: error.message, - data: [], - rowCount: 0, - }; - } - } else { - // SQL запрос - if (!query.sql) { - throw new Error(`SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`); - } - - try { - let processedQuery = query.sql; - const paramValues: any[] = []; - const paramMatches = query.sql.match(/\$\w+/g) || []; - const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))]; - - uniqueParams.forEach((paramName, index) => { - const regex = new RegExp(`\\$${paramName}\\b`, 'g'); - processedQuery = processedQuery.replace(regex, `$${index + 1}`); - const value = allParams[paramName]; - paramValues.push(value !== undefined ? value : null); - }); - - const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues); - - console.log(`[execQuery ${queryName}] success, rowCount:`, result.rowCount); - return { - success: true, - data: result.rows, - rowCount: result.rowCount, - executionTime: result.executionTime, - }; - } catch (error: any) { - // Возвращаем ошибку как объект, а не бросаем исключение - return { - success: false, - error: error.message, - data: [], - rowCount: 0, - }; - } - } - }; - - // Создаем асинхронную функцию из кода пользователя - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const userFunction = new AsyncFunction('params', 'execQuery', code); - - // Устанавливаем таймаут (10 минут) - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Script execution timeout (10min)')), 600000); - }); - - // Выполняем скрипт с таймаутом - const result = await Promise.race([ - userFunction(context.requestParams, execQuery), - timeoutPromise - ]); - - return result; - } catch (error: any) { - throw new Error(`JavaScript execution error: ${error.message}`); - } + async executeJavaScript(code: string, context: ScriptContext): Promise { + return isolatedScriptExecutor.execute(code, context); } /** * Выполняет Python скрипт в отдельном процессе */ - async executePython(code: string, context: ScriptContext): Promise { + async executePython(code: string, context: ScriptContext): Promise { + const logs: LogEntry[] = []; + const queries: QueryExecution[] = []; + return new Promise((resolve, reject) => { // Сериализуем параметры в JSON строку const paramsJson = JSON.stringify(context.requestParams); @@ -179,7 +77,6 @@ print(json.dumps(result)) const python = spawn(pythonCommand, ['-c', wrapperCode]); let output = ''; let errorOutput = ''; - let queryRequests: any[] = []; python.stdout.on('data', (data) => { output += data.toString(); @@ -192,12 +89,19 @@ print(json.dumps(result)) // Проверяем на запросы к БД const requestMatches = text.matchAll(/__QUERY_REQUEST__(.*?)__END_REQUEST__/g); for (const match of requestMatches) { + const queryStartTime = Date.now(); try { const request = JSON.parse(match[1]); // Выполняем запрос const query = context.scriptQueries.find(q => q.name === request.query_name); if (!query) { + queries.push({ + name: request.query_name, + executionTime: Date.now() - queryStartTime, + success: false, + error: `Query '${request.query_name}' not found`, + }); python.stdin.write(JSON.stringify({ error: `Query '${request.query_name}' not found` }) + '\n'); continue; } @@ -206,18 +110,18 @@ print(json.dumps(result)) const dbId = (query as any).database_id || context.databaseId; if (!dbId) { - python.stdin.write(JSON.stringify({ - error: `Database ID not found for query '${request.query_name}'. Query database_id: ${(query as any).database_id}, Context databaseId: ${context.databaseId}. Please specify database_id in the Script Queries configuration for query '${request.query_name}'.` - }) + '\n'); + const errMsg = `Database ID not found for query '${request.query_name}'.`; + queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg }); + python.stdin.write(JSON.stringify({ error: errMsg }) + '\n'); continue; } // Получаем конфигурацию базы данных для определения типа const dbConfig = await databasePoolManager.getDatabaseConfig(dbId); if (!dbConfig) { - python.stdin.write(JSON.stringify({ - error: `Database configuration not found for ID: ${dbId}` - }) + '\n'); + const errMsg = `Database configuration not found for ID: ${dbId}`; + queries.push({ name: request.query_name, executionTime: Date.now() - queryStartTime, success: false, error: errMsg }); + python.stdin.write(JSON.stringify({ error: errMsg }) + '\n'); continue; } @@ -233,6 +137,13 @@ print(json.dumps(result)) parameters: allParams, }); + queries.push({ + name: request.query_name, + executionTime: Date.now() - queryStartTime, + rowCount: result.rowCount, + success: true, + }); + python.stdin.write(JSON.stringify({ success: true, data: result.rows, @@ -240,7 +151,12 @@ print(json.dumps(result)) executionTime: result.executionTime, }) + '\n'); } catch (error: any) { - // Отправляем ошибку как объект, а не через поле error + queries.push({ + name: request.query_name, + executionTime: Date.now() - queryStartTime, + success: false, + error: error.message, + }); python.stdin.write(JSON.stringify({ success: false, error: error.message, @@ -251,9 +167,11 @@ print(json.dumps(result)) } else { // SQL запрос if (!query.sql) { + const errMsg = `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`; + queries.push({ name: request.query_name, sql: query.sql, executionTime: Date.now() - queryStartTime, success: false, error: errMsg }); python.stdin.write(JSON.stringify({ success: false, - error: `SQL query is required for database '${dbConfig.name}' (type: ${dbConfig.type})`, + error: errMsg, data: [], rowCount: 0, }) + '\n'); @@ -280,6 +198,14 @@ print(json.dumps(result)) paramValues ); + queries.push({ + name: request.query_name, + sql: query.sql, + executionTime: Date.now() - queryStartTime, + rowCount: result.rowCount, + success: true, + }); + python.stdin.write(JSON.stringify({ success: true, data: result.rows, @@ -287,6 +213,13 @@ print(json.dumps(result)) executionTime: result.executionTime, }) + '\n'); } catch (error: any) { + queries.push({ + name: request.query_name, + sql: query.sql, + executionTime: Date.now() - queryStartTime, + success: false, + error: error.message, + }); python.stdin.write(JSON.stringify({ success: false, error: error.message, @@ -296,6 +229,12 @@ print(json.dumps(result)) } } } catch (error: any) { + queries.push({ + name: 'unknown', + executionTime: Date.now() - queryStartTime, + success: false, + error: error.message, + }); python.stdin.write(JSON.stringify({ success: false, error: error.message, @@ -304,18 +243,38 @@ print(json.dumps(result)) }) + '\n'); } } + + // Capture non-query stderr output as log entries + const nonQueryLines = text.replace(/__QUERY_REQUEST__.*?__END_REQUEST__/g, '').trim(); + if (nonQueryLines) { + nonQueryLines.split('\n').forEach((line: string) => { + const trimmed = line.trim(); + if (trimmed) { + logs.push({ type: 'log', message: trimmed, timestamp: Date.now() }); + } + }); + } }); - python.on('close', (code) => { - if (code !== 0) { + python.on('close', (exitCode) => { + if (exitCode !== 0) { reject(new Error(`Python execution error: ${errorOutput}`)); } else { try { - // Последняя строка вывода - результат + // Последняя строка вывода - результат, остальные - логи const lines = output.trim().split('\n'); const resultLine = lines[lines.length - 1]; + + // Capture print() output lines (everything except the last JSON result) + for (let i = 0; i < lines.length - 1; i++) { + const trimmed = lines[i].trim(); + if (trimmed) { + logs.push({ type: 'log', message: trimmed, timestamp: Date.now() }); + } + } + const result = JSON.parse(resultLine); - resolve(result); + resolve({ result, logs, queries }); } catch (error) { reject(new Error(`Failed to parse Python output: ${output}`)); } @@ -337,7 +296,7 @@ print(json.dumps(result)) language: 'javascript' | 'python', code: string, context: ScriptContext - ): Promise { + ): Promise { if (language === 'javascript') { return this.executeJavaScript(code, context); } else if (language === 'python') { diff --git a/backend/src/services/endpointCrypto.ts b/backend/src/services/endpointCrypto.ts new file mode 100644 index 0000000..2c82377 --- /dev/null +++ b/backend/src/services/endpointCrypto.ts @@ -0,0 +1,35 @@ +import crypto from 'crypto'; + +const ENCRYPTION_KEY = 'kis-api-builder-endpoint-key-32b'; // exactly 32 bytes for AES-256 +const ALGORITHM = 'aes-256-gcm'; + +export function encryptEndpointData(data: object): Buffer { + const json = JSON.stringify(data); + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'utf-8'), iv); + + const encrypted = Buffer.concat([ + cipher.update(json, 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + // Format: [16 bytes IV][16 bytes authTag][...encrypted data] + return Buffer.concat([iv, authTag, encrypted]); +} + +export function decryptEndpointData(buffer: Buffer): object { + const iv = buffer.subarray(0, 16); + const authTag = buffer.subarray(16, 32); + const encrypted = buffer.subarray(32); + + const decipher = crypto.createDecipheriv(ALGORITHM, Buffer.from(ENCRYPTION_KEY, 'utf-8'), iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return JSON.parse(decrypted.toString('utf8')); +} diff --git a/backend/src/types/index.ts b/backend/src/types/index.ts index 746e472..acbde92 100644 --- a/backend/src/types/index.ts +++ b/backend/src/types/index.ts @@ -101,6 +101,27 @@ export interface QueryResult { executionTime: number; } +export interface LogEntry { + type: 'log' | 'error' | 'warn' | 'info'; + message: string; + timestamp: number; +} + +export interface QueryExecution { + name: string; + sql?: string; + executionTime: number; + rowCount?: number; + success: boolean; + error?: string; +} + +export interface IsolatedExecutionResult { + result: any; + logs: LogEntry[]; + queries: QueryExecution[]; +} + export interface SwaggerEndpoint { tags: string[]; summary: string; @@ -109,3 +130,38 @@ export interface SwaggerEndpoint { responses: any; security?: any[]; } + +export interface ExportedScriptQuery { + name: string; + sql?: string; + database_name?: string; + database_type?: string; + aql_method?: string; + aql_endpoint?: string; + aql_body?: string; + aql_query_params?: Record; +} + +export interface ExportedEndpoint { + _format: 'kabe_v1'; + name: string; + description: string; + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; + execution_type: 'sql' | 'script' | 'aql'; + database_name: string | null; + database_type: string | null; + sql_query: string; + parameters: EndpointParameter[]; + script_language: string | null; + script_code: string | null; + script_queries: ExportedScriptQuery[]; + aql_method: string | null; + aql_endpoint: string | null; + aql_body: string | null; + aql_query_params: Record | null; + is_public: boolean; + enable_logging: boolean; + detailed_response: boolean; + folder_name: string | null; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2ec7b2f..6fc27ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,11 @@ "name": "kis-api-builder-frontend", "version": "1.0.0", "dependencies": { + "@dagrejs/dagre": "^1.1.8", "@hookform/resolvers": "^3.3.3", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.14.2", + "@xyflow/react": "^12.10.0", "axios": "^1.6.2", "clsx": "^2.0.0", "cmdk": "^0.2.0", @@ -369,6 +371,24 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.8.tgz", + "integrity": "sha512-5SEDlndt4W/LaVzPYJW+bSmSEZc9EzTf8rJ20WCKvjS5EAZAN0b+x0Yww7VMT4R3Wootkg+X9bUfUxazYw6Blw==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "2.2.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz", + "integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==", + "license": "MIT", + "engines": { + "node": ">17.0.0" + } + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -1724,6 +1744,55 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2009,6 +2078,38 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2383,6 +2484,12 @@ "node": ">= 6" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2495,6 +2602,111 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 14f1139..d2deb9e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,11 +14,11 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@dagrejs/dagre": "^1.1.8", "@hookform/resolvers": "^3.3.3", "@monaco-editor/react": "^4.6.0", "@tanstack/react-query": "^5.14.2", - "@xyflow/react": "^12.0.0", - "@dagrejs/dagre": "^1.1.4", + "@xyflow/react": "^12.10.0", "axios": "^1.6.2", "clsx": "^2.0.0", "cmdk": "^0.2.0", diff --git a/frontend/src/components/EndpointModal.tsx b/frontend/src/components/EndpointModal.tsx index 171fe5e..0b136e0 100644 --- a/frontend/src/components/EndpointModal.tsx +++ b/frontend/src/components/EndpointModal.tsx @@ -1,8 +1,8 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'; import { endpointsApi, foldersApi } from '@/services/api'; -import { Endpoint, EndpointParameter } from '@/types'; -import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp } from 'lucide-react'; +import { Endpoint, EndpointParameter, QueryTestResult, LogEntry, QueryExecution } from '@/types'; +import { Plus, Trash2, Play, Edit2, ChevronDown, ChevronUp, X, CheckCircle, XCircle, Clock } from 'lucide-react'; import toast from 'react-hot-toast'; import SqlEditor from '@/components/SqlEditor'; import CodeEditor from '@/components/CodeEditor'; @@ -49,6 +49,8 @@ export default function EndpointModal({ const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false); const [parametersExpanded, setParametersExpanded] = useState(true); const [queriesExpanded, setQueriesExpanded] = useState(true); + const [testResult, setTestResult] = useState(null); + const [activeResultTab, setActiveResultTab] = useState<'data' | 'logs' | 'queries'>('data'); // Определяем тип выбранной базы данных const selectedDatabase = databases.find(db => db.id === formData.database_id); @@ -65,7 +67,39 @@ export default function EndpointModal({ onError: () => toast.error('Не удалось сохранить эндпоинт'), }); - const [testParams, setTestParams] = useState({}); + // Restore test params and result from localStorage + const storageKey = endpoint?.id ? `test_${endpoint.id}` : null; + const [testParams, setTestParams] = useState(() => { + if (storageKey) { + try { + const saved = localStorage.getItem(storageKey); + if (saved) return JSON.parse(saved).testParams || {}; + } catch {} + } + return {}; + }); + + // Restore testResult from localStorage on mount + useEffect(() => { + if (storageKey) { + try { + const saved = localStorage.getItem(storageKey); + if (saved) { + const parsed = JSON.parse(saved); + if (parsed.testResult) setTestResult(parsed.testResult); + } + } catch {} + } + }, [storageKey]); + + // Save testParams and testResult to localStorage + useEffect(() => { + if (storageKey) { + try { + localStorage.setItem(storageKey, JSON.stringify({ testParams, testResult })); + } catch {} + } + }, [storageKey, testParams, testResult]); const testMutation = useMutation({ mutationFn: () => { @@ -120,10 +154,20 @@ export default function EndpointModal({ } }, onSuccess: (response) => { - toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`); + setTestResult(response.data); + setActiveResultTab('data'); }, onError: (error: any) => { - toast.error(error.response?.data?.error || 'Ошибка тестирования запроса'); + const errorData = error.response?.data; + setTestResult({ + success: false, + error: errorData?.error || error.message || 'Ошибка тестирования запроса', + detail: errorData?.detail, + hint: errorData?.hint, + logs: errorData?.logs || [], + queries: errorData?.queries || [], + }); + setActiveResultTab('data'); }, }); @@ -689,6 +733,202 @@ export default function EndpointModal({ {saveMutation.isPending ? 'Сохранение...' : 'Сохранить эндпоинт'} + + {/* Test Results Panel */} + {testResult && ( +
+ {/* Status bar */} +
+
+ {testResult.success ? ( + + ) : ( + + )} + + {testResult.success ? 'Успешно' : 'Ошибка'} + + {testResult.executionTime !== undefined && ( + + {testResult.executionTime}мс + + )} + {testResult.rowCount !== undefined && ( + + | {testResult.rowCount} строк + + )} +
+ +
+ + {/* Tabs */} +
+ + + {formData.execution_type === 'script' && ( + + )} +
+ + {/* Tab content */} +
+ {activeResultTab === 'data' && ( +
+ {!testResult.success ? ( +
+
+
{testResult.error}
+ {testResult.detail &&
{testResult.detail}
} + {testResult.hint &&
Hint: {testResult.hint}
} +
+
+ ) : testResult.data !== undefined ? ( + (() => { + // Normalize data to array for table rendering + const dataArray = Array.isArray(testResult.data) ? testResult.data : [testResult.data]; + if (dataArray.length === 0) { + return

Нет данных

; + } + // Get columns from first row + const firstRow = dataArray[0]; + if (typeof firstRow !== 'object' || firstRow === null) { + return ( +
+                              {JSON.stringify(testResult.data, null, 2)}
+                            
+ ); + } + const columns = Object.keys(firstRow); + return ( +
+ + + + + {columns.map(col => ( + + ))} + + + + {dataArray.slice(0, 100).map((row: any, idx: number) => ( + + + {columns.map(col => ( + + ))} + + ))} + +
#{col}
{idx + 1} + {row[col] === null ? null : typeof row[col] === 'object' ? JSON.stringify(row[col]) : String(row[col])} +
+ {dataArray.length > 100 && ( +

Показано 100 из {dataArray.length} строк

+ )} +
+ ); + })() + ) : ( +

Нет данных

+ )} +
+ )} + + {activeResultTab === 'logs' && ( +
+ {testResult.processedQuery && ( +
+
Обработанный запрос:
+
{testResult.processedQuery}
+
+ )} + {(!testResult.logs || testResult.logs.length === 0) ? ( +

Нет логов

+ ) : ( + testResult.logs.map((log: LogEntry, idx: number) => ( +
+ {new Date(log.timestamp).toLocaleTimeString()} + [{log.type}] + {log.message} +
+ )) + )} +
+ )} + + {activeResultTab === 'queries' && ( +
+ {(!testResult.queries || testResult.queries.length === 0) ? ( +

Нет запросов

+ ) : ( + testResult.queries.map((q: QueryExecution, idx: number) => ( +
+
+
+ {q.success ? : } + {q.name} +
+
+ {q.executionTime}мс + {q.rowCount !== undefined && {q.rowCount} строк} +
+
+ {q.sql && ( +
{q.sql}
+ )} + {q.error && ( +
{q.error}
+ )} +
+ )) + )} +
+ )} +
+
+ )} diff --git a/frontend/src/components/ImportEndpointModal.tsx b/frontend/src/components/ImportEndpointModal.tsx new file mode 100644 index 0000000..cc4f265 --- /dev/null +++ b/frontend/src/components/ImportEndpointModal.tsx @@ -0,0 +1,222 @@ +import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { endpointsApi } from '@/services/api'; +import { ImportPreviewResponse } from '@/types'; +import { X, AlertTriangle, CheckCircle, Database, ArrowRight } from 'lucide-react'; +import toast from 'react-hot-toast'; + +interface ImportEndpointModalProps { + preview: ImportPreviewResponse; + file: File; + onClose: () => void; +} + +export default function ImportEndpointModal({ preview, file, onClose }: ImportEndpointModalProps) { + const queryClient = useQueryClient(); + + const [databaseMapping, setDatabaseMapping] = useState>(() => { + const initial: Record = {}; + preview.databases.forEach(db => { + if (db.found && db.local_id) { + initial[db.name] = db.local_id; + } + }); + return initial; + }); + + const [overridePath, setOverridePath] = useState(preview.endpoint.path); + const [folderId] = useState( + preview.folder?.found ? preview.folder.local_id : null + ); + + const importMutation = useMutation({ + mutationFn: async () => { + const arrayBuffer = await file.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + + return endpointsApi.importConfirm({ + file_data: base64, + database_mapping: databaseMapping, + folder_id: folderId, + override_path: preview.path_exists ? overridePath : undefined, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['endpoints'] }); + toast.success('Эндпоинт успешно импортирован'); + onClose(); + }, + onError: (error: any) => { + toast.error(error.response?.data?.error || 'Ошибка импорта эндпоинта'); + }, + }); + + const allMapped = preview.databases.every( + db => db.found || databaseMapping[db.name] + ); + + const handleMappingChange = (sourceName: string, localId: string) => { + setDatabaseMapping(prev => ({ ...prev, [sourceName]: localId })); + }; + + const methodColor = (method: string) => { + switch (method) { + case 'GET': return 'bg-green-100 text-green-700'; + case 'POST': return 'bg-blue-100 text-blue-700'; + case 'PUT': return 'bg-yellow-100 text-yellow-700'; + case 'DELETE': return 'bg-red-100 text-red-700'; + default: return 'bg-gray-100 text-gray-700'; + } + }; + + return ( +
+
+ {/* Header */} +
+

Импорт эндпоинта

+ +
+ +
+ {/* Endpoint Preview */} +
+

Информация об эндпоинте

+
+
+ Название: + {preview.endpoint.name} +
+
+ Метод: + + {preview.endpoint.method} + +
+
+ Путь: + {preview.endpoint.path} +
+
+ Тип: + {preview.endpoint.execution_type} +
+ {preview.endpoint.description && ( +
+ Описание: + {preview.endpoint.description} +
+ )} +
+
+ + {/* Path conflict */} + {preview.path_exists && ( +
+
+ + Путь уже существует +
+

+ Эндпоинт с путем {preview.endpoint.path} уже существует. Укажите другой путь: +

+ setOverridePath(e.target.value)} + className="input w-full" + /> +
+ )} + + {/* Database Mapping */} + {preview.databases.length > 0 && ( +
+

Сопоставление баз данных

+
+ {preview.databases.map((db) => ( +
+
+ +
+ {db.name} + ({db.type}) +
+
+ +
+ {db.found ? ( +
+ + Найдена +
+ ) : ( + + )} +
+
+ ))} +
+
+ )} + + {/* Folder info */} + {preview.folder && !preview.folder.found && ( +
+

+ Папка "{preview.folder.name}" не найдена. Эндпоинт будет импортирован в корневую папку. +

+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/SqlEditor.tsx b/frontend/src/components/SqlEditor.tsx index 03686b2..931cc33 100644 --- a/frontend/src/components/SqlEditor.tsx +++ b/frontend/src/components/SqlEditor.tsx @@ -11,6 +11,7 @@ interface SqlEditorProps { onChange: (value: string) => void; databaseId?: string; height?: string; + tabId?: string; } // Cache for schema with 5-minute expiration @@ -138,7 +139,7 @@ function getFkSuggestions( return suggestions; } -export default function SqlEditor({ value, onChange, databaseId, height }: SqlEditorProps) { +export default function SqlEditor({ value, onChange, databaseId, height, tabId }: SqlEditorProps) { const editorRef = useRef(null); const monacoRef = useRef(null); @@ -325,7 +326,8 @@ export default function SqlEditor({ value, onChange, databaseId, height }: SqlEd
onChange(value || '')} onMount={handleEditorDidMount} diff --git a/frontend/src/pages/Endpoints.tsx b/frontend/src/pages/Endpoints.tsx index f9c40b2..0034612 100644 --- a/frontend/src/pages/Endpoints.tsx +++ b/frontend/src/pages/Endpoints.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react'; +import { useState, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { endpointsApi, databasesApi } from '@/services/api'; -import { Endpoint } from '@/types'; -import { Plus, Search, Edit2, Trash2 } from 'lucide-react'; +import { Endpoint, ImportPreviewResponse } from '@/types'; +import { Plus, Search, Edit2, Trash2, Download, Upload } from 'lucide-react'; import toast from 'react-hot-toast'; import EndpointModal from '@/components/EndpointModal'; +import ImportEndpointModal from '@/components/ImportEndpointModal'; import Dialog from '@/components/Dialog'; export default function Endpoints() { @@ -12,6 +13,10 @@ export default function Endpoints() { const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); const [editingEndpoint, setEditingEndpoint] = useState(null); + const [showImportModal, setShowImportModal] = useState(false); + const [importFile, setImportFile] = useState(null); + const [importPreview, setImportPreview] = useState(null); + const fileInputRef = useRef(null); const [dialog, setDialog] = useState<{ isOpen: boolean; title: string; @@ -66,6 +71,42 @@ export default function Endpoints() { setShowModal(true); }; + const handleExport = async (endpointId: string, endpointName: string) => { + try { + const response = await endpointsApi.exportEndpoint(endpointId); + const blob = new Blob([response.data]); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${endpointName.replace(/[^a-zA-Z0-9_\-а-яА-ЯёЁ]/g, '_')}.kabe`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Эндпоинт экспортирован'); + } catch { + toast.error('Ошибка экспорта эндпоинта'); + } + }; + + const handleImportFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.name.endsWith('.kabe')) { + toast.error('Выберите файл с расширением .kabe'); + return; + } + try { + const response = await endpointsApi.importPreview(file); + setImportFile(file); + setImportPreview(response.data); + setShowImportModal(true); + } catch (error: any) { + toast.error(error.response?.data?.error || 'Ошибка чтения файла'); + } + e.target.value = ''; + }; + return (
@@ -73,10 +114,26 @@ export default function Endpoints() {

API Эндпоинты

Управление динамическими API эндпоинтами

- +
+ + + +
@@ -138,6 +195,13 @@ export default function Endpoints() { )}
+
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index bfc28c8..39ba6c8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,5 @@ import axios from 'axios'; -import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult } from '@/types'; +import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult, ImportPreviewResponse } from '@/types'; const api = axios.create({ baseURL: '/api', @@ -109,15 +109,37 @@ export const endpointsApi = { test: (data: { database_id: string; - execution_type?: 'sql' | 'script'; + execution_type?: 'sql' | 'script' | 'aql'; sql_query?: string; parameters?: any[]; endpoint_parameters?: any[]; script_language?: 'javascript' | 'python'; script_code?: string; script_queries?: any[]; + aql_method?: string; + aql_endpoint?: string; + aql_body?: string; + aql_query_params?: Record; }) => api.post('/endpoints/test', data), + + exportEndpoint: (id: string) => + api.get(`/endpoints/${id}/export`, { responseType: 'blob' }), + + importPreview: (file: File) => + file.arrayBuffer().then(buffer => + api.post('/endpoints/import/preview', buffer, { + headers: { 'Content-Type': 'application/octet-stream' }, + }) + ), + + importConfirm: (data: { + file_data: string; + database_mapping: Record; + folder_id?: string | null; + override_path?: string; + }) => + api.post('/endpoints/import', data), }; // Folders API diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index cc1007d..3075b97 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -95,10 +95,62 @@ export interface ApiKey { expires_at: string | null; } +export interface LogEntry { + type: 'log' | 'error' | 'warn' | 'info'; + message: string; + timestamp: number; +} + +export interface QueryExecution { + name: string; + sql?: string; + executionTime: number; + rowCount?: number; + success: boolean; + error?: string; +} + export interface QueryTestResult { success: boolean; - data?: any[]; + data?: any; rowCount?: number; executionTime?: number; error?: string; + detail?: string; + hint?: string; + logs: LogEntry[]; + queries: QueryExecution[]; + processedQuery?: string; +} + +export interface ImportPreviewDatabase { + name: string; + type: string; + found: boolean; + local_id: string | null; +} + +export interface ImportPreviewFolder { + name: string; + found: boolean; + local_id: string | null; +} + +export interface ImportPreviewResponse { + endpoint: { + name: string; + description: string; + method: string; + path: string; + execution_type: string; + is_public: boolean; + enable_logging: boolean; + detailed_response: boolean; + folder_name: string | null; + }; + databases: ImportPreviewDatabase[]; + all_databases_found: boolean; + local_databases: { id: string; name: string; type: string }[]; + folder: ImportPreviewFolder | null; + path_exists: boolean; }