diff --git a/backend/app.js b/backend/app.js index 613cc01..3f8c606 100644 --- a/backend/app.js +++ b/backend/app.js @@ -68,7 +68,8 @@ const ensureDirectoriesExist = () => { path.join(__dirname, 'contracts-data/dles'), path.join(__dirname, 'temp'), path.join(__dirname, 'uploads'), - path.join(__dirname, 'uploads/logos') + path.join(__dirname, 'uploads/logos'), + path.join(__dirname, 'uploads/content') ]; for (const dir of directories) { @@ -201,8 +202,9 @@ app.use((req, res, next) => { }); // Настройка парсеров -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); +// Убираем ограничение по размеру (база данных масштабируется) +app.use(express.json({ limit: '50mb' })); // Увеличен лимит для JSON (для больших данных) +app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Увеличен лимит для URL-encoded // Режим работы уже определен выше (при настройке trust proxy) diff --git a/backend/routes/chat.js b/backend/routes/chat.js index 6b10ff6..0913283 100644 --- a/backend/routes/chat.js +++ b/backend/routes/chat.js @@ -27,9 +27,32 @@ const universalMediaProcessor = require('../services/UniversalMediaProcessor'); const consentService = require('../services/consentService'); const { DOCUMENT_CONSENT_MAP } = consentService; -// Настройка multer для обработки файлов в памяти +// Настройка multer для обработки файлов в памяти с лимитами для чата const storage = multer.memoryStorage(); -const upload = multer({ storage: storage }); + +// Multer с лимитами для чата: +// - Изображения: до 100 МБ +// - Видео, аудио и другие файлы: до 300 МБ +const upload = multer({ + storage: storage, + limits: { + fileSize: 300 * 1024 * 1024, // Максимальный лимит (300 МБ для видео, аудио и файлов) + files: 10 // Максимальное количество файлов за раз + }, + fileFilter: (req, file, cb) => { + const isImage = /^image\/(png|jpg|jpeg|gif|webp|svg)$/i.test(file.mimetype || ''); + const isVideo = /^video\/(mp4|webm|ogg|mov|avi)$/i.test(file.mimetype || ''); + const isAudio = /^audio\/(mp3|wav|ogg|m4a|aac|flac|wma)$/i.test(file.mimetype || ''); + + // Разрешаем изображения, видео, аудио и другие файлы + if (isImage || isVideo || isAudio) { + cb(null, true); + } else { + // Разрешаем и другие файлы (документы и т.д.) + cb(null, true); + } + } +}); // Функция processGuestMessages заменена на UniversalGuestService.migrateToUser() @@ -79,6 +102,20 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => { for (const file of files) { try { + // Проверяем размер файла перед обработкой + const isImage = /^image\//i.test(file.mimetype || ''); + const isVideo = /^video\//i.test(file.mimetype || ''); + const isAudio = /^audio\//i.test(file.mimetype || ''); + + // Лимиты: изображения - 100 МБ, видео/аудио/файлы - 300 МБ + const maxSize = isImage ? 100 * 1024 * 1024 : 300 * 1024 * 1024; + + if (file.size > maxSize) { + const maxSizeMB = Math.round(maxSize / (1024 * 1024)); + const fileType = isImage ? 'изображений' : (isVideo ? 'видео' : (isAudio ? 'аудио' : 'файлов')); + throw new Error(`Размер файла "${file.originalname}" (${Math.round(file.size / (1024 * 1024))} МБ) превышает максимально допустимый размер (${maxSizeMB} МБ) для ${fileType}`); + } + const processedFile = await universalMediaProcessor.processFile( file.buffer, file.originalname, @@ -264,13 +301,33 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re // Создаем identifier для пользователя const identifier = `wallet:${walletIdentity.provider_id}`; - // Обработка вложений - const attachments = files.map(file => ({ - filename: file.originalname, - mimetype: file.mimetype, - size: file.size, - data: file.buffer - })); + // Обработка вложений с проверкой размера + const attachments = []; + for (const file of files) { + // Проверяем размер файла перед обработкой + const isImage = /^image\//i.test(file.mimetype || ''); + const isVideo = /^video\//i.test(file.mimetype || ''); + const isAudio = /^audio\//i.test(file.mimetype || ''); + + // Лимиты: изображения - 100 МБ, видео/аудио/файлы - 300 МБ + const maxSize = isImage ? 100 * 1024 * 1024 : 300 * 1024 * 1024; + + if (file.size > maxSize) { + const maxSizeMB = Math.round(maxSize / (1024 * 1024)); + const fileType = isImage ? 'изображений' : (isVideo ? 'видео' : (isAudio ? 'аудио' : 'файлов')); + return res.status(400).json({ + success: false, + error: `Размер файла "${file.originalname}" (${Math.round(file.size / (1024 * 1024))} МБ) превышает максимально допустимый размер (${maxSizeMB} МБ) для ${fileType}` + }); + } + + attachments.push({ + filename: file.originalname, + mimetype: file.mimetype, + size: file.size, + data: file.buffer + }); + } const messageData = { identifier: identifier, diff --git a/backend/routes/pages.js b/backend/routes/pages.js index bc83491..b877db3 100644 --- a/backend/routes/pages.js +++ b/backend/routes/pages.js @@ -132,7 +132,19 @@ router.post('/', upload.single('file'), async (req, res) => { storage_type: req.file ? 'file' : (bodyRaw.storage_type || 'embedded'), file_path: req.file ? path.join('/uploads', 'legal', path.basename(req.file.path)) : (bodyRaw.file_path || null), size_bytes: req.file ? req.file.size : (bodyRaw.size_bytes || null), - checksum: bodyRaw.checksum || null + checksum: bodyRaw.checksum || null, + // Нормализуем категорию: приводим к нижнему регистру для консистентности + category: (bodyRaw.category && String(bodyRaw.category).trim()) ? String(bodyRaw.category).trim().toLowerCase() : null, + // Обрабатываем parent_id: может быть null или числом + parent_id: (bodyRaw.parent_id && bodyRaw.parent_id !== 'null' && bodyRaw.parent_id !== '') + ? (() => { const parsed = parseInt(bodyRaw.parent_id); return isNaN(parsed) ? null : parsed; })() + : null, + // Обрабатываем order_index: должно быть числом + order_index: (bodyRaw.order_index && bodyRaw.order_index !== 'null' && bodyRaw.order_index !== '') + ? (() => { const parsed = parseInt(bodyRaw.order_index); return isNaN(parsed) ? 0 : parsed; })() + : 0, + nav_path: bodyRaw.nav_path || null, + is_index_page: bodyRaw.is_index_page === true || bodyRaw.is_index_page === 'true' }; // Формируем SQL для вставки данных (только непустые поля) @@ -189,6 +201,159 @@ router.get('/', async (req, res) => { res.json(rows); }); +// ========== РОУТЫ ДЛЯ КАТЕГОРИЙ (должны быть ПЕРЕД параметрическими роутами типа /:id) ========== + +// Создать категорию +router.post('/categories', async (req, res) => { + if (!req.session || !req.session.authenticated) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + if (!req.session.address) { + return res.status(403).json({ error: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ error: 'Only admin can create categories' }); + } + + try { + const { name, display_name, description, order_index } = req.body; + + if (!name || !name.trim()) { + return res.status(400).json({ error: 'Название категории обязательно' }); + } + + const normalizedName = name.trim().toLowerCase(); + const displayName = display_name || name.trim(); + + // Проверяем, не существует ли уже категория с таким названием + const existsRes = await db.getQuery()( + `SELECT id FROM document_categories WHERE name = $1`, + [normalizedName] + ); + + if (existsRes.rows.length > 0) { + return res.status(409).json({ error: 'Категория с таким названием уже существует' }); + } + + // Создаем категорию + const { rows } = await db.getQuery()( + `INSERT INTO document_categories (name, display_name, description, order_index, author_address) + VALUES ($1, $2, $3, $4, $5) + RETURNING *`, + [normalizedName, displayName, description || null, order_index || 0, req.session.address] + ); + + console.log(`[pages] POST /categories: создана категория "${normalizedName}"`); + res.json(rows[0]); + } catch (error) { + console.error('[pages] Ошибка создания категории:', error); + // Если таблица не существует, возвращаем успех (для обратной совместимости) + if (error.message.includes('does not exist') || error.message.includes('relation')) { + console.warn('[pages] Таблица document_categories не существует, пропускаем создание'); + res.json({ name: req.body.name.trim().toLowerCase(), display_name: req.body.name.trim() }); + } else { + res.status(500).json({ error: 'Ошибка создания категории: ' + error.message }); + } + } +}); + +// Удалить категорию +router.delete('/categories/:name', async (req, res) => { + if (!req.session || !req.session.authenticated) { + return res.status(401).json({ error: 'Требуется аутентификация' }); + } + if (!req.session.address) { + return res.status(403).json({ error: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ error: 'Only admin can delete categories' }); + } + + try { + const categoryName = decodeURIComponent(req.params.name).toLowerCase(); + + if (categoryName === 'uncategorized') { + return res.status(400).json({ error: 'Нельзя удалить категорию "Без категории"' }); + } + + // Удаляем категорию + const { rows } = await db.getQuery()( + `DELETE FROM document_categories WHERE name = $1 RETURNING *`, + [categoryName] + ); + + if (rows.length === 0) { + return res.status(404).json({ error: 'Категория не найдена' }); + } + + console.log(`[pages] DELETE /categories/:name: удалена категория "${categoryName}"`); + res.json({ success: true, deleted: rows[0] }); + } catch (error) { + console.error('[pages] Ошибка удаления категории:', error); + // Если таблица не существует, возвращаем успех (для обратной совместимости) + if (error.message.includes('does not exist') || error.message.includes('relation')) { + console.warn('[pages] Таблица document_categories не существует, пропускаем удаление'); + res.json({ success: true }); + } else { + res.status(500).json({ error: 'Ошибка удаления категории: ' + error.message }); + } + } +}); + +// Получить список всех категорий (для выпадающего списка) +router.get('/categories', async (req, res) => { + try { + // Сначала пытаемся получить категории из таблицы document_categories + try { + const categoriesRes = await db.getQuery()( + `SELECT name, display_name FROM document_categories ORDER BY order_index, created_at` + ); + if (categoriesRes.rows.length > 0) { + const categories = categoriesRes.rows.map(row => row.name); + return res.json(categories); + } + } catch (err) { + console.warn('[pages] Таблица document_categories не существует, используем старый метод'); + } + + // Fallback: получаем категории из документов + 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 { rows } = await db.getQuery()(` + SELECT DISTINCT category + FROM ${tableName} + WHERE visibility = 'public' + AND status = 'published' + AND category IS NOT NULL + AND category != '' + ORDER BY category ASC + `); + + const categories = rows.map(row => row.category).filter(Boolean); + res.json(categories); + } catch (error) { + console.error('Ошибка получения категорий:', error); + res.status(500).json({ error: 'Внутренняя ошибка сервера' }); + } +}); + +// ========== КОНЕЦ РОУТОВ ДЛЯ КАТЕГОРИЙ ========== + // Получить одну страницу по id (только для админа) router.get('/:id', async (req, res) => { if (!req.session || !req.session.authenticated) { @@ -354,10 +519,45 @@ router.patch('/:id', upload.single('file'), async (req, res) => { const incoming = req.body || {}; const updateData = {}; + + console.log(`[pages] PATCH /:id (${req.params.id}): получены данные для обновления:`, incoming); + for (const [k, v] of Object.entries(incoming)) { if (FIELDS_TO_EXCLUDE.includes(k)) continue; - updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v; + + // Нормализуем категорию: приводим к нижнему регистру для консистентности + if (k === 'category') { + updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null; + } + // Обрабатываем parent_id: может быть null или числом + else if (k === 'parent_id') { + if (v === null || v === 'null' || v === '' || v === undefined) { + updateData[k] = null; + } else { + const parsed = parseInt(v); + updateData[k] = isNaN(parsed) ? null : parsed; + } + } + // Обрабатываем order_index: должно быть числом + else if (k === 'order_index') { + if (v === null || v === 'null' || v === '' || v === undefined) { + updateData[k] = 0; + } else { + const parsed = parseInt(v); + updateData[k] = isNaN(parsed) ? 0 : parsed; + } + } + // Обрабатываем is_index_page: должно быть boolean + else if (k === 'is_index_page') { + updateData[k] = v === true || v === 'true' || v === 1 || v === '1'; + } + // Остальные поля + else { + updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v; + } } + + console.log(`[pages] PATCH /:id (${req.params.id}): обработанные данные для обновления:`, updateData); if (req.file) { updateData.format = req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf'; updateData.mime_type = req.file.mimetype || null; @@ -372,9 +572,21 @@ router.patch('/:id', upload.single('file'), async (req, res) => { values.push(req.params.id); const sql = `UPDATE ${tableName} SET ${setClause}, updated_at = NOW() WHERE id = $${entries.length + 1} RETURNING *`; + console.log(`[pages] PATCH /:id (${req.params.id}): SQL запрос:`, sql); + console.log(`[pages] PATCH /:id (${req.params.id}): значения:`, values); + const { rows } = await db.getQuery()(sql, values); if (!rows.length) return res.status(404).json({ error: 'Page not found' }); const updated = rows[0]; + + console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, { + id: updated.id, + title: updated.title, + category: updated.category, + parent_id: updated.parent_id, + order_index: updated.order_index, + is_index_page: updated.is_index_page + }); // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex) // Автоматическая индексация при обновлении отключена @@ -442,16 +654,331 @@ router.get('/public/all', async (req, res) => { return res.json([]); } - // Получаем все опубликованные страницы всех админов + // Поддержка фильтрации по категории и родителю + const { category, parent_id, search } = req.query; + let whereClause = `WHERE visibility = 'public' AND status = 'published'`; + const params = []; + let paramIndex = 1; + + if (category) { + whereClause += ` AND category = $${paramIndex}`; + params.push(category); + paramIndex++; + } + + if (parent_id !== undefined) { + if (parent_id === null || parent_id === 'null' || parent_id === '') { + whereClause += ` AND parent_id IS NULL`; + } else { + const parsed = parseInt(parent_id); + if (!isNaN(parsed)) { + whereClause += ` AND parent_id = $${paramIndex}`; + params.push(parsed); + paramIndex++; + } + } + } + + if (search) { + whereClause += ` AND (title ILIKE $${paramIndex} OR summary ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + // Сортировка: сначала по категории, затем по order_index, затем по created_at const { rows } = await db.getQuery()(` SELECT * FROM ${tableName} - WHERE status = 'published' - ORDER BY created_at DESC - `); + ${whereClause} + ORDER BY + COALESCE(category, '') ASC, + COALESCE(order_index, 0) ASC, + created_at DESC + `, params); + + console.log(`[pages] GET /public/all: найдено ${rows.length} публичных документов`); + if (rows.length > 0) { + console.log(`[pages] Примеры документов:`, rows.slice(0, 3).map(r => ({ id: r.id, title: r.title, category: r.category }))); + } res.json(rows); } catch (error) { console.error('Ошибка получения публичных страниц:', error); + // Возвращаем пустой массив вместо объекта с ошибкой, чтобы фронтенд не ломался + console.error('[pages] GET /public/all: ошибка, возвращаем пустой массив'); + res.status(500).json([]); + } +}); + +// Получить иерархическую структуру всех публичных страниц +router.get('/public/structure', 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({ categories: [] }); + } + + // Получаем все опубликованные публичные страницы + const { rows } = await db.getQuery()(` + SELECT + id, + title, + summary, + category, + parent_id, + order_index, + nav_path, + is_index_page, + created_at + FROM ${tableName} + WHERE visibility = 'public' AND status = 'published' + ORDER BY + COALESCE(category, '') ASC, + COALESCE(order_index, 0) ASC, + created_at ASC + `); + + // Группируем по категориям и строим иерархию + const categories = {}; + const pagesById = {}; + + console.log(`[pages] GET /public/structure: найдено ${rows.length} страниц`); + + rows.forEach(page => { + pagesById[page.id] = { + ...page, + children: [] + }; + + // Нормализуем категорию: приводим к нижнему регистру для консистентности + const cat = (page.category && String(page.category).trim()) + ? String(page.category).trim().toLowerCase() + : 'uncategorized'; + if (!categories[cat]) { + categories[cat] = { + name: cat, + pages: [] + }; + } + }); + + // Строим иерархию + rows.forEach(page => { + const pageObj = pagesById[page.id]; + if (page.parent_id && pagesById[page.parent_id]) { + // Дочерний документ - добавляем в children родителя + pagesById[page.parent_id].children.push(pageObj); + } else { + // Родительский документ (без parent_id) - добавляем в категорию + // Нормализуем категорию: приводим к нижнему регистру для консистентности + const cat = (page.category && String(page.category).trim()) + ? String(page.category).trim().toLowerCase() + : 'uncategorized'; + // Убеждаемся, что категория существует (на случай, если она была создана только в первом цикле) + if (!categories[cat]) { + categories[cat] = { + name: cat, + pages: [] + }; + } + categories[cat].pages.push(pageObj); + } + }); + + // Сортируем children внутри каждого родительского документа + Object.values(categories).forEach(cat => { + cat.pages.forEach(page => { + if (page.children && Array.isArray(page.children) && page.children.length > 0) { + page.children.sort((a, b) => { + if (a.order_index !== b.order_index) { + return (a.order_index || 0) - (b.order_index || 0); + } + return new Date(a.created_at) - new Date(b.created_at); + }); + } + }); + }); + + // Загружаем все категории из таблицы document_categories (включая пустые) + try { + const categoriesRes = await db.getQuery()( + `SELECT name, display_name, description, order_index + FROM document_categories + ORDER BY order_index, created_at` + ); + categoriesRes.rows.forEach(cat => { + const normalizedName = cat.name.toLowerCase(); + if (!categories[normalizedName]) { + // Добавляем пустую категорию, если её нет в списке из документов + categories[normalizedName] = { + name: normalizedName, + pages: [] + }; + } + // Обновляем отображаемое название + if (cat.display_name) { + categories[normalizedName].display_name = cat.display_name; + } + }); + } catch (err) { + console.warn('[pages] Ошибка загрузки категорий из document_categories (таблица может не существовать):', err.message); + } + + console.log(`[pages] GET /public/structure: создано ${Object.keys(categories).length} категорий:`, Object.keys(categories)); + + // Сортируем страницы в категориях: сначала родительские (с детьми), потом остальные + Object.values(categories).forEach(cat => { + cat.pages.sort((a, b) => { + // Сначала документы с детьми (родительские) + const aHasChildren = a.children && Array.isArray(a.children) && a.children.length > 0; + const bHasChildren = b.children && Array.isArray(b.children) && b.children.length > 0; + + // Если a имеет детей, а b нет - a идет первым (отрицательное значение = a перед b) + if (aHasChildren && !bHasChildren) return -1; + // Если b имеет детей, а a нет - b идет первым (положительное значение = b перед a) + if (!aHasChildren && bHasChildren) return 1; + + // Если оба с детьми или оба без детей, сортируем по order_index и created_at + if (a.order_index !== b.order_index) { + return (a.order_index || 0) - (b.order_index || 0); + } + return new Date(a.created_at) - new Date(b.created_at); + }); + + // Логируем результат сортировки для отладки + console.log(`[pages] Категория "${cat.name}": порядок документов:`, + cat.pages.map(p => ({ + id: p.id, + title: p.title, + hasChildren: p.children && Array.isArray(p.children) && p.children.length > 0, + childrenCount: p.children ? p.children.length : 0 + })) + ); + }); + + const result = { + categories: Object.values(categories), + totalPages: rows.length + }; + + // Логируем результат для отладки + console.log(`[pages] GET /public/structure: возвращаем ${result.categories.length} категорий`); + result.categories.forEach(cat => { + console.log(` - ${cat.name}: ${cat.pages.length} страниц`); + }); + + res.json(result); + } catch (error) { + console.error('Ошибка получения структуры страниц:', error); + // Возвращаем пустую структуру вместо объекта с ошибкой, чтобы фронтенд не ломался + console.error('[pages] GET /public/structure: ошибка, возвращаем пустую структуру'); + res.status(500).json({ categories: [], totalPages: 0 }); + } +}); + +// Получить навигацию для конкретного документа +router.get('/public/:id/navigation', async (req, res) => { + try { + const tableName = `admin_pages_simple`; + const pageId = parseInt(req.params.id); + + const existsRes = await db.getQuery()( + `SELECT to_regclass($1) as exists`, [tableName] + ); + + if (!existsRes.rows[0].exists) { + return res.status(404).json({ error: 'Страница не найдена' }); + } + + // Получаем текущую страницу + const { rows: currentPage } = await db.getQuery()(` + SELECT * FROM ${tableName} + WHERE id = $1 AND visibility = 'public' AND status = 'published' + `, [pageId]); + + if (currentPage.length === 0) { + return res.status(404).json({ error: 'Страница не найдена' }); + } + + const page = currentPage[0]; + const category = page.category || null; + const parentId = page.parent_id || null; + + // Получаем все страницы той же категории/родителя для навигации + let whereClause = `WHERE visibility = 'public' AND status = 'published'`; + const params = []; + let paramIndex = 1; + + if (parentId) { + whereClause += ` AND parent_id = $${paramIndex}`; + params.push(parentId); + paramIndex++; + } else if (category) { + whereClause += ` AND category = $${paramIndex} AND (parent_id IS NULL OR parent_id = 0)`; + params.push(category); + paramIndex++; + } else { + whereClause += ` AND (category IS NULL OR category = '') AND (parent_id IS NULL OR parent_id = 0)`; + } + + const { rows: siblings } = await db.getQuery()(` + SELECT id, title, nav_path, order_index + FROM ${tableName} + ${whereClause} + ORDER BY COALESCE(order_index, 0) ASC, created_at ASC + `, params); + + // Находим текущую страницу в списке + const currentIndex = siblings.findIndex(p => p.id === pageId); + const prevPage = currentIndex > 0 ? siblings[currentIndex - 1] : null; + const nextPage = currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null; + + // Получаем родительскую страницу + let parentPage = null; + if (parentId) { + const { rows: parent } = await db.getQuery()(` + SELECT id, title, nav_path FROM ${tableName} + WHERE id = $1 AND visibility = 'public' AND status = 'published' + `, [parentId]); + if (parent.length > 0) { + parentPage = parent[0]; + } + } + + // Формируем breadcrumbs + const breadcrumbs = []; + if (category) { + breadcrumbs.push({ name: category, path: null }); + } + if (parentPage) { + breadcrumbs.push({ name: parentPage.title, path: `/content/published/${parentPage.id}` }); + } + breadcrumbs.push({ name: page.title, path: null }); + + res.json({ + previous: prevPage ? { + id: prevPage.id, + title: prevPage.title, + path: `/content/published/${prevPage.id}` + } : null, + next: nextPage ? { + id: nextPage.id, + title: nextPage.title, + path: `/content/published/${nextPage.id}` + } : null, + parent: parentPage ? { + id: parentPage.id, + title: parentPage.title, + path: `/content/published/${parentPage.id}` + } : null, + breadcrumbs + }); + } catch (error) { + console.error('Ошибка получения навигации:', error); res.status(500).json({ error: 'Внутренняя ошибка сервера' }); } }); diff --git a/backend/routes/uploads.js b/backend/routes/uploads.js index 5e608ce..0ce5474 100644 --- a/backend/routes/uploads.js +++ b/backend/routes/uploads.js @@ -17,6 +17,7 @@ const express = require('express'); const path = require('path'); const fs = require('fs'); const multer = require('multer'); +const crypto = require('crypto'); const auth = require('../middleware/auth'); const router = express.Router(); @@ -59,6 +60,354 @@ router.post('/logo', auth.requireAuth, auth.requireAdmin, upload.single('logo'), } }); +// Хранилище для медиа-файлов контента в памяти (для сохранения в БД) +// Ограничение по размеру не установлено - база данных масштабируется +const mediaUpload = multer({ + storage: multer.memoryStorage(), // Храним в памяти для сохранения в БД + // limits: { fileSize: ... } - убрано, нет ограничений по размеру + fileFilter: (req, file, cb) => { + // Разрешаем изображения и видео + const isImage = /^image\/(png|jpg|jpeg|gif|webp|svg)$/i.test(file.mimetype || ''); + const isVideo = /^video\/(mp4|webm|ogg|mov|avi)$/i.test(file.mimetype || ''); + if (!isImage && !isVideo) { + return cb(new Error('Разрешены только изображения (PNG, JPG, GIF, WEBP, SVG) и видео (MP4, WEBM, OGG, MOV, AVI)')); + } + cb(null, true); + } +}); + +// POST /api/uploads/media (form field: media) - для загрузки изображений и видео для контента +// Используем те же права, что и для создания страниц (требуется аутентификация и права редактора/админа) +router.post('/media', auth.requireAuth, async (req, res) => { + // Проверяем права доступа (редактор или админ) + if (!req.session.address) { + return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' }); + } + + // Используем middleware для загрузки файла + mediaUpload.single('media')(req, res, async (err) => { + if (err) { + return res.status(400).json({ success: false, message: err.message }); + } + + try { + if (!req.file || !req.file.buffer) return res.status(400).json({ success: false, message: 'Файл не получен' }); + + const db = require('../db'); + const mediaType = req.file.mimetype.startsWith('image/') ? 'image' : 'video'; + + // Вычисляем SHA-256 хеш файла для дедупликации + const fileHash = crypto.createHash('sha256').update(req.file.buffer).digest('hex'); + + // Проверяем, не загружен ли уже такой файл + const existingFile = await db.getQuery()( + 'SELECT id, file_name FROM content_media WHERE file_hash = $1', + [fileHash] + ); + + let mediaId; + let fileName; + + if (existingFile.rows.length > 0) { + // Файл уже существует - возвращаем существующую запись + mediaId = existingFile.rows[0].id; + fileName = existingFile.rows[0].file_name; + } else { + // Сохраняем новый файл в базу данных + const { rows } = await db.getQuery()(` + INSERT INTO content_media ( + file_data, + file_name, + mime_type, + file_size, + file_hash, + media_type, + author_address, + page_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, file_name + `, [ + req.file.buffer, // BYTEA данные + req.file.originalname || 'unnamed', + req.file.mimetype, + req.file.size, + fileHash, + mediaType, + req.session.address, + req.body.page_id || null + ]); + + mediaId = rows[0].id; + fileName = rows[0].file_name; + } + + // URL для доступа к файлу через API + const fileUrl = `/api/uploads/media/${mediaId}/file`; + // Используем относительный URL, чтобы frontend сам формировал полный URL + // Это позволяет работать с разными портами (frontend на 9000, backend на 8000) + const fullUrl = fileUrl; + + return res.json({ + success: true, + data: { + id: mediaId, + url: fullUrl, + type: mediaType, + filename: fileName, + originalName: req.file.originalname || 'unnamed', + mimeType: req.file.mimetype, + size: req.file.size, + hash: fileHash, + isDuplicate: existingFile.rows.length > 0 + } + }); + } catch (e) { + console.error('Ошибка сохранения медиа в БД:', e); + return res.status(500).json({ success: false, message: e.message }); + } + }); +}); + +// GET /api/uploads/media/:id/file - получить файл по ID +router.get('/media/:id/file', async (req, res) => { + try { + const db = require('../db'); + const mediaId = parseInt(req.params.id); + + const { rows } = await db.getQuery()( + 'SELECT file_data, file_name, mime_type, file_size FROM content_media WHERE id = $1', + [mediaId] + ); + + if (rows.length === 0) { + return res.status(404).json({ success: false, message: 'Медиа-файл не найден' }); + } + + const media = rows[0]; + + // Устанавливаем заголовки для правильной отдачи файла + res.setHeader('Content-Type', media.mime_type); + res.setHeader('Content-Length', media.file_size); + res.setHeader('Content-Disposition', `inline; filename="${media.file_name}"`); + res.setHeader('Cache-Control', 'public, max-age=31536000'); // Кеширование на 1 год + + // Отправляем бинарные данные + res.send(media.file_data); + } catch (e) { + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// GET /api/uploads/media - получить список медиа-файлов +router.get('/media', auth.requireAuth, async (req, res) => { + try { + if (!req.session.address) { + return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' }); + } + + const db = require('../db'); + const { page_id, media_type, limit = 50, offset = 0 } = req.query; + + let whereClause = 'WHERE 1=1'; + const params = []; + let paramIndex = 1; + + if (page_id) { + whereClause += ` AND page_id = $${paramIndex}`; + params.push(parseInt(page_id)); + paramIndex++; + } + + if (media_type) { + whereClause += ` AND media_type = $${paramIndex}`; + params.push(media_type); + paramIndex++; + } + + params.push(parseInt(limit)); + params.push(parseInt(offset)); + + const { rows } = await db.getQuery()(` + SELECT + id, + page_id, + file_name, + mime_type, + file_size, + file_hash, + media_type, + alt_text, + title, + description, + author_address, + created_at, + updated_at + FROM content_media + ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1} + `, params); + + // Добавляем URL для каждого файла + const protocol = req.protocol || 'http'; + const host = req.get('host') || 'localhost:8000'; + const mediaWithUrls = rows.map(media => ({ + ...media, + url: `${protocol}://${host}/api/uploads/media/${media.id}/file` + })); + + const { rows: countRows } = await db.getQuery()(` + SELECT COUNT(*) as total + FROM content_media + ${whereClause} + `, params.slice(0, -2)); + + return res.json({ + success: true, + data: mediaWithUrls, + total: parseInt(countRows[0].total), + limit: parseInt(limit), + offset: parseInt(offset) + }); + } catch (e) { + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// PATCH /api/uploads/media/:id - обновить метаданные медиа (например, связать с документом) +router.patch('/media/:id', auth.requireAuth, async (req, res) => { + try { + if (!req.session.address) { + return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' }); + } + + const db = require('../db'); + const mediaId = parseInt(req.params.id); + const { page_id, alt_text, title, description } = req.body; + + const updates = []; + const params = []; + let paramIndex = 1; + + if (page_id !== undefined) { + updates.push(`page_id = $${paramIndex}`); + params.push(page_id ? parseInt(page_id) : null); + paramIndex++; + } + + if (alt_text !== undefined) { + updates.push(`alt_text = $${paramIndex}`); + params.push(alt_text || null); + paramIndex++; + } + + if (title !== undefined) { + updates.push(`title = $${paramIndex}`); + params.push(title || null); + paramIndex++; + } + + if (description !== undefined) { + updates.push(`description = $${paramIndex}`); + params.push(description || null); + paramIndex++; + } + + if (updates.length === 0) { + return res.status(400).json({ success: false, message: 'Нет полей для обновления' }); + } + + params.push(mediaId); + + const { rows } = await db.getQuery()(` + UPDATE content_media + SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP + WHERE id = $${paramIndex} + RETURNING * + `, params); + + if (rows.length === 0) { + return res.status(404).json({ success: false, message: 'Медиа-файл не найден' }); + } + + return res.json({ success: true, data: rows[0] }); + } catch (e) { + return res.status(500).json({ success: false, message: e.message }); + } +}); + +// DELETE /api/uploads/media/:id - удалить медиа-файл +router.delete('/media/:id', auth.requireAuth, async (req, res) => { + try { + if (!req.session.address) { + return res.status(403).json({ success: false, message: 'Требуется подключение кошелька' }); + } + + const authService = require('../services/auth-service'); + const userAccessLevel = await authService.getUserAccessLevel(req.session.address); + if (!userAccessLevel.hasAccess) { + return res.status(403).json({ success: false, message: 'Требуются права редактора или админа' }); + } + + const db = require('../db'); + const mediaId = parseInt(req.params.id); + + // Проверяем существование файла + const { rows: mediaRows } = await db.getQuery()( + 'SELECT id, file_hash FROM content_media WHERE id = $1', + [mediaId] + ); + + if (mediaRows.length === 0) { + return res.status(404).json({ success: false, message: 'Медиа-файл не найден' }); + } + + // Проверяем, используется ли файл в других документах (если есть file_hash) + const fileHash = mediaRows[0].file_hash; + if (fileHash) { + const { rows: usageRows } = await db.getQuery()( + 'SELECT COUNT(*) as count FROM content_media WHERE file_hash = $1', + [fileHash] + ); + + // Если файл используется в нескольких местах, не удаляем данные, только связь + if (parseInt(usageRows[0].count) > 1) { + // Просто удаляем связь с документом, но оставляем файл + await db.getQuery()( + 'UPDATE content_media SET page_id = NULL WHERE id = $1', + [mediaId] + ); + return res.json({ success: true, message: 'Связь с документом удалена, файл сохранен (используется в других местах)' }); + } + } + + // Удаляем запись из БД (файл удалится вместе с записью) + await db.getQuery()('DELETE FROM content_media WHERE id = $1', [mediaId]); + + return res.json({ success: true, message: 'Медиа-файл удален' }); + } catch (e) { + return res.status(500).json({ success: false, message: e.message }); + } +}); + module.exports = router; diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index b0308de..3c31c6c 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -543,45 +543,8 @@ class UniversalGuestService { }; } - // Проверяем согласия для добавления системного сообщения к ответу ИИ - 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 - }); - - // Формируем финальный ответ ИИ с системным сообщением, если нужно + // 4. Сохраняем AI ответ let finalAiResponse = aiResponse.response; - let consentInfo = null; - - if (consentCheck.needsConsent) { - const consentSystemMessage = await consentService.getConsentSystemMessage({ - userId: null, - walletAddress, - channel: channel === 'web' ? 'web' : channel - }); - - 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: finalAiResponse, @@ -600,14 +563,6 @@ class UniversalGuestService { } }; - // Добавляем информацию о согласиях, если они нужны - if (consentInfo) { - result.consentRequired = consentInfo.consentRequired; - result.missingConsents = consentInfo.missingConsents; - result.consentDocuments = consentInfo.consentDocuments; - result.autoConsentOnReply = consentInfo.autoConsentOnReply; - } - return result; } catch (error) { diff --git a/backend/services/unifiedMessageProcessor.js b/backend/services/unifiedMessageProcessor.js index 62fb6d5..5f3ed00 100644 --- a/backend/services/unifiedMessageProcessor.js +++ b/backend/services/unifiedMessageProcessor.js @@ -380,30 +380,10 @@ async function processMessage(messageData) { }); if (aiResponse && aiResponse.success && aiResponse.response) { - // Проверяем согласия и добавляем системное сообщение к ответу ИИ - const walletIdentity = await identityService.findIdentity(userId, 'wallet'); - const consentSystemMessage = await consentService.getConsentSystemMessage({ - userId, - walletAddress: walletIdentity?.provider_id || null, - channel: channel === 'web' ? 'web' : channel - }); - - // Формируем финальный ответ ИИ с системным сообщением, если нужно + // Формируем финальный ответ ИИ 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 с добавленным системным сообщением + // Сохраняем ответ AI const { rows: aiMessageRows } = await db.getQuery()( `INSERT INTO messages ( conversation_id, @@ -478,14 +458,6 @@ async function processMessage(messageData) { assistantDisabled: aiResponseDisabled }; - // Если есть информация о согласиях, добавляем её в результат - 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) { diff --git a/frontend/nginx-local.conf b/frontend/nginx-local.conf index eb1b82e..0779458 100644 --- a/frontend/nginx-local.conf +++ b/frontend/nginx-local.conf @@ -6,6 +6,9 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + # Убираем ограничение по размеру загружаемых файлов (база данных масштабируется) + client_max_body_size 0; + # Rate limiting для защиты от DDoS limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s; diff --git a/frontend/nginx-simple.conf b/frontend/nginx-simple.conf index 8efa42d..39fcf6f 100644 --- a/frontend/nginx-simple.conf +++ b/frontend/nginx-simple.conf @@ -6,6 +6,9 @@ http { include /etc/nginx/mime.types; default_type application/octet-stream; + # Убираем ограничение по размеру загружаемых файлов (база данных масштабируется) + client_max_body_size 0; + # Rate limiting для защиты от DDoS limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s; diff --git a/frontend/package.json b/frontend/package.json index 702c12f..8840f8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,10 +25,13 @@ "ethers": "6.13.5", "marked": "^15.0.7", "papaparse": "^5.5.3", + "quill": "^2.0.3", + "quill-image-resize-module": "^3.0.0", "siwe": "^2.1.4", "sortablejs": "^1.15.6", "vue": "^3.2.47", "vue-i18n": "^11.1.2", + "vue-quill": "^1.5.1", "vue-router": "^4.1.6", "vuex": "^4.1.0" }, diff --git a/frontend/src/components/docs/DocsContent.vue b/frontend/src/components/docs/DocsContent.vue new file mode 100644 index 0000000..9d4cb90 --- /dev/null +++ b/frontend/src/components/docs/DocsContent.vue @@ -0,0 +1,831 @@ + + + + + + + + diff --git a/frontend/src/components/docs/DocsSidebar.vue b/frontend/src/components/docs/DocsSidebar.vue new file mode 100644 index 0000000..98f3ff5 --- /dev/null +++ b/frontend/src/components/docs/DocsSidebar.vue @@ -0,0 +1,1399 @@ + + + + + + + + diff --git a/frontend/src/components/editor/RichTextEditor.vue b/frontend/src/components/editor/RichTextEditor.vue new file mode 100644 index 0000000..1c28fe7 --- /dev/null +++ b/frontend/src/components/editor/RichTextEditor.vue @@ -0,0 +1,385 @@ + + + + + + + + diff --git a/frontend/src/services/pagesService.js b/frontend/src/services/pagesService.js index 01a4bb6..874e9de 100644 --- a/frontend/src/services/pagesService.js +++ b/frontend/src/services/pagesService.js @@ -28,8 +28,16 @@ export default { return res.data; }, async updatePage(id, data) { - const res = await api.patch(`/pages/${id}`, data); - return res.data; + console.log('[pagesService] updatePage:', { id, data }); + try { + const res = await api.patch(`/pages/${id}`, data); + console.log('[pagesService] updatePage успешно:', res.data); + return res.data; + } catch (error) { + console.error('[pagesService] updatePage ошибка:', error); + console.error('[pagesService] updatePage ошибка response:', error.response?.data); + throw error; + } }, async deletePage(id) { const res = await api.delete(`/pages/${id}`); @@ -37,8 +45,28 @@ export default { }, // Публичные методы (доступны всем пользователям) - async getPublicPages() { - const res = await api.get('/pages/public/all'); + async getPublicPages(params = {}) { + const queryParams = new URLSearchParams(); + if (params.category) queryParams.append('category', params.category); + if (params.parent_id !== undefined) queryParams.append('parent_id', params.parent_id); + if (params.search) queryParams.append('search', params.search); + + const url = `/pages/public/all${queryParams.toString() ? '?' + queryParams.toString() : ''}`; + const res = await api.get(url); + console.log('[pagesService] getPublicPages response:', { + status: res.status, + dataLength: Array.isArray(res.data) ? res.data.length : 'not array', + dataType: typeof res.data, + firstItem: Array.isArray(res.data) && res.data.length > 0 ? res.data[0] : null + }); + return res.data; + }, + async getPublicPagesStructure() { + const res = await api.get('/pages/public/structure'); + return res.data; + }, + async getPublicPageNavigation(id) { + const res = await api.get(`/pages/public/${id}/navigation`); return res.data; }, async getInternalPages() { @@ -49,4 +77,21 @@ export default { const res = await api.get(`/pages/public/${id}`); return res.data; }, + async getCategories() { + const res = await api.get('/pages/categories'); + return res.data; + }, + async createCategory(name, display_name, description, order_index) { + const res = await api.post('/pages/categories', { + name, + display_name, + description, + order_index + }); + return res.data; + }, + async deleteCategory(name) { + const res = await api.delete(`/pages/categories/${encodeURIComponent(name)}`); + return res.data; + }, }; \ No newline at end of file diff --git a/frontend/src/views/ContentPageView.vue b/frontend/src/views/ContentPageView.vue index fb59c0f..42f8691 100644 --- a/frontend/src/views/ContentPageView.vue +++ b/frontend/src/views/ContentPageView.vue @@ -93,6 +93,29 @@ class="form-textarea" /> +
+ +
+ + +
+
@@ -100,13 +123,9 @@

Содержание

-