ваше сообщение коммита

This commit is contained in:
2025-11-24 21:56:35 +03:00
parent 2087ba2d37
commit 9b0e133118
16 changed files with 5053 additions and 262 deletions

View File

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

View File

@@ -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: 'Внутренняя ошибка сервера' });
}
});

View File

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