Контент не добавлен
+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 @@
+
+
+
+ Контент не добавлен Загрузка документа... Запрашиваемый документ не существует или не опубликован{{ page.title }}
+
+ Скачать изображение
+
Документ не найден
+
Публичные документы, доступные пользователям
Загрузка документов...
{{ p.summary || 'Без описания' }}
- -{{ page.summary || 'Без описания' }}
+ +