From e28848146ddc14b945d5ecaa0e9e8866ee7a6205 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 1 Nov 2025 17:25:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=84?= =?UTF-8?q?=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app.js | 2 + backend/routes/auth.js | 54 ++- backend/routes/chat.js | 41 ++- backend/routes/consent.js | 289 ++++++++++++++++ backend/services/UniversalGuestService.js | 165 ++++++++- backend/services/auth-service.js | 41 ++- backend/services/consentService.js | 263 ++++++++++++++ backend/services/emailBot.js | 51 ++- backend/services/encryptedDatabaseService.js | 52 ++- backend/services/session-service.js | 40 +++ backend/services/telegramBot.js | 2 + backend/services/unifiedMessageProcessor.js | 118 ++++++- frontend/src/components/ChatInterface.vue | 8 + frontend/src/components/ConsentModal.vue | 341 +++++++++++++++++++ frontend/src/components/Message.vue | 170 ++++++++- frontend/src/composables/useChat.js | 12 +- frontend/src/services/wallet.js | 22 +- frontend/src/utils/wallet.js | 67 +++- frontend/src/views/HomeView.vue | 9 + 19 files changed, 1680 insertions(+), 67 deletions(-) create mode 100644 backend/routes/consent.js create mode 100644 backend/services/consentService.js create mode 100644 frontend/src/components/ConsentModal.vue diff --git a/backend/app.js b/backend/app.js index 3ce3800..f71ac37 100644 --- a/backend/app.js +++ b/backend/app.js @@ -108,6 +108,7 @@ const dleAnalyticsRoutes = require('./routes/dleAnalytics'); // Аналитик const compileRoutes = require('./routes/compile'); // Компиляция контрактов const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга +const consentRoutes = require('./routes/consent'); // Добавляем импорт маршрутов согласий const app = express(); @@ -283,6 +284,7 @@ app.use('/api/identities', identitiesRoutes); app.use('/api/rag', ragRoutes); // Подключаем роут app.use('/api/monitoring', monitoringRoutes); app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц +app.use('/api/consent', consentRoutes); // Добавляем маршрут согласий app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы) app.use('/api/ens', ensRoutes); // ENS utilities diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 01ce70c..2bd1b48 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -25,6 +25,9 @@ const emailAuth = require('../services/emailAuth'); const verificationService = require('../services/verification-service'); const identityService = require('../services/identity-service'); const sessionService = require('../services/session-service'); +// Используем централизованный сервис для работы с согласиями +const consentService = require('../services/consentService'); +const { DOCUMENT_CONSENT_MAP } = consentService; // Создаем лимитер для попыток аутентификации const authLimiter = rateLimit({ @@ -173,30 +176,60 @@ router.post('/verify', async (req, res) => { const origin = req.get('origin') || 'http://localhost:5173'; const domain = new URL(origin).host; // Извлекаем домен из origin + // Получаем список документов для подписания и добавляем их в resources + const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP); + const tableName = 'admin_pages_simple'; + const tableExistsRes = await db.getQuery()( + `SELECT to_regclass($1) as exists`, [tableName] + ); + + let resources = [`${origin}/api/auth/verify`]; + if (tableExistsRes.rows[0].exists) { + const { rows: documents } = await db.getQuery()(` + SELECT id FROM ${tableName} + WHERE status = 'published' + AND visibility = 'public' + AND title = ANY($1) + `, [documentTitles]); + + // Добавляем ссылки на документы в resources + documents.forEach(doc => { + resources.push(`${origin}/content/published/${doc.id}`); + }); + } + + // Сортируем resources для консистентности (должно совпадать с фронтендом) + resources = resources.sort(); + + // Используем issuedAt из запроса, если он есть, иначе создаем новый + const messageIssuedAt = issuedAt || new Date().toISOString(); + const { SiweMessage } = require('siwe'); const message = new SiweMessage({ domain, address: normalizedAddress, - statement: 'Sign in with Ethereum to the app.', + statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.', uri: origin, version: '1', chainId: 1, nonce: nonce, - issuedAt: issuedAt || new Date().toISOString(), - resources: [`${origin}/api/auth/verify`], + issuedAt: messageIssuedAt, + resources: resources, }); const messageToSign = message.prepareMessage(); logger.info(`[verify] SIWE message for verification: ${messageToSign}`); + logger.info(`[verify] Resources: ${JSON.stringify(resources)}`); + logger.info(`[verify] IssuedAt: ${messageIssuedAt}`); logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`); logger.info(`[verify] Normalized address: ${normalizedAddress}`); logger.info(`[verify] Request headers origin: ${req.get('origin')}`); logger.info(`[verify] Request headers host: ${req.get('host')}`); logger.info(`[verify] Request headers referer: ${req.get('referer')}`); - // Проверяем подпись - const isValid = await authService.verifySignature(messageToSign, signature, normalizedAddress); + // Проверяем подпись через SiweMessage.verify() (передаем объект сообщения, а не строку) + const isValid = await authService.verifySignature(message, signature, normalizedAddress); if (!isValid) { logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`); return res.status(401).json({ success: false, error: 'Invalid signature' }); @@ -257,6 +290,15 @@ router.post('/verify', async (req, res) => { // Связываем гостевые сообщения с пользователем await sessionService.linkGuestMessages(req.session, userId); + // Проверяем согласия пользователя через централизованный сервис + const consentCheck = await consentService.checkConsents({ + userId, + walletAddress: normalizedAddress + }); + + const needsConsent = consentCheck.needsConsent; + const missingConsents = consentCheck.missingConsents; + // Возвращаем успешный ответ userAccessLevel = await authService.getUserAccessLevel(normalizedAddress); return res.json({ @@ -265,6 +307,8 @@ router.post('/verify', async (req, res) => { address: normalizedAddress, // Возвращаем нормализованный адрес userAccessLevel: userAccessLevel, authenticated: true, + needsConsent: needsConsent, + missingConsents: missingConsents, }); } catch (error) { logger.error('[verify] Error:', error); diff --git a/backend/routes/chat.js b/backend/routes/chat.js index d11124b..2389f01 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -25,6 +25,14 @@ const aiAssistantRulesService = require('../services/aiAssistantRulesService'); const botManager = require('../services/botManager'); const universalMediaProcessor = require('../services/UniversalMediaProcessor'); +// Маппинг названий документов на типы согласий +const DOCUMENT_CONSENT_MAP = { + 'Политика конфиденциальности': 'privacy_policy', + 'Права субъектов персональных данных и отзыв согласия': 'personal_data', + 'Согласие на использование файлов cookie': 'cookies', + 'Согласие на обработку персональных данных': 'personal_data_processing', +}; + // Настройка multer для обработки файлов в памяти const storage = multer.memoryStorage(); const upload = multer({ storage: storage }); @@ -143,6 +151,7 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => { }; // Обработка через unified processor + // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора const result = await unifiedMessageProcessor.processMessage(messageData); logger.info('[Chat] Результат обработки:', { @@ -151,13 +160,25 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => { aiResponseType: typeof result.aiResponse?.response }); - res.json({ + // Формируем ответ + // Системное сообщение уже включено в ответ ИИ (если нужно) + const response = { success: true, guestId: webGuestId, aiResponse: result.aiResponse ? { response: result.aiResponse.response } : null - }); + }; + + // Добавляем информацию о согласиях из результата (если есть) + if (result.consentRequired) { + response.consentRequired = result.consentRequired; + response.missingConsents = result.missingConsents; + response.consentDocuments = result.consentDocuments; + response.autoConsentOnReply = result.autoConsentOnReply; + } + + res.json(response); } catch (error) { logger.error('[Chat] Ошибка обработки гостевого сообщения:', error); @@ -268,9 +289,11 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re }; // Обработка через unified processor + // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора const result = await unifiedMessageProcessor.processMessage(messageData); - res.json({ + // Формируем ответ с информацией о согласиях + const response = { success: true, userMessageId: result.userMessageId, conversationId: result.conversationId, @@ -278,7 +301,17 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re response: result.aiResponse.response } : null, noAiResponse: result.noAiResponse - }); + }; + + // Добавляем информацию о согласиях из результата (если есть) + if (result.consentRequired) { + response.consentRequired = result.consentRequired; + response.missingConsents = result.missingConsents; + response.consentDocuments = result.consentDocuments; + response.autoConsentOnReply = result.autoConsentOnReply; + } + + res.json(response); } catch (error) { logger.error('[Chat] Ошибка обработки сообщения:', error); diff --git a/backend/routes/consent.js b/backend/routes/consent.js new file mode 100644 index 0000000..4a1ab0e --- /dev/null +++ b/backend/routes/consent.js @@ -0,0 +1,289 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +const express = require('express'); +const router = express.Router(); +const db = require('../db'); +const logger = require('../utils/logger'); +const { requireAuth } = require('../middleware/auth'); +const consentService = require('../services/consentService'); +const { DOCUMENT_CONSENT_MAP } = consentService; + +// Получить список документов для подписания +router.get('/documents', async (req, res) => { + try { + const tableName = 'admin_pages_simple'; + + // Проверяем, есть ли таблица + const existsRes = await db.getQuery()( + `SELECT to_regclass($1) as exists`, [tableName] + ); + + if (!existsRes.rows[0].exists) { + return res.json([]); + } + + // Получаем документы для подписания по названиям + const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP); + const { rows } = await db.getQuery()(` + SELECT id, title, summary, content, created_at, updated_at + FROM ${tableName} + WHERE status = 'published' + AND visibility = 'public' + AND title = ANY($1) + ORDER BY created_at DESC + `, [documentTitles]); + + // Добавляем тип согласия к каждому документу + const documents = rows.map(doc => ({ + ...doc, + consentType: DOCUMENT_CONSENT_MAP[doc.title] || null, + })); + + res.json(documents); + } catch (error) { + logger.error('Error fetching consent documents:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Проверить, подписал ли пользователь необходимые документы +router.get('/status', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const walletAddress = req.session.address; + + if (!userId && !walletAddress) { + return res.status(400).json({ error: 'Требуется авторизация' }); + } + + // Получаем все необходимые типы согласий + const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP); + + // Получаем активные согласия пользователя + let query = ` + SELECT consent_type, status, signed_at, document_id, document_title + FROM consent_logs + WHERE status = 'granted' + AND ( + `; + + const params = []; + if (userId) { + query += `user_id = $${params.length + 1}`; + params.push(userId); + } + if (walletAddress) { + if (params.length > 0) query += ' OR '; + query += `wallet_address = $${params.length + 1}`; + params.push(walletAddress); + } + + query += `) + AND consent_type = ANY($${params.length + 1}) + ORDER BY signed_at DESC + `; + params.push(requiredConsentTypes); + + const { rows } = await db.getQuery()(query, params); + + // Формируем статус для каждого типа согласия + const status = {}; + requiredConsentTypes.forEach(type => { + const consent = rows.find(r => r.consent_type === type); + status[type] = consent ? { + granted: true, + signedAt: consent.signed_at, + documentId: consent.document_id, + documentTitle: consent.document_title, + } : { + granted: false, + }; + }); + + // Проверяем, все ли согласия предоставлены + const allGranted = requiredConsentTypes.every(type => status[type].granted); + + res.json({ + allGranted, + status, + }); + } catch (error) { + logger.error('Error checking consent status:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Сохранить согласие пользователя +router.post('/grant', async (req, res) => { + try { + // Разрешаем подпись как для авторизованных, так и для гостей + const userId = req.session?.userId || null; + const walletAddress = req.session?.address || null; + const guestId = req.session?.guestId || null; + const { documentIds, consentTypes } = req.body; // Массивы ID документов и типов согласий + + // Если нет ни userId, ни walletAddress, используем guestId для идентификации + if (!userId && !walletAddress && !guestId) { + return res.status(400).json({ error: 'Требуется идентификация (авторизация или гостевая сессия)' }); + } + + if (!documentIds || !Array.isArray(documentIds) || documentIds.length === 0) { + return res.status(400).json({ error: 'Требуется указать документы для подписания' }); + } + + // Получаем информацию о документах + const { rows: documents } = await db.getQuery()(` + SELECT id, title + FROM admin_pages_simple + WHERE id = ANY($1) AND status = 'published' + `, [documentIds]); + + if (documents.length !== documentIds.length) { + return res.status(400).json({ error: 'Некоторые документы не найдены' }); + } + + const ipAddress = req.ip || req.connection.remoteAddress; + const userAgent = req.get('user-agent'); + + // Сохраняем согласия для каждого документа + const results = []; + for (let i = 0; i < documents.length; i++) { + const doc = documents[i]; + const consentType = consentTypes && consentTypes[i] + ? consentTypes[i] + : DOCUMENT_CONSENT_MAP[doc.title]; + + if (!consentType) { + logger.warn(`Unknown consent type for document: ${doc.title}`); + continue; + } + + // Проверяем, есть ли уже активное согласие + let checkQuery = ` + SELECT id FROM consent_logs + WHERE status = 'granted' AND consent_type = $1 AND ( + `; + const checkParams = [consentType]; + if (userId) { + checkQuery += `user_id = $${checkParams.length + 1}`; + checkParams.push(userId); + } + if (walletAddress) { + if (checkParams.length > 1) checkQuery += ' OR '; + checkQuery += `wallet_address = $${checkParams.length + 1}`; + checkParams.push(walletAddress); + } + // Для гостей проверяем по формату guest_${guestId} + if (guestId && !walletAddress) { + if (checkParams.length > 1) checkQuery += ' OR '; + checkQuery += `wallet_address = $${checkParams.length + 1}`; + checkParams.push(`guest_${guestId}`); + } + checkQuery += ')'; + + const existing = await db.getQuery()(checkQuery, checkParams); + + if (existing.rows.length > 0) { + // Обновляем существующее согласие + await db.getQuery()(` + UPDATE consent_logs + SET document_id = $1, + document_title = $2, + signed_at = NOW(), + revoked_at = NULL, + ip_address = $3, + user_agent = $4, + updated_at = NOW() + WHERE id = $5 + `, [doc.id, doc.title, ipAddress, userAgent, existing.rows[0].id]); + + results.push({ documentId: doc.id, consentType, action: 'updated' }); + } else { + // Для гостей используем guestId как wallet_address для последующей миграции + const consentWalletAddress = walletAddress || (guestId ? `guest_${guestId}` : null); + + // Создаем новое согласие + await db.getQuery()(` + INSERT INTO consent_logs ( + user_id, wallet_address, document_id, document_title, + consent_type, status, ip_address, user_agent, channel + ) VALUES ($1, $2, $3, $4, $5, 'granted', $6, $7, 'web') + `, [userId, consentWalletAddress, doc.id, doc.title, consentType, ipAddress, userAgent]); + + results.push({ documentId: doc.id, consentType, action: 'created' }); + } + } + + logger.info(`Consent granted: userId=${userId}, walletAddress=${walletAddress}, documents=${documentIds.join(',')}`); + + res.json({ + success: true, + results, + }); + } catch (error) { + logger.error('Error granting consent:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// Отозвать согласие +router.post('/revoke', requireAuth, async (req, res) => { + try { + const userId = req.session.userId; + const walletAddress = req.session.address; + const { consentTypes } = req.body; // Массив типов согласий для отзыва + + if (!userId && !walletAddress) { + return res.status(400).json({ error: 'Требуется авторизация' }); + } + + if (!consentTypes || !Array.isArray(consentTypes) || consentTypes.length === 0) { + return res.status(400).json({ error: 'Требуется указать типы согласий для отзыва' }); + } + + let query = ` + UPDATE consent_logs + SET status = 'revoked', + revoked_at = NOW(), + updated_at = NOW() + WHERE consent_type = ANY($1) AND status = 'granted' AND ( + `; + + const params = [consentTypes]; + if (userId) { + query += `user_id = $${params.length + 1}`; + params.push(userId); + } + if (walletAddress) { + if (params.length > 1) query += ' OR '; + query += `wallet_address = $${params.length + 1}`; + params.push(walletAddress); + } + query += ')'; + + const { rowCount } = await db.getQuery()(query, params); + + logger.info(`Consent revoked: userId=${userId}, walletAddress=${walletAddress}, types=${consentTypes.join(',')}`); + + res.json({ + success: true, + revokedCount: rowCount, + }); + } catch (error) { + logger.error('Error revoking consent:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +module.exports = router; + diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index 3451f1d..2dbd85a 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -330,11 +330,75 @@ class UniversalGuestService { logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`); + // 0.5. Проверяем, нужно ли автоматически подписать согласие при ответе + // Загружаем историю для проверки последнего сообщения + const previousHistory = await this.getHistory(identifier); + + // Если в истории есть системное сообщение о согласиях, автоматически подписываем при ответе + if (previousHistory.length > 0) { + const consentService = require('./consentService'); + const [provider, providerId] = identifier?.split(':') || []; + let walletAddress = null; + + if (provider === 'web' && providerId?.startsWith('guest_')) { + walletAddress = providerId; + } + + // Проверяем, было ли последнее сообщение системным с согласием + const lastMessage = previousHistory[previousHistory.length - 1]; + const hasConsentSystemMessage = lastMessage && + (lastMessage.role === 'system' || lastMessage.consentRequired); + + if (hasConsentSystemMessage) { + // Проверяем текущие согласия + const consentCheck = await consentService.checkConsents({ + userId: null, + walletAddress + }); + + // Если согласия нужны, автоматически подписываем + if (consentCheck.needsConsent) { + logger.info(`[UniversalGuestService] Автоматическое подписание согласий при ответе гостя ${identifier}`); + + const consentDocuments = await consentService.getConsentDocuments(consentCheck.missingConsents); + const documentIds = consentDocuments.map(doc => doc.id); + const consentTypes = consentDocuments.map(doc => doc.consentType).filter(type => type); + + if (documentIds.length > 0 && consentTypes.length > 0) { + const db = require('../db'); + try { + // Для гостей используем wallet_address в формате guest_ID + await db.getQuery()( + `INSERT INTO consent_logs (wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at) + SELECT $1, unnest($2::int[]), unnest($3::text[]), unnest($4::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW() + ON CONFLICT (wallet_address, consent_type, document_id) + DO UPDATE SET + status = 'granted', + signed_at = NOW(), + revoked_at = NULL, + updated_at = NOW() + WHERE consent_logs.wallet_address = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`, + [ + walletAddress, + documentIds, + consentDocuments.map(doc => doc.title), + consentTypes + ] + ); + logger.info(`[UniversalGuestService] Согласия автоматически подписаны для гостя ${identifier}`); + } catch (consentError) { + logger.error(`[UniversalGuestService] Ошибка автоматического подписания согласий:`, consentError); + } + } + } + } + } + // 1. Сохраняем сообщение гостя const saveResult = await this.saveMessage(messageData); const processedContent = saveResult.processedContent; - // 2. Загружаем историю для контекста + // 2. Загружаем историю для контекста (заново, так как могли добавиться сообщения) const conversationHistory = await this.getHistory(identifier); // 3. Генерируем AI ответ @@ -368,25 +432,74 @@ class UniversalGuestService { }; } - // 4. Сохраняем AI ответ + // Проверяем согласия для добавления системного сообщения к ответу ИИ + const consentService = require('./consentService'); + const [provider, providerId] = identifier?.split(':') || []; + let walletAddress = null; + + if (provider === 'web' && providerId?.startsWith('guest_')) { + walletAddress = providerId; // Для веб-гостей используем guest_ID + } + + const consentCheck = await consentService.checkConsents({ + userId: null, + walletAddress + }); + + // Формируем финальный ответ ИИ с системным сообщением, если нужно + let finalAiResponse = aiResponse.response; + let consentInfo = null; + + if (consentCheck.needsConsent) { + const consentSystemMessage = await consentService.getConsentSystemMessage({ + userId: null, + walletAddress, + channel: channel === 'web' ? 'web' : channel, + baseUrl: process.env.BASE_URL || 'http://localhost:9000' + }); + + if (consentSystemMessage && consentSystemMessage.consentRequired) { + // Добавляем системное сообщение к ответу ИИ + finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`; + + consentInfo = { + consentRequired: true, + missingConsents: consentSystemMessage.missingConsents, + consentDocuments: consentSystemMessage.consentDocuments, + autoConsentOnReply: consentSystemMessage.autoConsentOnReply + }; + } + } + + // 4. Сохраняем AI ответ с добавленным системным сообщением await this.saveAiResponse({ identifier, - content: aiResponse.response, + content: finalAiResponse, channel, metadata: messageData.metadata || {} }); logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`); - return { + const result = { success: true, identifier, aiResponse: { - response: aiResponse.response, + response: finalAiResponse, ragData: aiResponse.ragData } }; + // Добавляем информацию о согласиях, если они нужны + if (consentInfo) { + result.consentRequired = consentInfo.consentRequired; + result.missingConsents = consentInfo.missingConsents; + result.consentDocuments = consentInfo.consentDocuments; + result.autoConsentOnReply = consentInfo.autoConsentOnReply; + } + + return result; + } catch (error) { logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error); throw error; @@ -534,6 +647,48 @@ class UniversalGuestService { ); } + // 5. Переносим согласия гостя на пользователя, если они есть + // Согласия могут быть связаны с гостевой сессией через wallet_address = "guest_${guestId}" + try { + const [channel, guestId] = identifier.split(':'); + + // Ищем согласия по гостевому идентификатору в формате "guest_${guestId}" + const guestWalletAddress = `guest_${guestId}`; + + const { rows: guestConsents } = await db.getQuery()(` + SELECT id, consent_type, document_id, document_title, status, signed_at, ip_address, user_agent, channel as consent_channel + FROM consent_logs + WHERE wallet_address = $1 + AND status = 'granted' + AND (user_id IS NULL OR user_id = $2) + `, [guestWalletAddress, userId]); + + if (guestConsents.length > 0) { + logger.info(`[UniversalGuestService] Найдено ${guestConsents.length} согласий для переноса`); + + // Переносим согласия на пользователя + // Обновляем wallet_address на нормализованный адрес кошелька пользователя, если он есть + const identityService = require('./identity-service'); + const walletIdentity = await identityService.findIdentity(userId, 'wallet'); + const normalizedWalletAddress = walletIdentity?.provider_id || null; + + for (const consent of guestConsents) { + await db.getQuery()(` + UPDATE consent_logs + SET user_id = $1, + wallet_address = COALESCE($2, wallet_address), + updated_at = NOW() + WHERE id = $3 + `, [userId, normalizedWalletAddress, consent.id]); + } + + logger.info(`[UniversalGuestService] Перенесено ${guestConsents.length} согласий на user ${userId}`); + } + } catch (consentError) { + // Не критично, если не удалось перенести согласия - просто логируем + logger.warn(`[UniversalGuestService] Ошибка переноса согласий (не критично):`, consentError); + } + logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`); return { diff --git a/backend/services/auth-service.js b/backend/services/auth-service.js index dc8a88e..c705d66 100644 --- a/backend/services/auth-service.js +++ b/backend/services/auth-service.js @@ -33,28 +33,47 @@ const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)']; class AuthService { constructor() {} - // Проверка подписи - async verifySignature(message, signature, address) { + // Проверка подписи SIWE + // Используем SiweMessage для правильной проверки SIWE подписей + async verifySignature(siweMessage, signature, address) { try { - if (!message || !signature || !address) return false; + if (!siweMessage || !signature || !address) return false; // Нормализуем входящий адрес const normalizedAddress = ethers.getAddress(address); - // Восстанавливаем адрес из подписи - const recoveredAddress = ethers.verifyMessage(message, signature); + // Используем SiweMessage.verify() для правильной проверки SIWE подписи + const { SiweMessage } = require('siwe'); + + // Если siweMessage уже является объектом SiweMessage, используем его напрямую + // Если это строка, парсим её + let message; + if (typeof siweMessage === 'string') { + message = new SiweMessage(siweMessage); + } else { + message = siweMessage; + } + + // Проверяем подпись через SiweMessage.verify() + const { success, data } = await message.verify({ signature }); // Логируем для отладки - logger.info(`[verifySignature] Message: ${message}`); + logger.info(`[verifySignature] SIWE verification result: ${success}`); + if (data) { + logger.info(`[verifySignature] Verified address: ${data.address}`); + logger.info(`[verifySignature] Expected address: ${normalizedAddress}`); + logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(data.address) === normalizedAddress}`); + } logger.info(`[verifySignature] Signature: ${signature}`); - logger.info(`[verifySignature] Expected address: ${normalizedAddress}`); - logger.info(`[verifySignature] Recovered address: ${recoveredAddress}`); - logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(recoveredAddress) === normalizedAddress}`); + + if (!success) { + return false; + } // Сравниваем нормализованные адреса - return ethers.getAddress(recoveredAddress) === normalizedAddress; + return data && ethers.getAddress(data.address) === normalizedAddress; } catch (error) { - logger.error('Error in signature verification:', error); + logger.error('Error in SIWE signature verification:', error); return false; } } diff --git a/backend/services/consentService.js b/backend/services/consentService.js new file mode 100644 index 0000000..33e6f70 --- /dev/null +++ b/backend/services/consentService.js @@ -0,0 +1,263 @@ +/** + * Copyright (c) 2024-2025 Тарабанов Александр Викторович + * All rights reserved. + * + * This software is proprietary and confidential. + * Unauthorized copying, modification, or distribution is prohibited. + * + * For licensing inquiries: info@hb3-accelerator.com + * Website: https://hb3-accelerator.com + * GitHub: https://github.com/VC-HB3-Accelerator + */ + +const db = require('../db'); +const logger = require('../utils/logger'); + +// Маппинг названий документов на типы согласий +const DOCUMENT_CONSENT_MAP = { + 'Политика конфиденциальности': 'privacy_policy', + 'Права субъектов персональных данных и отзыв согласия': 'personal_data', + 'Согласие на использование файлов cookie': 'cookies', + 'Согласие на обработку персональных данных': 'personal_data_processing' +}; + +/** + * Проверить согласия пользователя или гостя + * @param {Object} params - Параметры проверки + * @param {number|null} params.userId - ID пользователя (если авторизован) + * @param {string|null} params.walletAddress - Адрес кошелька или guest_ID + * @returns {Promise} - Результат проверки с информацией о недостающих согласиях + */ +async function checkConsents({ userId = null, walletAddress = null }) { + try { + const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP); + + // Строим запрос в зависимости от наличия userId + let consentCheckQuery; + let queryParams; + + if (userId) { + // Для авторизованного пользователя + consentCheckQuery = ` + SELECT consent_type, COUNT(*) as count + FROM consent_logs + WHERE status = 'granted' + AND (user_id = $1 OR wallet_address = $2) + AND consent_type = ANY($3) + GROUP BY consent_type + `; + queryParams = [userId, walletAddress, requiredConsentTypes]; + } else if (walletAddress) { + // Для гостя + consentCheckQuery = ` + SELECT consent_type, COUNT(*) as count + FROM consent_logs + WHERE wallet_address = $1 + AND status = 'granted' + AND consent_type = ANY($2) + GROUP BY consent_type + `; + queryParams = [walletAddress, requiredConsentTypes]; + } else { + // Если нет ни userId, ни walletAddress - все согласия отсутствуют + return { + needsConsent: true, + missingConsents: requiredConsentTypes, + grantedConsents: [] + }; + } + + const consentResult = await db.getQuery()(consentCheckQuery, queryParams); + const grantedConsentTypes = consentResult.rows.map(r => r.consent_type); + const missingConsents = requiredConsentTypes.filter(type => !grantedConsentTypes.includes(type)); + const needsConsent = missingConsents.length > 0; + + return { + needsConsent, + missingConsents, + grantedConsents: grantedConsentTypes + }; + } catch (error) { + logger.error('[ConsentService] Ошибка проверки согласий:', error); + // В случае ошибки считаем, что согласия нужны для безопасности + return { + needsConsent: true, + missingConsents: Object.values(DOCUMENT_CONSENT_MAP), + grantedConsents: [] + }; + } +} + +/** + * Получить список документов для подписания + * @param {Array} missingConsents - Типы недостающих согласий + * @returns {Promise} - Массив документов с информацией + */ +async function getConsentDocuments(missingConsents = []) { + try { + // Определяем, какие документы нужно подписать (по недостающим типам согласий) + const documentsToShow = Object.entries(DOCUMENT_CONSENT_MAP) + .filter(([title, consentType]) => missingConsents.includes(consentType)) + .map(([title]) => title); + + if (documentsToShow.length === 0) { + return []; + } + + // Получаем список документов для подписания + const { rows: documents } = await db.getQuery()(` + SELECT id, title, summary + FROM admin_pages_simple + WHERE status = 'published' + AND visibility = 'public' + AND title = ANY($1) + ORDER BY created_at DESC + `, [documentsToShow]); + + return documents.map(doc => ({ + id: doc.id, + title: doc.title, + summary: doc.summary, + consentType: DOCUMENT_CONSENT_MAP[doc.title], + url: `/content/published/${doc.id}` + })); + } catch (error) { + logger.error('[ConsentService] Ошибка получения документов:', error); + return []; + } +} + +/** + * Сформировать системное сообщение о необходимости согласия + * @param {Object} params - Параметры + * @param {string} params.channel - Канал (web/telegram/email) + * @param {Array} params.missingConsents - Типы недостающих согласий + * @param {Array} params.consentDocuments - Документы для подписания + * @param {string} params.baseUrl - Базовый URL сайта (для формирования ссылок) + * @returns {Object} - Системное сообщение, отформатированное для канала + */ +function formatConsentMessage({ channel = 'web', missingConsents = [], consentDocuments = [], baseUrl = 'http://localhost:9000' }) { + const baseMessage = 'Для полноценного использования сервиса необходимо предоставить согласие на обработку персональных данных.'; + const autoConsentMessage = '\n\n⚠️ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.'; + + switch (channel) { + case 'web': + // Для веб-чата возвращаем структурированные данные + return { + content: baseMessage + autoConsentMessage, + consentRequired: true, + missingConsents, + consentDocuments, + autoConsentOnReply: true, // Флаг для автоматического подписания при ответе + format: 'structured' + }; + + case 'telegram': + // Для Telegram форматируем как текст с ссылками + let telegramMessage = `${baseMessage}\n\nДля продолжения работы ознакомьтесь со следующими документами:\n\n`; + + consentDocuments.forEach((doc, index) => { + telegramMessage += `${index + 1}. ${doc.title}\n`; + if (doc.summary) { + telegramMessage += ` ${doc.summary}\n`; + } + telegramMessage += ` ${baseUrl}${doc.url}\n\n`; + }); + + telegramMessage += `⚠️ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.`; + + return { + content: telegramMessage, + consentRequired: true, + missingConsents, + consentDocuments, + autoConsentOnReply: true, + format: 'text' + }; + + case 'email': + // Для email форматируем как HTML + let emailHtml = `

