feat: новая функция

This commit is contained in:
2025-11-01 17:25:49 +03:00
parent 772d4cff54
commit e28848146d
19 changed files with 1680 additions and 67 deletions

View File

@@ -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);

View File

@@ -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);

289
backend/routes/consent.js Normal file
View File

@@ -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;