/** * Copyright (c) 2024-2026 Тарабанов Александр Викторович * All rights reserved. * * This software is proprietary and confidential. * Unauthorized copying, modification, or distribution is prohibited. * * For licensing inquiries: info@hb3-accelerator.com * Website: https://hb3-accelerator.com * GitHub: https://github.com/VC-HB3-Accelerator */ /** * Загрузка файлов (логотипы) через Multer */ 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(); // Хранилище на диске: uploads/logos const storage = multer.diskStorage({ destination: function (req, file, cb) { const dir = path.join(__dirname, '..', 'uploads', 'logos'); try { fs.mkdirSync(dir, { recursive: true }); } catch (_) {} cb(null, dir); }, filename: function (req, file, cb) { const ext = (file.originalname || '').split('.').pop(); const safeExt = ext && ext.length <= 10 ? ext : 'png'; const name = `logo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${safeExt}`; cb(null, name); } }); const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const ok = /(png|jpg|jpeg|gif|webp)$/i.test(file.originalname || '') && /^image\//i.test(file.mimetype || ''); if (!ok) return cb(new Error('Only image files are allowed')); cb(null, true); } }); // POST /api/uploads/logo (form field: logo) router.post('/logo', auth.requireAuth, auth.requireAdmin, upload.single('logo'), async (req, res) => { try { if (!req.file) return res.status(400).json({ success: false, message: 'Файл не получен' }); const rel = path.posix.join('uploads', 'logos', path.basename(req.file.filename)); const urlPath = `/uploads/logos/${path.basename(req.file.filename)}`; const fullUrl = `http://localhost:8000${urlPath}`; return res.json({ success: true, data: { path: rel, url: fullUrl } }); } catch (e) { return res.status(500).json({ success: false, message: e.message }); } }); // Хранилище для медиа-файлов контента в памяти (для сохранения в БД) // Ограничение по размеру не установлено - база данных масштабируется 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) { console.error('[uploads/media] Ошибка multer:', err); // Формируем понятное сообщение об ошибке let errorMessage = err.message || 'Ошибка загрузки файла'; if (err.code === 'LIMIT_FILE_SIZE') { errorMessage = 'Файл слишком большой'; } else if (err.code === 'LIMIT_UNEXPECTED_FILE') { errorMessage = 'Неожиданный файл'; } return res.status(400).json({ success: false, message: errorMessage }); } try { if (!req.file) { console.error('[uploads/media] Файл не получен в req.file'); return res.status(400).json({ success: false, message: 'Файл не получен' }); } if (!req.file.buffer) { console.error('[uploads/media] Буфер файла отсутствует'); return res.status(400).json({ success: false, message: 'Буфер файла отсутствует' }); } if (!req.file.mimetype) { console.error('[uploads/media] MIME тип отсутствует'); return res.status(400).json({ success: false, message: 'MIME тип файла не определен' }); } const db = require('../db'); const mediaType = req.file.mimetype.startsWith('image/') ? 'image' : 'video'; console.log('[uploads/media] Начало обработки файла:', { originalname: req.file.originalname, mimetype: req.file.mimetype, size: req.file.size, mediaType: mediaType }); // Вычисляем SHA-256 хеш файла для дедупликации const fileHash = crypto.createHash('sha256').update(req.file.buffer).digest('hex'); console.log('[uploads/media] Хеш файла вычислен:', fileHash.substring(0, 16) + '...'); // Проверяем, не загружен ли уже такой файл 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) { // Файл уже существует - возвращаем существующую запись console.log('[uploads/media] Файл уже существует, используем существующую запись:', existingFile.rows[0].id); mediaId = existingFile.rows[0].id; fileName = existingFile.rows[0].file_name; } else { // Сохраняем новый файл в базу данных console.log('[uploads/media] Сохранение нового файла в БД...'); // Нормализуем page_id: преобразуем в число или null let pageId = null; if (req.body.page_id) { const parsedPageId = parseInt(req.body.page_id); if (!isNaN(parsedPageId) && parsedPageId > 0) { pageId = parsedPageId; } } 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, pageId ]); mediaId = rows[0].id; fileName = rows[0].file_name; console.log('[uploads/media] Файл успешно сохранен в БД, ID:', mediaId); } // URL для доступа к файлу через API const fileUrl = `/api/uploads/media/${mediaId}/file`; // Используем относительный URL, чтобы frontend сам формировал полный URL // Это позволяет работать с разными портами (frontend на 9000, backend на 8000) const fullUrl = fileUrl; console.log('[uploads/media] Успешная загрузка, возвращаем ответ'); 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('[uploads/media] Ошибка сохранения медиа в БД:', { message: e.message, stack: e.stack, name: e.name, code: e.code, detail: e.detail, constraint: e.constraint, table: e.table, column: e.column }); // Проверяем, можно ли отправлять ответ if (res.headersSent || res.destroyed) { console.error('[uploads/media] Ответ уже отправлен или соединение закрыто, пропускаем отправку ошибки'); return; } // Формируем понятное сообщение об ошибке let errorMessage = e.message || 'Внутренняя ошибка сервера'; let statusCode = 500; // Обработка ошибок подключения к БД if (e.message && e.message.includes('timeout exceeded when trying to connect')) { errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; statusCode = 503; // Service Unavailable } else if (e.detail) { // Если есть детали ошибки PostgreSQL, добавляем их к сообщению errorMessage += `: ${e.detail}`; } else if (e.constraint) { errorMessage += ` (нарушение ограничения: ${e.constraint})`; } return res.status(statusCode).json({ success: false, message: errorMessage // Убираем объект error, чтобы фронтенд не получал [object Object] }); } }); }); // Middleware для логирования всех запросов к медиа-файлам router.use('/media/:id/file', (req, res, next) => { const mediaId = parseInt(req.params.id); console.log(`[uploads/media/:id/file] MIDDLEWARE: Запрос к файлу ID: ${mediaId}, метод: ${req.method}, Range: ${req.headers.range || 'нет'}`); next(); }); // GET /api/uploads/media/:id/file - получить файл по ID с поддержкой Range requests router.get('/media/:id/file', async (req, res) => { let client = null; let clientReleased = false; // Флаг для предотвращения двойного освобождения клиента const mediaId = parseInt(req.params.id); console.log(`[uploads/media/:id/file] HANDLER: Начало обработки запроса для файла ID: ${mediaId}`); // Валидация mediaId if (isNaN(mediaId) || mediaId <= 0) { console.error(`[uploads/media/:id/file] Неверный ID файла: ${req.params.id}`); if (!res.headersSent && !res.destroyed) { return res.status(400).json({ success: false, message: 'Неверный ID файла' }); } return; } // Увеличиваем chunk size до 1MB для больших файлов - меньше запросов к БД const chunkSize = 1048576; // 1MB chunks для оптимальной производительности стриминга console.log(`[uploads/media/:id/file] Запрос файла ID: ${mediaId}, Range: ${req.headers.range || 'нет'}`); // Функция для безопасного освобождения клиента const releaseClient = () => { if (client && !clientReleased) { clientReleased = true; try { client.release(); } catch (releaseErr) { console.error(`[uploads/media/:id/file] Ошибка освобождения клиента для файла ID ${mediaId}:`, releaseErr); } } }; // Обработчики событий для очистки let connectionTimeoutHandle = null; let responseErrorHandler = null; let responseCloseHandler = null; // Функция для очистки всех обработчиков и таймеров const cleanup = () => { if (connectionTimeoutHandle) { clearTimeout(connectionTimeoutHandle); connectionTimeoutHandle = null; } if (responseErrorHandler && res.removeListener) { res.removeListener('error', responseErrorHandler); } if (responseCloseHandler && res.removeListener) { res.removeListener('close', responseCloseHandler); } }; try { const db = require('../db'); const pool = db.getPool(); // Добавляем таймаут для подключения к пулу (10 секунд) const connectionTimeout = new Promise((_, reject) => { connectionTimeoutHandle = setTimeout(() => { reject(new Error('timeout exceeded when trying to connect')); }, 30000); // Увеличиваем таймаут до 30 секунд (было 10) }); try { client = await Promise.race([ pool.connect().then(client => { // Очищаем таймер при успешном подключении if (connectionTimeoutHandle) { clearTimeout(connectionTimeoutHandle); connectionTimeoutHandle = null; } return client; }), connectionTimeout ]); console.log(`[uploads/media/:id/file] Клиент БД подключен для файла ID: ${mediaId}`); } catch (connectErr) { // Очищаем таймер при ошибке if (connectionTimeoutHandle) { clearTimeout(connectionTimeoutHandle); connectionTimeoutHandle = null; } console.error(`[uploads/media/:id/file] Ошибка подключения к БД для файла ID ${mediaId}:`, { message: connectErr.message, stack: connectErr.stack }); if (!res.headersSent && !res.destroyed) { return res.status(503).json({ success: false, message: 'Ошибка подключения к базе данных. Попробуйте позже.' }); } return; } // Проверяем, не закрыто ли соединение перед запросом к БД if (res.destroyed || res.headersSent) { releaseClient(); cleanup(); return; } // Сначала получаем метаданные без file_data let metaResult; try { metaResult = await client.query( 'SELECT file_name, mime_type, file_size FROM content_media WHERE id = $1', [mediaId] ); } catch (queryErr) { console.error(`[uploads/media/:id/file] Ошибка запроса метаданных для файла ID ${mediaId}:`, { message: queryErr.message, stack: queryErr.stack }); releaseClient(); cleanup(); if (!res.headersSent && !res.destroyed) { return res.status(500).json({ success: false, message: 'Ошибка получения метаданных файла' }); } return; } if (metaResult.rows.length === 0) { console.error(`[uploads/media/:id/file] Файл не найден: ID ${mediaId}`); releaseClient(); cleanup(); if (!res.headersSent && !res.destroyed) { return res.status(404).json({ success: false, message: 'Медиа-файл не найден' }); } return; } const media = metaResult.rows[0]; const fileSize = parseInt(media.file_size) || 0; console.log(`[uploads/media/:id/file] Файл найден: ID ${mediaId}, размер: ${fileSize} bytes, тип: ${media.mime_type}`); // Проверяем, не закрыто ли соединение перед установкой заголовков if (res.destroyed || res.headersSent) { releaseClient(); cleanup(); return; } // Поддержка HTTP Range requests для стриминга (как на YouTube/Vimeo) const range = req.headers.range; let start = 0; let end = fileSize - 1; let statusCode = 200; if (range) { // Парсим Range заголовок (например: "bytes=0-1023" или "bytes=1024-") const parts = range.replace(/bytes=/, '').split('-'); start = parseInt(parts[0], 10); end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; // Валидация диапазона if (start >= fileSize || end >= fileSize || start < 0 || end < start) { if (!res.headersSent && !res.destroyed) { res.setHeader('Content-Range', `bytes */${fileSize}`); releaseClient(); cleanup(); return res.status(416).end(); // Range Not Satisfiable } releaseClient(); cleanup(); return; } statusCode = 206; // Partial Content } const contentLength = end - start + 1; // Устанавливаем заголовки для правильной отдачи файла с поддержкой Range // Проверяем, не закрыто ли соединение перед установкой заголовков if (res.destroyed || res.headersSent) { releaseClient(); return; } res.setHeader('Content-Type', media.mime_type); res.setHeader('Accept-Ranges', 'bytes'); // Указываем, что поддерживаем Range requests res.setHeader('Content-Length', contentLength); res.setHeader('Cache-Control', 'public, max-age=31536000'); // Кеширование на 1 год // Правильное кодирование имени файла для HTTP заголовков (RFC 5987) // Экранируем специальные символы и используем ASCII для совместимости const safeFileName = media.file_name .replace(/"/g, '\\"') // Экранируем кавычки .replace(/\n/g, '') // Убираем переносы строк .replace(/\r/g, ''); // Убираем возврат каретки // Для кириллицы и специальных символов используем RFC 5987 формат const encodedFileName = encodeURIComponent(media.file_name); if (range) { res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`); res.status(statusCode); } else { // Используем оба формата: ASCII для совместимости и UTF-8 для корректного отображения res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"; filename*=UTF-8''${encodedFileName}`); } // Используем прямой стриминг BYTEA данных частями через SQL substring // Начинаем с нужной позиции (для Range requests) let offset = start + 1; // PostgreSQL substring использует 1-based индексацию const endOffset = end + 1; const streamChunk = async () => { try { // Проверяем, не освобожден ли клиент или не закрыто ли соединение if (clientReleased || !client || res.destroyed) { if (!clientReleased) { releaseClient(); } if (!res.destroyed && res.headersSent) { res.end(); } return; // Клиент уже освобожден или соединение закрыто, прекращаем стриминг } // Проверяем, не достигли ли мы конца запрошенного диапазона if (offset > endOffset) { // Достигнут конец запрошенного диапазона releaseClient(); if (!res.destroyed) { res.end(); } return; } // Вычисляем размер текущего chunk (может быть меньше chunkSize для последнего chunk) const currentChunkSize = Math.min(chunkSize, endOffset - offset + 1); // Читаем следующий chunk данных, используя encode для получения hex-строки let chunkResult; try { chunkResult = await client.query( `SELECT encode(substring(file_data FROM $1 FOR $2), 'hex') as chunk_hex FROM content_media WHERE id = $3`, [offset, currentChunkSize, mediaId] ); } catch (queryErr) { // Если ошибка запроса, но соединение закрыто - просто выходим if (res.destroyed || clientReleased) { return; } throw queryErr; // Пробрасываем ошибку дальше для обработки } if (chunkResult.rows.length === 0 || !chunkResult.rows[0] || !chunkResult.rows[0].chunk_hex) { // Достигнут конец файла или данные отсутствуют releaseClient(); if (!res.destroyed) { res.end(); } return; } const chunkHex = chunkResult.rows[0].chunk_hex; // Если chunk пустой, значит достигнут конец if (!chunkHex || chunkHex.length === 0) { releaseClient(); if (!res.destroyed) { res.end(); } return; } // Преобразуем hex-строку в Buffer const buffer = Buffer.from(chunkHex, 'hex'); // Проверяем, не закрыто ли соединение перед отправкой данных if (res.destroyed) { releaseClient(); return; } // Отправляем chunk клиенту if (!res.write(buffer)) { // Буфер переполнен, ждем события 'drain' res.once('drain', () => { // Проверяем, не освобожден ли клиент и не закрыто ли соединение перед продолжением if (!clientReleased && client && !res.destroyed) { offset += currentChunkSize; streamChunk(); } else { releaseClient(); } }); } else { // Продолжаем отправку следующего chunk if (!clientReleased && client && !res.destroyed) { offset += currentChunkSize; streamChunk(); } else { releaseClient(); } } } catch (chunkErr) { // Игнорируем ошибки, если клиент уже освобожден или соединение закрыто if (clientReleased || res.destroyed) { return; } console.error('[uploads/media/:id/file] Ошибка чтения chunk:', { message: chunkErr.message, stack: chunkErr.stack, offset: offset, endOffset: endOffset, fileSize: fileSize }); releaseClient(); // Если заголовки еще не отправлены, отправляем ошибку if (!res.headersSent && !res.destroyed) { res.status(500).json({ success: false, message: 'Ошибка чтения файла' }); } else if (!res.destroyed) { // Если заголовки уже отправлены, просто завершаем соединение res.end(); } } }; // Начинаем стриминг streamChunk(); // Обработка ошибок HTTP ответа responseErrorHandler = (resErr) => { console.error(`[uploads/media/:id/file] Ошибка HTTP ответа для файла ID ${mediaId}:`, resErr); releaseClient(); cleanup(); }; res.on('error', responseErrorHandler); // Обработка закрытия соединения клиентом responseCloseHandler = () => { // Если соединение закрыто клиентом до завершения стриминга, освобождаем клиент if (!clientReleased) { console.log(`[uploads/media/:id/file] Соединение закрыто клиентом для файла ID ${mediaId}, освобождаем клиент БД`); releaseClient(); } cleanup(); }; res.on('close', responseCloseHandler); } catch (e) { releaseClient(); cleanup(); console.error(`[uploads/media/:id/file] Ошибка получения файла ID ${mediaId}:`, { message: e.message, stack: e.stack, name: e.name, code: e.code, detail: e.detail, constraint: e.constraint, table: e.table, column: e.column, mediaId: mediaId }); // Проверяем, можно ли отправлять ответ if (!res.headersSent && !res.destroyed) { // Для ошибок подключения к БД возвращаем специальный статус const statusCode = e.message && e.message.includes('timeout exceeded when trying to connect') ? 503 : 500; const message = e.message && e.message.includes('timeout exceeded when trying to connect') ? 'Ошибка подключения к базе данных. Попробуйте позже.' : (e.message || 'Внутренняя ошибка сервера'); return res.status(statusCode).json({ success: false, message: message, error: process.env.NODE_ENV === 'development' ? { name: e.name, code: e.code, detail: e.detail, constraint: e.constraint } : undefined }); } } }); // 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) { console.error('[uploads/media GET] Ошибка получения списка медиа:', { message: e.message, stack: e.stack }); // Проверяем, можно ли отправлять ответ if (res.headersSent || res.destroyed) { console.error('[uploads/media GET] Ответ уже отправлен или соединение закрыто'); return; } // Обработка ошибок подключения к БД let statusCode = 500; let errorMessage = e.message || 'Внутренняя ошибка сервера'; if (e.message && e.message.includes('timeout exceeded when trying to connect')) { errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; statusCode = 503; } return res.status(statusCode).json({ success: false, message: errorMessage }); } }); // 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) { console.error('[uploads/media/:id PATCH] Ошибка обновления медиа:', { message: e.message, stack: e.stack, mediaId: parseInt(req.params.id) }); // Проверяем, можно ли отправлять ответ if (res.headersSent || res.destroyed) { console.error('[uploads/media/:id PATCH] Ответ уже отправлен или соединение закрыто'); return; } // Обработка ошибок подключения к БД let statusCode = 500; let errorMessage = e.message || 'Внутренняя ошибка сервера'; if (e.message && e.message.includes('timeout exceeded when trying to connect')) { errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; statusCode = 503; } return res.status(statusCode).json({ success: false, message: errorMessage }); } }); // 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) { console.error('[uploads/media/:id DELETE] Ошибка удаления медиа:', { message: e.message, stack: e.stack, mediaId: parseInt(req.params.id) }); // Проверяем, можно ли отправлять ответ if (res.headersSent || res.destroyed) { console.error('[uploads/media/:id DELETE] Ответ уже отправлен или соединение закрыто'); return; } // Обработка ошибок подключения к БД let statusCode = 500; let errorMessage = e.message || 'Внутренняя ошибка сервера'; if (e.message && e.message.includes('timeout exceeded when trying to connect')) { errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.'; statusCode = 503; } return res.status(statusCode).json({ success: false, message: errorMessage }); } }); module.exports = router;