Files
DLE/backend/routes/uploads.js
2026-03-01 22:03:48 +03:00

915 lines
36 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;