${baseMessage}

`; + emailHtml += '

Для продолжения работы ознакомьтесь со следующими документами:

'; + emailHtml += '
    '; + + consentDocuments.forEach((doc) => { + emailHtml += `
  • ${doc.title}
    `; + if (doc.summary) { + emailHtml += `${doc.summary}
    `; + } + emailHtml += `Открыть документ
  • `; + }); + + emailHtml += '
'; + emailHtml += `

⚠️ Внимание: При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.

`; + emailHtml += `

Также вы можете подписать документы на сайте

`; + + const textContent = baseMessage + '\n\n' + + consentDocuments.map(doc => `${doc.title}: ${baseUrl}${doc.url}`).join('\n') + + '\n\n⚠️ Внимание: При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.'; + + return { + content: emailHtml, + textContent: textContent, + consentRequired: true, + missingConsents, + consentDocuments, + autoConsentOnReply: true, + format: 'html' + }; + + default: + return { + content: baseMessage + autoConsentMessage, + consentRequired: true, + missingConsents, + consentDocuments, + autoConsentOnReply: true, + format: 'text' + }; + } +} + +/** + * Получить полную информацию о согласиях и сформировать системное сообщение + * @param {Object} params - Параметры + * @param {number|null} params.userId - ID пользователя + * @param {string|null} params.walletAddress - Адрес кошелька или guest_ID + * @param {string} params.channel - Канал (web/telegram/email) + * @param {string} params.baseUrl - Базовый URL сайта + * @returns {Promise} - Системное сообщение или null, если согласия есть + */ +async function getConsentSystemMessage({ userId = null, walletAddress = null, channel = 'web', baseUrl = 'http://localhost:9000' }) { + try { + // Проверяем согласия + const consentCheck = await checkConsents({ userId, walletAddress }); + + if (!consentCheck.needsConsent) { + return null; // Все согласия есть + } + + // Получаем документы для подписания + const consentDocuments = await getConsentDocuments(consentCheck.missingConsents); + + // Формируем системное сообщение для канала + return formatConsentMessage({ + channel, + missingConsents: consentCheck.missingConsents, + consentDocuments, + baseUrl + }); + } catch (error) { + logger.error('[ConsentService] Ошибка формирования системного сообщения:', error); + return null; + } +} + +module.exports = { + checkConsents, + getConsentDocuments, + formatConsentMessage, + getConsentSystemMessage, + DOCUMENT_CONSENT_MAP +}; + diff --git a/backend/services/emailBot.js b/backend/services/emailBot.js index 88b1a3b..69eda86 100644 --- a/backend/services/emailBot.js +++ b/backend/services/emailBot.js @@ -297,7 +297,22 @@ class EmailBot { const messageData = await this.extractMessageData(parsed, messageId, uid); if (messageData && this.messageProcessor) { - await this.messageProcessor(messageData); + // Обрабатываем сообщение через унифицированный процессор + // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора + const result = await this.messageProcessor(messageData); + + // Если есть ответ ИИ с информацией о согласиях, отправляем email + if (result && result.success && result.aiResponse) { + const fromEmail = parsed.from?.value?.[0]?.address; + if (fromEmail) { + // Ответ ИИ уже содержит системное сообщение о согласиях (если нужно) + await this.sendEmail( + fromEmail, + 'Ответ на ваше сообщение', + result.aiResponse.response + ); + } + } } processedCount++; @@ -466,6 +481,40 @@ class EmailBot { } } + /** + * Отправка email с HTML содержимым + * @param {string} to - Email получателя + * @param {string} subject - Тема письма + * @param {string} text - Текстовая версия + * @param {string} html - HTML версия + */ + async sendEmailWithHtml(to, subject, text, html) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(to)) { + throw new Error(`Неверный формат email адреса: ${to}`); + } + + try { + const mailOptions = { + from: this.settings.from_email, + to, + subject, + text, + html + }; + + await this.transporter.sendMail(mailOptions); + this.transporter.close(); + + logger.info(`[EmailBot] Email с HTML отправлен успешно: ${to}`); + return true; + + } catch (error) { + logger.error('[EmailBot] Ошибка отправки email с HTML:', error); + throw error; + } + } + /** * Отправка кода верификации * @param {string} email - Email получателя diff --git a/backend/services/encryptedDatabaseService.js b/backend/services/encryptedDatabaseService.js index 498b315..263e7a0 100644 --- a/backend/services/encryptedDatabaseService.js +++ b/backend/services/encryptedDatabaseService.js @@ -300,26 +300,48 @@ class EncryptedDataService { const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`; - // Собираем параметры в правильном порядке: сначала для encrypted, потом для unencrypted - const paramsArray = []; - if (hasEncryptedFields) paramsArray.push(this.encryptionKey); + // Собираем параметры в правильном порядке по номерам из плейсхолдеров + const paramMap = new Map(); // номер параметра -> значение - // Добавляем параметры для encrypted колонок - for (const key of Object.keys(encryptedData)) { - const originalKey = key.replace('_encrypted', ''); - if (filteredData[originalKey] !== undefined) { - paramsArray.push(filteredData[originalKey]); - } else if (filteredData[originalKey + '_unencrypted'] !== undefined) { - paramsArray.push(filteredData[originalKey + '_unencrypted']); + if (hasEncryptedFields) { + paramMap.set(1, this.encryptionKey); // $1 - ключ шифрования + } + + // Проходим по колонкам в порядке allData и добавляем соответствующие значения + for (const key of Object.keys(allData)) { + const placeholder = allData[key].toString(); + // Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text) + const paramMatches = placeholder.match(/\$(\d+)/g); + if (paramMatches) { + // Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.) + // Для незашифрованных - первый параметр ($2, $3 и т.д.) + if (encryptedData[key]) { + // Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования) + const originalKey = key.replace('_encrypted', ''); + if (filteredData[originalKey] !== undefined && paramMatches.length > 0) { + // Последний параметр это значение для шифрования + const valueParam = paramMatches[paramMatches.length - 1]; + const paramNum = parseInt(valueParam.substring(1)); + paramMap.set(paramNum, filteredData[originalKey]); + } + } else if (unencryptedData[key]) { + // Это незашифрованная колонка - берем параметр из плейсхолдера + const valueParam = paramMatches[0]; + const paramNum = parseInt(valueParam.substring(1)); + paramMap.set(paramNum, filteredData[key]); + } } } - // Добавляем параметры для unencrypted колонок - for (const key of Object.keys(unencryptedData)) { - paramsArray.push(filteredData[key + '_unencrypted'] || filteredData[key]); + // Создаем массив параметров в правильном порядке (от $1 до максимального номера) + const maxParamNum = Math.max(...Array.from(paramMap.keys())); + const params = []; + for (let i = 1; i <= maxParamNum; i++) { + if (!paramMap.has(i)) { + throw new Error(`Отсутствует параметр $${i} для запроса`); + } + params.push(paramMap.get(i)); } - - const params = paramsArray; console.log(`🔍 Выполняем INSERT запрос:`, query); console.log(`🔍 Параметры:`, params); diff --git a/backend/services/session-service.js b/backend/services/session-service.js index b28402d..9f84664 100644 --- a/backend/services/session-service.js +++ b/backend/services/session-service.js @@ -114,6 +114,46 @@ class SessionService { const identifier = `web:${guestId}`; // Старые гости всегда из web await universalGuestService.migrateToUser(identifier, userId); } + + // После миграции сообщений переносим согласия по всем гостевых идентификаторам + // Это нужно на случай, если согласия были связаны с гостевой сессией + try { + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Получаем wallet адрес пользователя для обновления согласий + const identityService = require('./identity-service'); + const walletIdentity = await identityService.findIdentity(userId, 'wallet'); + const normalizedWalletAddress = walletIdentity?.provider_id || null; + + for (const guestId of guestIdsToProcess) { + // Ищем согласия по гостевому идентификатору в формате "guest_${guestId}" + const guestWalletAddress = `guest_${guestId}`; + + const { rows: guestConsents } = await db.getQuery()(` + SELECT id FROM consent_logs + WHERE wallet_address = $1 + AND status = 'granted' + AND (user_id IS NULL OR user_id = $2) + `, [guestWalletAddress, userId]); + + if (guestConsents.length > 0) { + // Переносим согласия на пользователя и обновляем wallet_address + await db.getQuery()(` + UPDATE consent_logs + SET user_id = $1, + wallet_address = COALESCE($2, wallet_address), + updated_at = NOW() + WHERE id = ANY($3) + `, [userId, normalizedWalletAddress, guestConsents.map(c => c.id)]); + + logger.info(`[SessionService] Перенесено ${guestConsents.length} согласий для guest ${guestId} → user ${userId}`); + } + } + } catch (consentError) { + // Не критично, если не удалось перенести согласия - просто логируем + logger.warn(`[SessionService] Ошибка переноса согласий (не критично):`, consentError); + } } return { success: true, processedCount: guestIdsToProcess.size }; diff --git a/backend/services/telegramBot.js b/backend/services/telegramBot.js index 007b0b1..b7f8baf 100644 --- a/backend/services/telegramBot.js +++ b/backend/services/telegramBot.js @@ -382,9 +382,11 @@ class TelegramBot { } // Обрабатываем сообщение через унифицированный процессор + // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора const result = await messageProcessor(messageData); // Отправляем ответ пользователю + // Системное сообщение о согласиях уже включено в ответ ИИ (если нужно) if (result.success && result.aiResponse) { await ctx.reply(result.aiResponse.response); } else if (result.success) { diff --git a/backend/services/unifiedMessageProcessor.js b/backend/services/unifiedMessageProcessor.js index 2047274..5817e6a 100644 --- a/backend/services/unifiedMessageProcessor.js +++ b/backend/services/unifiedMessageProcessor.js @@ -183,6 +183,80 @@ async function processMessage(messageData) { const conversationId = conversation.id; + // Получаем ключ шифрования (будет использоваться далее) + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // 5.5. Проверяем, нужно ли автоматически подписать согласие при ответе + // Ищем последнее сообщение от ассистента или системное сообщение с согласием + const consentService = require('./consentService'); + + const { rows: lastMessages } = await db.getQuery()( + `SELECT + decrypt_text(role_encrypted, $2) as role, + decrypt_text(content_encrypted, $2) as content, + message_type + FROM messages + WHERE conversation_id = $1 + AND user_id = $3 + AND ( + decrypt_text(role_encrypted, $2) = 'assistant' + OR message_type = 'system_consent' + ) + ORDER BY created_at DESC + LIMIT 1`, + [conversationId, encryptionKey, userId] + ); + + // Если последнее сообщение было от ассистента, проверяем наличие системного сообщения о согласиях + if (lastMessages.length > 0) { + // Проверяем согласия пользователя + const walletIdentity = await identityService.findIdentity(userId, 'wallet'); + const consentCheck = await consentService.checkConsents({ + userId, + walletAddress: walletIdentity?.provider_id || null + }); + + // Если согласия нужны, но пользователь отвечает на сообщение, автоматически подписываем + if (consentCheck.needsConsent) { + logger.info(`[UnifiedMessageProcessor] Автоматическое подписание согласий при ответе пользователя ${userId}`); + + // Получаем документы для подписания + const consentDocuments = await consentService.getConsentDocuments(consentCheck.missingConsents); + const documentIds = consentDocuments.map(doc => doc.id); + const consentTypes = consentDocuments.map(doc => doc.consentType).filter(type => type); + + // Автоматически подписываем согласие + if (documentIds.length > 0 && consentTypes.length > 0) { + const consentRoutes = require('../routes/consent'); + // Вызываем логику подписания напрямую через сервис или API + try { + await db.getQuery()( + `INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at) + SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW() + ON CONFLICT (user_id, consent_type, document_id) + DO UPDATE SET + status = 'granted', + signed_at = NOW(), + revoked_at = NULL, + updated_at = NOW() + WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`, + [ + userId, + walletIdentity?.provider_id || null, + documentIds, + consentDocuments.map(doc => doc.title), + consentTypes + ] + ); + logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`); + } catch (consentError) { + logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError); + // Не блокируем обработку сообщения при ошибке подписания + } + } + } + } + // 6. Обработка вложений let attachment_filename = null; let attachment_mimetype = null; @@ -198,7 +272,7 @@ async function processMessage(messageData) { } // 7. Сохраняем входящее сообщение пользователя - const encryptionKey = encryptionUtils.getEncryptionKey(); + // encryptionKey уже объявлен выше const { rows } = await db.getQuery()( `INSERT INTO messages ( @@ -293,7 +367,31 @@ async function processMessage(messageData) { }); if (aiResponse && aiResponse.success && aiResponse.response) { - // Сохраняем ответ AI + // Проверяем согласия и добавляем системное сообщение к ответу ИИ + const walletIdentity = await identityService.findIdentity(userId, 'wallet'); + const consentSystemMessage = await consentService.getConsentSystemMessage({ + userId, + walletAddress: walletIdentity?.provider_id || null, + channel: channel === 'web' ? 'web' : channel, + baseUrl: process.env.BASE_URL || 'http://localhost:9000' + }); + + // Формируем финальный ответ ИИ с системным сообщением, если нужно + let finalAiResponse = aiResponse.response; + if (consentSystemMessage && consentSystemMessage.consentRequired) { + // Добавляем системное сообщение к ответу ИИ + finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`; + + // Сохраняем информацию о согласиях в метаданные ответа + aiResponse.consentInfo = { + consentRequired: true, + missingConsents: consentSystemMessage.missingConsents, + consentDocuments: consentSystemMessage.consentDocuments, + autoConsentOnReply: consentSystemMessage.autoConsentOnReply + }; + } + + // Сохраняем ответ AI с добавленным системным сообщением const { rows: aiMessageRows } = await db.getQuery()( `INSERT INTO messages ( conversation_id, @@ -322,7 +420,7 @@ async function processMessage(messageData) { conversationId, userId, // sender_id 'assistant', - aiResponse.response, + finalAiResponse, channel, 'assistant', 'outgoing', @@ -353,17 +451,27 @@ async function processMessage(messageData) { } // 11. Возвращаем результат - return { + const result = { success: true, userMessageId, conversationId, aiResponse: aiResponse && aiResponse.success ? { - response: aiResponse.response, + response: finalAiResponse || aiResponse.response, ragData: aiResponse.ragData } : null, noAiResponse: !shouldGenerateAi }; + // Если есть информация о согласиях, добавляем её в результат + if (aiResponse && aiResponse.success && aiResponse.consentInfo) { + result.consentRequired = aiResponse.consentInfo.consentRequired; + result.missingConsents = aiResponse.consentInfo.missingConsents; + result.consentDocuments = aiResponse.consentInfo.consentDocuments; + result.autoConsentOnReply = aiResponse.consentInfo.autoConsentOnReply; + } + + return result; + } catch (error) { logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error); throw error; diff --git a/frontend/src/components/ChatInterface.vue b/frontend/src/components/ChatInterface.vue index ec0145c..cac70d3 100644 --- a/frontend/src/components/ChatInterface.vue +++ b/frontend/src/components/ChatInterface.vue @@ -21,6 +21,7 @@ :message="message" :isPrivateChat="isPrivateChat" :currentUserId="currentUserId" + @consent-granted="handleConsentGranted" /> @@ -113,6 +114,7 @@ + @@ -148,6 +150,7 @@ const emit = defineEmits([ 'send-message', 'load-more', // Событие для загрузки старых сообщений 'ai-reply', + 'remove-consent-messages', // Событие для удаления системных сообщений о согласиях ]); const messagesContainer = ref(null); @@ -155,6 +158,11 @@ const messageInputRef = ref(null); const chatInputRef = ref(null); // Ref для chat-input const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее) +function handleConsentGranted(messageId) { + // После подписания удаляем системное сообщение о необходимости согласия + emit('remove-consent-messages', [messageId]); +} + // Локальное состояние для предпросмотра, синхронизированное с props.attachments const localAttachments = ref([...props.attachments]); watch(() => props.attachments, (newVal) => { diff --git a/frontend/src/components/ConsentModal.vue b/frontend/src/components/ConsentModal.vue new file mode 100644 index 0000000..c87fef8 --- /dev/null +++ b/frontend/src/components/ConsentModal.vue @@ -0,0 +1,341 @@ + + + + + + + + diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue index 023177d..b0fd38b 100644 --- a/frontend/src/components/Message.vue +++ b/frontend/src/components/Message.vue @@ -46,8 +46,43 @@ Ответить + + + -
+
@@ -117,6 +152,48 @@ const props = defineProps({ }, }); +const emit = defineEmits(['consent-granted']); + +// Состояние для выбранных документов и отправки согласия +const selectedConsentDocuments = ref([]); +const isSubmittingConsent = ref(false); + +// Инициализируем выбранные документы при монтировании, если есть документы +watch(() => props.message.consentDocuments, (docs) => { + if (docs && Array.isArray(docs) && docs.length > 0) { + // Автоматически выбираем все документы + selectedConsentDocuments.value = docs.map(doc => doc.id); + } +}, { immediate: true }); + +// Функция подписания документов +async function submitConsent() { + if (selectedConsentDocuments.value.length === 0 || isSubmittingConsent.value) return; + + isSubmittingConsent.value = true; + try { + const api = (await import('../api/axios')).default; + const documents = props.message.consentDocuments || []; + const consentTypes = documents + .filter(doc => selectedConsentDocuments.value.includes(doc.id)) + .map(doc => doc.consentType) + .filter(type => type); + + await api.post('/consent/grant', { + documentIds: selectedConsentDocuments.value, + consentTypes: consentTypes, + }); + + // Уведомляем родительский компонент об успешном подписании + emit('consent-granted', props.message.id); + } catch (error) { + console.error('Ошибка подписания документов:', error); + alert('Ошибка при подписании документов. Попробуйте еще раз.'); + } finally { + isSubmittingConsent.value = false; + } +} + // Простая функция для определения, является ли сообщение отправленным текущим пользователем // Используем данные из самого сообщения для определения направления const isCurrentUserMessage = computed(() => { @@ -494,6 +571,97 @@ function copyEmail(email) { .system-btn:hover { background: var(--color-primary-dark, #2563eb); } +.system-btn.primary { + background: var(--color-primary, #007bff); + font-weight: 600; +} +.system-btn.primary:hover { + background: var(--color-primary-dark, #0056b3); +} +.system-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Стили для блока с документами для подписания */ +.consent-documents-block { + margin-top: 16px; + padding: 16px; + background: #f8f9fa; + border-radius: 8px; + border: 1px solid #e9ecef; +} + +.consent-document-item { + margin-bottom: 12px; + padding: 12px; + background: white; + border-radius: 6px; + border: 1px solid #e9ecef; + transition: all 0.2s; +} + +.consent-document-item:hover { + border-color: var(--color-primary, #007bff); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1); +} + +.consent-document-item:last-child { + margin-bottom: 0; +} + +.consent-document-label { + display: flex; + gap: 12px; + cursor: pointer; + align-items: flex-start; +} + +.consent-checkbox { + margin-top: 4px; + width: 18px; + height: 18px; + cursor: pointer; + flex-shrink: 0; +} + +.consent-document-info { + flex: 1; +} + +.consent-document-title { + margin: 0 0 6px 0; + font-size: 1rem; + color: var(--color-primary, #333); + font-weight: 600; +} + +.consent-document-summary { + margin: 0 0 8px 0; + color: #666; + font-size: 0.9rem; + line-height: 1.4; +} + +.consent-document-link { + color: var(--color-primary, #007bff); + text-decoration: none; + font-size: 0.9rem; + display: inline-block; + margin-top: 4px; +} + +.consent-document-link:hover { + text-decoration: underline; +} + +.consent-actions { + margin-top: 16px; + display: flex; + justify-content: flex-end; + padding-top: 12px; + border-top: 1px solid #e9ecef; +} /* Стили для информации об отправителе в приватном чате */ .message-sender-info { diff --git a/frontend/src/composables/useChat.js b/frontend/src/composables/useChat.js index 68e7ad1..0ff6de5 100644 --- a/frontend/src/composables/useChat.js +++ b/frontend/src/composables/useChat.js @@ -316,6 +316,7 @@ export function useChat(auth) { } // Добавляем ответ ИИ, если есть + // Системное сообщение о согласиях уже включено в ответ ИИ if (response.data.aiResponse) { messages.value.push({ id: `ai_${Date.now()}`, @@ -323,12 +324,17 @@ export function useChat(auth) { sender_type: 'assistant', role: 'assistant', timestamp: new Date().toISOString(), - isLocal: false + isLocal: false, + // Добавляем информацию о согласиях, если есть + consentRequired: response.data.consentRequired || false, + missingConsents: response.data.missingConsents || [], + consentDocuments: response.data.consentDocuments || [], + autoConsentOnReply: response.data.autoConsentOnReply || false }); } - // Добавляем системное сообщение для гостя (только на клиенте, не сохраняется в истории) - if (isGuestMessage && response.data.systemMessage) { + // Добавляем системное сообщение для гостя (только если нет согласия, чтобы не дублировать) + if (isGuestMessage && response.data.systemMessage && !response.data.consentRequired) { messages.value.push({ id: `system-${Date.now()}`, content: response.data.systemMessage, diff --git a/frontend/src/services/wallet.js b/frontend/src/services/wallet.js index 8d3c339..eb2bcd1 100644 --- a/frontend/src/services/wallet.js +++ b/frontend/src/services/wallet.js @@ -48,12 +48,30 @@ export async function connectWithWallet() { throw new Error('Не удалось получить nonce с сервера'); } + // Получаем список документов для подписания + let resources = [`${window.location.origin}/api/auth/verify`]; + try { + const docsResponse = await axios.get('/consent/documents'); + if (docsResponse.data && docsResponse.data.length > 0) { + docsResponse.data.forEach(doc => { + resources.push(`${window.location.origin}/content/published/${doc.id}`); + }); + } + } catch (error) { + // Если не удалось получить документы, продолжаем без них + console.warn('Не удалось получить список документов для подписания:', error); + } + // Создаем сообщение для подписи const domain = window.location.host; const origin = window.location.origin; - const statement = 'Sign in with Ethereum to the app.'; + const statement = 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.'; const issuedAt = new Date().toISOString(); + + // Создаем копию resources и сортируем (не мутируем исходный массив) + const sortedResources = [...resources].sort(); + const siweMessage = new SiweMessage({ domain, address, @@ -63,7 +81,7 @@ export async function connectWithWallet() { chainId: 1, nonce, issuedAt, - resources: [`${origin}/api/auth/verify`], + resources: sortedResources, }); const message = siweMessage.prepareMessage(); diff --git a/frontend/src/utils/wallet.js b/frontend/src/utils/wallet.js index 4d745b3..57470d7 100644 --- a/frontend/src/utils/wallet.js +++ b/frontend/src/utils/wallet.js @@ -43,8 +43,9 @@ export const connectWallet = async () => { // Берем первый аккаунт в списке const address = accounts[0]; - // Нормализуем адрес (приводим к нижнему регистру для последующих сравнений) - const normalizedAddress = ethers.utils.getAddress(address); + // Нормализуем адрес (используем getAddress для совместимости) + // Проверяем версию ethers - если v6, используем ethers.getAddress, иначе ethers.utils.getAddress + const normalizedAddress = ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address); // console.log('Normalized address:', normalizedAddress); // Запрашиваем nonce с сервера @@ -60,34 +61,70 @@ export const connectWallet = async () => { }; } - // Создаем провайдер Ethers - const provider = new ethers.providers.Web3Provider(window.ethereum); - const signer = provider.getSigner(); + // Для SIWE используем personal_sign напрямую через window.ethereum + // Не используем ethers signer, так как он добавляет префикс, который нарушает SIWE формат + + // Получаем список документов для подписания + let resources = [`${window.location.origin}/api/auth/verify`]; + try { + const docsResponse = await axios.get('/consent/documents'); + if (docsResponse.data && docsResponse.data.length > 0) { + docsResponse.data.forEach(doc => { + resources.push(`${window.location.origin}/content/published/${doc.id}`); + }); + } + } catch (error) { + // Если не удалось получить документы, продолжаем без них + console.warn('Не удалось получить список документов для подписания:', error); + } // Создаем сообщение для подписи - const domain = window.location.host; + // Важно: domain должен быть hostname без протокола и порта (если порт стандартный) + const domain = window.location.hostname === 'localhost' ? + `localhost:${window.location.port}` : + window.location.hostname; const origin = window.location.origin; + + // Создаем issuedAt один раз, чтобы использовать одинаковый в сообщении и запросе + const issuedAt = new Date().toISOString(); - // Создаем SIWE сообщение + // Создаем копию resources и сортируем (не мутируем исходный массив) + const sortedResources = [...resources].sort(); + + // Создаем SIWE сообщение с документами в resources const message = new SiweMessage({ domain, address: normalizedAddress, - statement: 'Sign in with Ethereum to the app.', + statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.', uri: origin, version: '1', chainId: 1, // Ethereum mainnet nonce: nonce, - issuedAt: new Date().toISOString(), - resources: [`${origin}/api/auth/verify`], + issuedAt: issuedAt, + resources: sortedResources, }); // Получаем строку сообщения для подписи const messageToSign = message.prepareMessage(); - // console.log('SIWE message:', messageToSign); + + // Логируем для отладки + console.log('🔐 [Frontend] Domain:', domain); + console.log('🔐 [Frontend] Origin:', origin); + console.log('🔐 [Frontend] Address:', normalizedAddress); + console.log('🔐 [Frontend] Nonce:', nonce); + console.log('🔐 [Frontend] IssuedAt:', issuedAt); + console.log('🔐 [Frontend] Resources:', JSON.stringify(sortedResources)); + console.log('🔐 [Frontend] SIWE message to sign:', messageToSign); + console.log('🔐 [Frontend] Message length:', messageToSign.length); - // Запрашиваем подпись - // console.log('Requesting signature...'); - const signature = await signer.signMessage(messageToSign); + // Запрашиваем подпись через personal_sign (правильный способ для SIWE) + // personal_sign подписывает сообщение С префиксом "\x19Ethereum Signed Message:\n" + // ethers.verifyMessage() также добавляет этот префикс, поэтому они совместимы + // Параметры: [message, address] - MetaMask принимает строку напрямую + const signature = await window.ethereum.request({ + method: 'personal_sign', + params: [messageToSign, normalizedAddress.toLowerCase()], + }); if (!signature) { return { @@ -104,7 +141,7 @@ export const connectWallet = async () => { address: normalizedAddress, signature, nonce, - issuedAt: new Date().toISOString(), + issuedAt: issuedAt, // Используем тот же issuedAt, что и в сообщении }; // console.log('Request data:', requestData); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 96d94a3..1c852bc 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -38,6 +38,7 @@ v-model:attachments="attachments" @send-message="handleSendMessage" @load-more="loadMessages" + @remove-consent-messages="handleRemoveConsentMessages" />
@@ -155,6 +157,13 @@ loadMessages({ initial: true }); } }; + + // Функция удаления системных сообщений о согласиях после подписания + const handleRemoveConsentMessages = (messageIds) => { + if (messageIds && Array.isArray(messageIds)) { + messages.value = messages.value.filter(msg => !messageIds.includes(msg.id)); + } + };