915 lines
36 KiB
JavaScript
915 lines
36 KiB
JavaScript
/**
|
||
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
|
||
* All rights reserved.
|
||
*
|
||
* This software is proprietary and confidential.
|
||
* Unauthorized copying, modification, or distribution is prohibited.
|
||
*
|
||
* For licensing inquiries: info@hb3-accelerator.com
|
||
* Website: https://hb3-accelerator.com
|
||
* GitHub: https://github.com/VC-HB3-Accelerator
|
||
*/
|
||
|
||
/**
|
||
* Загрузка файлов (логотипы) через 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;
|
||
|
||
|