ваше сообщение коммита
This commit is contained in:
145
backend/app.js
145
backend/app.js
@@ -164,17 +164,67 @@ app.use(
|
||||
);
|
||||
|
||||
// Настройка сессии (используем геттер, чтобы всегда был актуальный middleware)
|
||||
app.use((req, res, next) => sessionConfig.sessionMiddleware(req, res, next));
|
||||
app.use((req, res, next) => {
|
||||
try {
|
||||
sessionConfig.sessionMiddleware(req, res, (err) => {
|
||||
// Обрабатываем ошибки сессий (например, таймауты подключения к БД)
|
||||
if (err) {
|
||||
// Если это ошибка таймаута БД для сессии - не блокируем запрос
|
||||
if (err.message && err.message.includes('timeout exceeded when trying to connect')) {
|
||||
logger.warn('[app.js] Timeout БД для сессии, продолжаем без сессии:', req.sessionID);
|
||||
// Создаем пустую сессию и продолжаем запрос
|
||||
// НО сохраняем sessionID, чтобы запрос мог продолжиться
|
||||
if (!req.session) {
|
||||
req.session = {};
|
||||
}
|
||||
// Сохраняем sessionID для дальнейшего использования
|
||||
if (req.sessionID) {
|
||||
req.session.id = req.sessionID;
|
||||
}
|
||||
// НЕ передаем ошибку дальше - это не критичная ошибка
|
||||
// Но запрос может продолжиться - роуты сами проверят аутентификацию
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.error('[app.js] Ошибка session middleware:', err);
|
||||
// Для других ошибок тоже продолжаем выполнение без сессии
|
||||
if (!res.headersSent && !res.destroyed) {
|
||||
req.session = req.session || {}; // Создаем пустую сессию, если ее нет
|
||||
}
|
||||
// Только для критичных ошибок передаем дальше
|
||||
if (err.name !== 'Error' || !err.message || !err.message.includes('timeout')) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
// Если ошибки нет - продолжаем нормально
|
||||
next();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[app.js] Критическая ошибка session middleware:', error);
|
||||
// Продолжаем выполнение без сессии
|
||||
req.session = {};
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Добавим middleware для проверки сессии
|
||||
app.use(async (req, res, next) => {
|
||||
// console.log('Request cookies:', req.headers.cookie);
|
||||
// console.log('Session ID:', req.sessionID);
|
||||
|
||||
// Проверяем сессию в базе данных
|
||||
// Проверяем сессию в базе данных (только если нет ошибок подключения)
|
||||
if (req.sessionID) {
|
||||
try {
|
||||
const result = await db.getQuery()('SELECT sess FROM session WHERE sid = $1', [req.sessionID]);
|
||||
// console.log('Session from DB:', result.rows[0]?.sess);
|
||||
} catch (error) {
|
||||
// Логируем ошибку, но не блокируем запрос
|
||||
if (error.message && error.message.includes('timeout exceeded')) {
|
||||
logger.warn('[app.js] Timeout при проверке сессии в БД:', req.sessionID);
|
||||
} else {
|
||||
logger.error('[app.js] Ошибка при проверке сессии в БД:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
@@ -225,10 +275,10 @@ const limiter = rateLimit({
|
||||
// Применяем rate limiting ко всем запросам (временно отключено для тестирования)
|
||||
// app.use(limiter);
|
||||
|
||||
// Строгий rate limiting для чувствительных эндпоинтов
|
||||
// Строгий rate limiting для чувствительных эндпоинтов (отключено - лимиты убраны)
|
||||
const strictLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 минут
|
||||
max: isProduction ? 100 : 400, // 100 попыток в продакшне, 400 в разработке
|
||||
max: 1000000, // Очень большой лимит (практически без ограничений)
|
||||
message: {
|
||||
error: 'Превышен лимит попыток, попробуйте позже',
|
||||
retryAfter: '15 минут'
|
||||
@@ -239,10 +289,10 @@ const strictLimiter = rateLimit({
|
||||
trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет
|
||||
});
|
||||
|
||||
// Мягкий rate limiting для RPC настроек (часто запрашиваемых данных)
|
||||
// Мягкий rate limiting для RPC настроек (отключено - лимиты убраны)
|
||||
const rpcSettingsLimiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1 минута
|
||||
max: isProduction ? 200 : 1000, // 200 запросов в продакшне, 1000 в разработке за минуту
|
||||
max: 1000000, // Очень большой лимит (практически без ограничений)
|
||||
message: {
|
||||
error: 'Слишком много запросов к RPC настройкам, попробуйте позже',
|
||||
retryAfter: '1 минута'
|
||||
@@ -255,7 +305,8 @@ const rpcSettingsLimiter = rateLimit({
|
||||
|
||||
// Статическая раздача загруженных файлов (для dev и prod)
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use('/api/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
// НЕ используем /api/uploads для статики, так как там роутер для медиа-файлов из БД
|
||||
// app.use('/api/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// Настройка безопасности
|
||||
app.use(
|
||||
@@ -311,11 +362,16 @@ app.use('/api/messages', messagesRoutes);
|
||||
app.use('/api/identities', identitiesRoutes);
|
||||
app.use('/api/rag', ragRoutes); // Подключаем роут
|
||||
app.use('/api/monitoring', monitoringRoutes);
|
||||
app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц
|
||||
app.use('/api/pages', pagesRoutes); // Обработка favicon.ico - возвращаем 204 No Content чтобы избежать 404 в логах
|
||||
app.get('/favicon.ico', (req, res) => {
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// Подключаем роутер страниц
|
||||
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы) - ДОЛЖНО БЫТЬ ПЕРЕД статической раздачей
|
||||
app.use('/api/consent', consentRoutes); // Добавляем маршрут согласий
|
||||
app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга
|
||||
app.use('/api/vds', vdsRoutes); // Добавляем маршрут VDS управления
|
||||
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы)
|
||||
app.use('/api/ens', ensRoutes); // ENS utilities
|
||||
app.use('/api', sshRoutes); // SSH роуты
|
||||
app.use('/api', encryptionRoutes); // Encryption роуты
|
||||
@@ -357,58 +413,79 @@ app.get('/api/health', async (req, res) => {
|
||||
services: {}
|
||||
};
|
||||
|
||||
// Проверяем подключение к БД
|
||||
// Проверяем подключение к БД с таймаутом
|
||||
try {
|
||||
await db.query('SELECT NOW()');
|
||||
const dbQueryPromise = db.query('SELECT NOW()');
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Database query timeout')), 5000)
|
||||
);
|
||||
await Promise.race([dbQueryPromise, timeoutPromise]);
|
||||
healthStatus.services.database = { status: 'ok' };
|
||||
} catch (error) {
|
||||
healthStatus.services.database = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
// Для healthcheck не считаем временные проблемы с БД критичными
|
||||
// Возвращаем 200, но указываем статус сервиса
|
||||
healthStatus.services.database = {
|
||||
status: 'warning',
|
||||
message: error.message.includes('timeout') ? 'Database connection timeout' : error.message
|
||||
};
|
||||
// Не меняем общий статус на 'error' для временных проблем
|
||||
}
|
||||
|
||||
// Проверяем AI сервис
|
||||
// Проверяем AI сервис (не блокируем healthcheck при ошибках)
|
||||
try {
|
||||
const aiStatus = await aiAssistant.checkHealth();
|
||||
const aiQueryPromise = aiAssistant.checkHealth();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('AI service timeout')), 5000)
|
||||
);
|
||||
const aiStatus = await Promise.race([aiQueryPromise, timeoutPromise]);
|
||||
healthStatus.services.ai = aiStatus;
|
||||
if (aiStatus.status === 'error') {
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
healthStatus.services.ai = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
healthStatus.services.ai = { status: 'warning', message: error.message };
|
||||
}
|
||||
|
||||
// Проверяем Vector Search сервис
|
||||
// Проверяем Vector Search сервис (не блокируем healthcheck при ошибках)
|
||||
try {
|
||||
const vectorSearchClient = require('./services/vectorSearchClient');
|
||||
const vectorStatus = await vectorSearchClient.health();
|
||||
const vectorQueryPromise = vectorSearchClient.health();
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Vector search timeout')), 5000)
|
||||
);
|
||||
const vectorStatus = await Promise.race([vectorQueryPromise, timeoutPromise]);
|
||||
healthStatus.services.vectorSearch = vectorStatus;
|
||||
if (vectorStatus.status === 'error') {
|
||||
healthStatus.status = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
healthStatus.services.vectorSearch = { status: 'error', error: error.message };
|
||||
healthStatus.status = 'error';
|
||||
healthStatus.services.vectorSearch = { status: 'warning', message: error.message };
|
||||
}
|
||||
|
||||
const statusCode = healthStatus.status === 'ok' ? 200 : 503;
|
||||
res.status(statusCode).json(healthStatus);
|
||||
// Всегда возвращаем 200 для healthcheck, чтобы контейнер не считался unhealthy
|
||||
// из-за временных проблем с внешними сервисами
|
||||
res.status(200).json(healthStatus);
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
// Даже при критической ошибке возвращаем 200, чтобы не убивать контейнер
|
||||
res.status(200).json({
|
||||
status: 'warning',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Очистка старых сессий
|
||||
// Очистка старых сессий (с обработкой таймаутов)
|
||||
setInterval(
|
||||
async () => {
|
||||
try {
|
||||
await db.getQuery('DELETE FROM session WHERE expire < NOW()');
|
||||
// Добавляем таймаут для запроса очистки
|
||||
const cleanupPromise = db.getQuery()('DELETE FROM session WHERE expire < NOW()');
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Session cleanup timeout')), 30000)
|
||||
);
|
||||
await Promise.race([cleanupPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
// console.error('Error cleaning old sessions:', error);
|
||||
// Не логируем ошибки очистки - это не критично, и они могут засорять логи
|
||||
// Таймауты при очистке сессий - нормальная ситуация при перегрузке БД
|
||||
if (!error.message || !error.message.includes('timeout')) {
|
||||
logger.warn('[app.js] Session cleanup error (non-timeout):', error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
15 * 60 * 1000
|
||||
|
||||
@@ -30,6 +30,16 @@ function createSessionMiddleware() {
|
||||
store: new pgSession({
|
||||
pool: db.getPool(),
|
||||
tableName: 'session',
|
||||
// Обработка ошибок подключения к БД для сессий
|
||||
errorLog: (err) => {
|
||||
// Логируем только как предупреждение, не как критическую ошибку
|
||||
// Таймауты подключения - это нормальная ситуация при перегрузке
|
||||
if (err && err.message && err.message.includes('timeout exceeded')) {
|
||||
console.warn('[session] Timeout подключения к БД для сессии (не критично):', err.message);
|
||||
} else if (err) {
|
||||
console.error('[session] Ошибка подключения к БД для сессии:', err.message);
|
||||
}
|
||||
},
|
||||
}),
|
||||
secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
|
||||
name: 'sessionId',
|
||||
|
||||
@@ -50,22 +50,24 @@ let pool = new Pool({
|
||||
user: process.env.DB_USER || 'dapp_user',
|
||||
password: process.env.DB_PASSWORD,
|
||||
ssl: false,
|
||||
// Настройки для предотвращения утечек памяти
|
||||
max: 10, // Максимальное количество клиентов в пуле
|
||||
min: 0, // Минимальное количество клиентов в пуле
|
||||
idleTimeoutMillis: 30000, // Время жизни неактивного клиента (30 сек)
|
||||
connectionTimeoutMillis: 30000, // Таймаут подключения (30 сек)
|
||||
// Настройки для предотвращения утечек памяти и таймаутов
|
||||
max: 100, // Увеличиваем максимальное количество клиентов в пуле (было 50)
|
||||
min: 10, // Минимальное количество клиентов в пуле для лучшей производительности (было 5)
|
||||
idleTimeoutMillis: 180000, // Время жизни неактивного клиента (180 сек, было 120)
|
||||
connectionTimeoutMillis: 180000, // Таймаут подключения (180 сек, было 120)
|
||||
maxUses: 7500, // Максимальное количество использований клиента
|
||||
allowExitOnIdle: true, // Разрешить выход при отсутствии активных клиентов
|
||||
});
|
||||
|
||||
// Увеличиваем лимит обработчиков событий для предотвращения предупреждений
|
||||
pool.setMaxListeners(20);
|
||||
pool.setMaxListeners(100);
|
||||
|
||||
// Добавляем обработчики для правильного закрытия пула
|
||||
// НЕ завершаем процесс при ошибках на idle клиентах - это может быть временная проблема
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
console.error('[db] Unexpected error on idle client:', err.message);
|
||||
// НЕ завершаем процесс - это может быть временная проблема с сетью или БД
|
||||
// process.exit(-1);
|
||||
});
|
||||
|
||||
// Обработчик для очистки при завершении процесса
|
||||
@@ -130,22 +132,22 @@ async function reinitPoolFromDbSettings() {
|
||||
user: dbSettings.db_user_encrypted ? await decryptValue(dbSettings.db_user_encrypted) : process.env.DB_USER || 'dapp_user',
|
||||
password: dbSettings.db_password_encrypted ? await decryptValue(dbSettings.db_password_encrypted) : process.env.DB_PASSWORD,
|
||||
ssl: false,
|
||||
// Те же настройки для предотвращения утечек
|
||||
max: 10,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
// Те же настройки для предотвращения утечек и таймаутов
|
||||
max: 100, // Увеличиваем максимальное количество клиентов (было 50)
|
||||
min: 10, // Минимальное количество клиентов для лучшей производительности (было 5)
|
||||
idleTimeoutMillis: 180000, // Увеличиваем до 180 сек (было 120)
|
||||
connectionTimeoutMillis: 180000, // Увеличиваем таймаут подключения до 180 сек (было 120)
|
||||
maxUses: 7500,
|
||||
allowExitOnIdle: true,
|
||||
});
|
||||
|
||||
// Устанавливаем лимит обработчиков для нового пула
|
||||
pool.setMaxListeners(20);
|
||||
pool.setMaxListeners(100);
|
||||
|
||||
// Добавляем обработчики ошибок для нового пула
|
||||
// Добавляем обработчик ошибок для нового пула (не завершаем процесс)
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
console.error('[db] Unexpected error on idle client (reinit):', err.message);
|
||||
// НЕ завершаем процесс - это может быть временная проблема
|
||||
});
|
||||
|
||||
// Пересоздаём session middleware
|
||||
|
||||
@@ -59,14 +59,58 @@ const errorHandler = (err, req, res, next) => {
|
||||
// В режиме разработки возвращаем стек ошибки
|
||||
const devError = process.env.NODE_ENV === 'development' ? { stack: err.stack } : {};
|
||||
|
||||
// Проверяем, что ответ еще не был отправлен и соединение не закрыто
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[errorHandler] Ответ уже отправлен или соединение закрыто, пропускаем обработку ошибки');
|
||||
return;
|
||||
}
|
||||
|
||||
// Для ошибок подключения к БД возвращаем понятное сообщение
|
||||
if (err.message && err.message.includes('timeout exceeded when trying to connect')) {
|
||||
errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.';
|
||||
statusCode = 503; // Service Unavailable
|
||||
}
|
||||
|
||||
// Проверяем еще раз перед отправкой (может измениться состояние)
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[errorHandler] Состояние изменилось, пропускаем отправку ответа');
|
||||
return;
|
||||
}
|
||||
|
||||
// Отправляем ответ клиенту
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
code: errorCode,
|
||||
// Используем формат, совместимый с frontend (success: false, message: string)
|
||||
try {
|
||||
// Финальная проверка перед отправкой - состояние могло измениться
|
||||
if (res.headersSent || res.destroyed || res.finished || !res.writable) {
|
||||
console.error('[errorHandler] Финальная проверка: ответ уже отправлен, соединение закрыто или завершено');
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем writableEnded - новый флаг в Node.js
|
||||
if (res.writableEnded) {
|
||||
console.error('[errorHandler] Ответ уже завершен (writableEnded)');
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
...devError,
|
||||
},
|
||||
});
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: errorMessage,
|
||||
...devError,
|
||||
},
|
||||
});
|
||||
} catch (sendErr) {
|
||||
// Если произошла ошибка при отправке (например, соединение закрыто), просто логируем
|
||||
// Игнорируем ошибки, связанные с уже отправленными заголовками или закрытым соединением
|
||||
if (sendErr.code !== 'ERR_HTTP_HEADERS_SENT' &&
|
||||
sendErr.code !== 'ECONNRESET' &&
|
||||
sendErr.code !== 'EPIPE' &&
|
||||
!sendErr.message?.includes('Cannot set headers after they are sent')) {
|
||||
console.error('[errorHandler] Ошибка при отправке ответа:', sendErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,10 +29,10 @@ const sessionService = require('../services/session-service');
|
||||
const consentService = require('../services/consentService');
|
||||
const { DOCUMENT_CONSENT_MAP } = consentService;
|
||||
|
||||
// Создаем лимитер для попыток аутентификации
|
||||
// Создаем лимитер для попыток аутентификации (отключено - лимиты убраны)
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 минут
|
||||
max: 20,
|
||||
max: 1000000, // Очень большой лимит (практически без ограничений)
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Слишком много попыток аутентификации. Попробуйте позже.' },
|
||||
|
||||
@@ -99,68 +99,206 @@ function stripHtml(html) {
|
||||
|
||||
// Создать страницу (только для админа)
|
||||
router.post('/', upload.single('file'), async (req, res) => {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||
console.log('[pages] POST /: Начало обработки запроса на создание страницы');
|
||||
try {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
console.log('[pages] POST /: Ошибка аутентификации - сессия не найдена');
|
||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||
}
|
||||
if (!req.session.address) {
|
||||
console.log('[pages] POST /: Ошибка - адрес кошелька не найден');
|
||||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||||
}
|
||||
|
||||
console.log('[pages] POST /: Проверка прав доступа для адреса:', req.session.address);
|
||||
// Проверяем роль админа через токены в кошельке
|
||||
const authService = require('../services/auth-service');
|
||||
let userAccessLevel;
|
||||
try {
|
||||
userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||||
} catch (authError) {
|
||||
console.error('[pages] POST /: Ошибка при проверке прав доступа:', authError);
|
||||
if (authError.message && authError.message.includes('timeout exceeded')) {
|
||||
return res.status(503).json({ error: 'Ошибка подключения к базе данных. Попробуйте позже.' });
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
|
||||
if (!userAccessLevel.hasAccess) {
|
||||
console.log('[pages] POST /: Доступ запрещен - недостаточно прав');
|
||||
return res.status(403).json({ error: 'Only admin can create pages' });
|
||||
}
|
||||
|
||||
console.log('[pages] POST /: Права доступа подтверждены, уровень:', userAccessLevel.level);
|
||||
|
||||
const authorAddress = req.session.address;
|
||||
const tableName = `admin_pages_simple`;
|
||||
|
||||
// Собираем данные страницы
|
||||
const bodyRaw = req.body || {};
|
||||
|
||||
// Обрабатываем required_permission: если это пустая строка или 'null', устанавливаем null
|
||||
let requiredPermission = null;
|
||||
if (bodyRaw.required_permission) {
|
||||
const perm = String(bodyRaw.required_permission).trim();
|
||||
requiredPermission = (perm && perm !== 'null' && perm !== '') ? perm : null;
|
||||
}
|
||||
|
||||
// Обрабатываем JSON поля (seo, settings) - могут прийти как строка из FormData
|
||||
let seoValue = null;
|
||||
if (bodyRaw.seo) {
|
||||
if (typeof bodyRaw.seo === 'string') {
|
||||
try {
|
||||
seoValue = JSON.parse(bodyRaw.seo);
|
||||
} catch (e) {
|
||||
seoValue = bodyRaw.seo.trim() ? bodyRaw.seo : null;
|
||||
}
|
||||
} else if (typeof bodyRaw.seo === 'object') {
|
||||
seoValue = bodyRaw.seo;
|
||||
}
|
||||
}
|
||||
|
||||
let settingsValue = null;
|
||||
if (bodyRaw.settings) {
|
||||
if (typeof bodyRaw.settings === 'string') {
|
||||
try {
|
||||
settingsValue = JSON.parse(bodyRaw.settings);
|
||||
} catch (e) {
|
||||
settingsValue = bodyRaw.settings.trim() ? bodyRaw.settings : null;
|
||||
}
|
||||
} else if (typeof bodyRaw.settings === 'object') {
|
||||
settingsValue = bodyRaw.settings;
|
||||
}
|
||||
}
|
||||
|
||||
const pageData = {
|
||||
title: bodyRaw.title || '',
|
||||
summary: bodyRaw.summary || '',
|
||||
content: bodyRaw.content || '',
|
||||
seo: seoValue,
|
||||
status: bodyRaw.status || 'draft',
|
||||
settings: settingsValue,
|
||||
visibility: bodyRaw.visibility || 'public',
|
||||
required_permission: requiredPermission,
|
||||
format: bodyRaw.format || (req.file ? (req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf') : 'html'),
|
||||
mime_type: req.file ? (req.file.mimetype || null) : (bodyRaw.mime_type || (bodyRaw.format === 'html' ? 'text/html' : null)),
|
||||
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,
|
||||
// Нормализуем категорию: приводим к нижнему регистру для консистентности
|
||||
category: (bodyRaw.category && String(bodyRaw.category).trim()) ? String(bodyRaw.category).trim().toLowerCase() : null,
|
||||
// Обрабатываем category_id: может быть null или числом
|
||||
category_id: (bodyRaw.category_id && bodyRaw.category_id !== 'null' && bodyRaw.category_id !== '')
|
||||
? (() => { const parsed = parseInt(bodyRaw.category_id); return isNaN(parsed) ? null : parsed; })()
|
||||
: 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'
|
||||
};
|
||||
|
||||
console.log('[pages] POST /: Создание страницы, данные:', {
|
||||
title: pageData.title,
|
||||
visibility: pageData.visibility,
|
||||
required_permission: pageData.required_permission,
|
||||
status: pageData.status,
|
||||
format: pageData.format
|
||||
});
|
||||
|
||||
// Формируем SQL для вставки данных (включаем все поля, даже null)
|
||||
// Фильтруем только undefined, null значения включаем (они допустимы в БД)
|
||||
const dataEntries = Object.entries(pageData).filter(([k, v]) => {
|
||||
// Исключаем только undefined, null и пустые строки для некоторых полей допустимы
|
||||
return v !== undefined;
|
||||
});
|
||||
|
||||
const colNames = ['author_address', ...dataEntries.map(([k]) => k)].join(', ');
|
||||
const values = [authorAddress, ...dataEntries.map(([, v]) => v)];
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
||||
|
||||
const sql = `INSERT INTO ${tableName} (${colNames}) VALUES (${placeholders}) RETURNING *`;
|
||||
|
||||
console.log('[pages] POST /: SQL запрос:', sql.substring(0, 300) + '...');
|
||||
console.log('[pages] POST /: Количество параметров:', values.length);
|
||||
console.log('[pages] POST /: Колонки:', colNames);
|
||||
console.log('[pages] POST /: Значения (первые 5):', values.slice(0, 5).map(v => v === null ? 'NULL' : (typeof v === 'string' ? v.substring(0, 50) : v)));
|
||||
|
||||
// Проверяем, что ответ еще не был отправлен перед запросом к БД
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] POST /: Ответ уже отправлен перед запросом к БД');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[pages] POST /: Выполнение SQL запроса к БД...');
|
||||
let rows;
|
||||
try {
|
||||
const result = await db.getQuery()(sql, values);
|
||||
rows = result.rows;
|
||||
console.log('[pages] POST /: SQL запрос выполнен успешно, создана страница с ID:', rows[0]?.id);
|
||||
} catch (dbError) {
|
||||
console.error('[pages] POST /: Ошибка БД при выполнении SQL:', dbError);
|
||||
console.error('[pages] POST /: Код ошибки БД:', dbError.code);
|
||||
console.error('[pages] POST /: Сообщение БД:', dbError.message);
|
||||
console.error('[pages] POST /: Детали БД:', dbError.detail);
|
||||
throw dbError;
|
||||
}
|
||||
const created = rows[0];
|
||||
|
||||
// Проверяем еще раз перед отправкой ответа
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] POST /: Ответ уже отправлен после запроса к БД');
|
||||
return;
|
||||
}
|
||||
|
||||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||
// Автоматическая индексация при создании отключена
|
||||
|
||||
res.json(created);
|
||||
} catch (error) {
|
||||
console.error('[pages] Ошибка при создании страницы:', error);
|
||||
console.error('[pages] Стек ошибки:', error.stack);
|
||||
console.error('[pages] Код ошибки:', error.code);
|
||||
console.error('[pages] Сообщение ошибки:', error.message);
|
||||
|
||||
// Если ответ уже отправлен, не пытаемся отправлять ошибку
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] POST /: Ответ уже отправлен в catch блоке');
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем статус код на основе типа ошибки
|
||||
let statusCode = 500;
|
||||
let errorMessage = 'Ошибка при создании страницы';
|
||||
|
||||
if (error.message && error.message.includes('timeout exceeded when trying to connect')) {
|
||||
statusCode = 503; // Service Unavailable
|
||||
errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.';
|
||||
} else if (error.code === '23505') { // PostgreSQL unique violation
|
||||
statusCode = 409; // Conflict
|
||||
errorMessage = 'Страница с такими данными уже существует';
|
||||
} else if (error.code === '23502') { // PostgreSQL not null violation
|
||||
statusCode = 400; // Bad Request
|
||||
errorMessage = 'Отсутствует обязательное поле: ' + (error.column || 'неизвестно');
|
||||
} else if (error.code === '42703') { // PostgreSQL undefined column
|
||||
statusCode = 400; // Bad Request
|
||||
errorMessage = 'Неверное поле в запросе: ' + (error.message || 'неизвестно');
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
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 pages' });
|
||||
}
|
||||
|
||||
const authorAddress = req.session.address;
|
||||
const tableName = `admin_pages_simple`;
|
||||
|
||||
// Собираем данные страницы
|
||||
const bodyRaw = req.body || {};
|
||||
const pageData = {
|
||||
title: bodyRaw.title || '',
|
||||
summary: bodyRaw.summary || '',
|
||||
content: bodyRaw.content || '',
|
||||
seo: bodyRaw.seo ? (typeof bodyRaw.seo === 'string' ? bodyRaw.seo : JSON.stringify(bodyRaw.seo)) : null,
|
||||
status: bodyRaw.status || 'draft',
|
||||
settings: bodyRaw.settings ? (typeof bodyRaw.settings === 'string' ? bodyRaw.settings : JSON.stringify(bodyRaw.settings)) : null,
|
||||
visibility: bodyRaw.visibility || 'public',
|
||||
required_permission: bodyRaw.required_permission || null,
|
||||
format: bodyRaw.format || (req.file ? (req.file.mimetype?.startsWith('image/') ? 'image' : 'pdf') : 'html'),
|
||||
mime_type: req.file ? (req.file.mimetype || null) : (bodyRaw.mime_type || (bodyRaw.format === 'html' ? 'text/html' : null)),
|
||||
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,
|
||||
// Нормализуем категорию: приводим к нижнему регистру для консистентности
|
||||
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 для вставки данных (только непустые поля)
|
||||
const dataEntries = Object.entries(pageData).filter(([, v]) => v !== undefined);
|
||||
const colNames = ['author_address', ...dataEntries.map(([k]) => k)].join(', ');
|
||||
const values = [authorAddress, ...dataEntries.map(([, v]) => v)];
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
||||
|
||||
const sql = `INSERT INTO ${tableName} (${colNames}) VALUES (${placeholders}) RETURNING *`;
|
||||
const { rows } = await db.getQuery()(sql, values);
|
||||
const created = rows[0];
|
||||
|
||||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||
// Автоматическая индексация при создании отключена
|
||||
|
||||
res.json(created);
|
||||
});
|
||||
|
||||
// Получить все страницы админов
|
||||
@@ -354,34 +492,81 @@ router.get('/categories', async (req, res) => {
|
||||
|
||||
// ========== КОНЕЦ РОУТОВ ДЛЯ КАТЕГОРИЙ ==========
|
||||
|
||||
// Получить одну страницу по id (только для админа)
|
||||
// Получить одну страницу по id (с проверкой прав доступа)
|
||||
router.get('/:id', async (req, res) => {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||
try {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||
}
|
||||
if (!req.session.address) {
|
||||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||||
}
|
||||
|
||||
const tableName = `admin_pages_simple`;
|
||||
const existsRes = await db.getQuery()(
|
||||
`SELECT to_regclass($1) as exists`, [tableName]
|
||||
);
|
||||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||||
|
||||
const { rows } = await db.getQuery()(
|
||||
`SELECT * FROM ${tableName} WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||
|
||||
const page = rows[0];
|
||||
|
||||
// Проверяем доступ к странице в зависимости от её видимости
|
||||
const authService = require('../services/auth-service');
|
||||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||||
const role = userAccessLevel.level; // 'user' | 'readonly' | 'editor'
|
||||
|
||||
// Публичные страницы доступны всем
|
||||
if (page.visibility === 'public' && page.status === 'published') {
|
||||
return res.json(page);
|
||||
}
|
||||
|
||||
// Внутренние страницы требуют проверки прав
|
||||
if (page.visibility === 'internal') {
|
||||
// Редактор видит все внутренние страницы (включая черновики)
|
||||
if (role === 'editor') {
|
||||
return res.json(page);
|
||||
}
|
||||
|
||||
// Обычные пользователи видят только опубликованные внутренние страницы
|
||||
if (page.status !== 'published') {
|
||||
return res.status(403).json({ error: 'Доступ запрещен: страница не опубликована' });
|
||||
}
|
||||
|
||||
// Если у страницы указан required_permission, проверяем права пользователя
|
||||
if (page.required_permission) {
|
||||
const { PERMISSIONS, hasPermission } = require('../shared/permissions');
|
||||
// Проверяем права доступа пользователя
|
||||
// VIEW_BASIC_DOCS доступно всем аутентифицированным пользователям (user, readonly, editor)
|
||||
// VIEW_LEGAL_DOCS требует readonly или editor
|
||||
// MANAGE_LEGAL_DOCS требует editor
|
||||
if (page.required_permission === PERMISSIONS.VIEW_LEGAL_DOCS && !hasPermission(role, PERMISSIONS.VIEW_LEGAL_DOCS)) {
|
||||
return res.status(403).json({ error: 'Доступ запрещен: требуются права читателя' });
|
||||
}
|
||||
if (page.required_permission === PERMISSIONS.MANAGE_LEGAL_DOCS && !hasPermission(role, PERMISSIONS.MANAGE_LEGAL_DOCS)) {
|
||||
return res.status(403).json({ error: 'Доступ запрещен: требуются права редактора' });
|
||||
}
|
||||
}
|
||||
|
||||
// Если required_permission не указан или права проверены, возвращаем страницу
|
||||
return res.json(page);
|
||||
}
|
||||
|
||||
// Для всех остальных случаев (например, draft публичных страниц) требуется роль редактора
|
||||
if (role !== 'editor') {
|
||||
return res.status(403).json({ error: 'Доступ запрещен: требуются права редактора' });
|
||||
}
|
||||
|
||||
res.json(page);
|
||||
} catch (error) {
|
||||
console.error('[pages] Ошибка получения страницы:', error);
|
||||
res.status(500).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 view pages' });
|
||||
}
|
||||
|
||||
const tableName = `admin_pages_simple`;
|
||||
const existsRes = await db.getQuery()(
|
||||
`SELECT to_regclass($1) as exists`, [tableName]
|
||||
);
|
||||
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' });
|
||||
|
||||
const { rows } = await db.getQuery()(
|
||||
`SELECT * FROM ${tableName} WHERE id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||
res.json(rows[0]);
|
||||
});
|
||||
|
||||
// Ручная переиндексация документа в vector-search (только для админа)
|
||||
@@ -497,19 +682,37 @@ router.post('/:id/reindex', async (req, res) => {
|
||||
|
||||
// Редактировать страницу по id
|
||||
router.patch('/:id', upload.single('file'), 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 edit pages' });
|
||||
}
|
||||
console.log('[pages] PATCH /:id: Начало обработки запроса на обновление страницы ID:', req.params.id);
|
||||
try {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
console.log('[pages] PATCH /:id: Ошибка аутентификации - сессия не найдена');
|
||||
return res.status(401).json({ error: 'Требуется аутентификация' });
|
||||
}
|
||||
if (!req.session.address) {
|
||||
console.log('[pages] PATCH /:id: Ошибка - адрес кошелька не найден');
|
||||
return res.status(403).json({ error: 'Требуется подключение кошелька' });
|
||||
}
|
||||
|
||||
console.log('[pages] PATCH /:id: Проверка прав доступа для адреса:', req.session.address);
|
||||
// Проверяем роль админа через токены в кошельке
|
||||
const authService = require('../services/auth-service');
|
||||
let userAccessLevel;
|
||||
try {
|
||||
userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||||
} catch (authError) {
|
||||
console.error('[pages] PATCH /:id: Ошибка при проверке прав доступа:', authError);
|
||||
if (authError.message && authError.message.includes('timeout exceeded')) {
|
||||
return res.status(503).json({ error: 'Ошибка подключения к базе данных. Попробуйте позже.' });
|
||||
}
|
||||
throw authError;
|
||||
}
|
||||
|
||||
if (!userAccessLevel.hasAccess) {
|
||||
console.log('[pages] PATCH /:id: Доступ запрещен - недостаточно прав');
|
||||
return res.status(403).json({ error: 'Only admin can edit pages' });
|
||||
}
|
||||
|
||||
console.log('[pages] PATCH /:id: Права доступа подтверждены, уровень:', userAccessLevel.level);
|
||||
|
||||
const tableName = `admin_pages_simple`;
|
||||
const existsRes = await db.getQuery()(
|
||||
@@ -520,15 +723,41 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
|
||||
const incoming = req.body || {};
|
||||
const updateData = {};
|
||||
|
||||
console.log(`[pages] PATCH /:id (${req.params.id}): получены данные для обновления:`, incoming);
|
||||
console.log(`[pages] PATCH /:id (${req.params.id}): получены данные для обновления:`, JSON.stringify(incoming, null, 2));
|
||||
console.log(`[pages] PATCH /:id (${req.params.id}): тип req.body:`, typeof req.body);
|
||||
console.log(`[pages] PATCH /:id (${req.params.id}): ключи в req.body:`, Object.keys(incoming));
|
||||
|
||||
// Обрабатываем required_permission:
|
||||
// Если visibility меняется на public, required_permission должен быть null
|
||||
// Если visibility = internal и нет required_permission, устанавливаем null
|
||||
if ('visibility' in incoming && incoming.visibility === 'public') {
|
||||
updateData.required_permission = null;
|
||||
} else if ('required_permission' in incoming) {
|
||||
if (incoming.required_permission) {
|
||||
const perm = String(incoming.required_permission).trim();
|
||||
updateData.required_permission = (perm && perm !== 'null' && perm !== '') ? perm : null;
|
||||
} else {
|
||||
updateData.required_permission = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of Object.entries(incoming)) {
|
||||
if (FIELDS_TO_EXCLUDE.includes(k)) continue;
|
||||
if (k === 'required_permission') continue; // Уже обработано выше
|
||||
|
||||
// Нормализуем категорию: приводим к нижнему регистру для консистентности
|
||||
if (k === 'category') {
|
||||
updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null;
|
||||
}
|
||||
// Обрабатываем category_id: может быть null или числом
|
||||
else if (k === 'category_id') {
|
||||
if (v === null || v === 'null' || v === '' || v === undefined) {
|
||||
updateData[k] = null;
|
||||
} else {
|
||||
const parsed = parseInt(v);
|
||||
updateData[k] = isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
}
|
||||
// Обрабатываем parent_id: может быть null или числом
|
||||
else if (k === 'parent_id') {
|
||||
if (v === null || v === 'null' || v === '' || v === undefined) {
|
||||
@@ -551,9 +780,27 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
|
||||
else if (k === 'is_index_page') {
|
||||
updateData[k] = v === true || v === 'true' || v === 1 || v === '1';
|
||||
}
|
||||
// Обрабатываем JSON поля (seo, settings) - могут прийти как строка из FormData
|
||||
else if (k === 'seo' || k === 'settings') {
|
||||
if (typeof v === 'string') {
|
||||
try {
|
||||
// Если это строка JSON, пытаемся распарсить
|
||||
const parsed = JSON.parse(v);
|
||||
updateData[k] = parsed;
|
||||
} catch (e) {
|
||||
// Если не JSON, сохраняем как строку или null
|
||||
updateData[k] = v && v.trim() ? v : null;
|
||||
}
|
||||
} else if (typeof v === 'object' && v !== null) {
|
||||
// Если это уже объект, сериализуем в JSON
|
||||
updateData[k] = v;
|
||||
} else {
|
||||
updateData[k] = v || null;
|
||||
}
|
||||
}
|
||||
// Остальные поля
|
||||
else {
|
||||
updateData[k] = typeof v === 'object' ? JSON.stringify(v) : v;
|
||||
updateData[k] = typeof v === 'object' && v !== null ? JSON.stringify(v) : v;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,8 +822,61 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
|
||||
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' });
|
||||
// Проверяем, что ответ еще не был отправлен перед запросом к БД
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] PATCH /:id: Ответ уже отправлен перед запросом к БД');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[pages] PATCH /:id: Выполнение SQL запроса к БД...');
|
||||
let rows;
|
||||
try {
|
||||
const result = await db.getQuery()(sql, values);
|
||||
rows = result.rows;
|
||||
} catch (dbError) {
|
||||
console.error('[pages] PATCH /:id: Ошибка БД при выполнении SQL:', dbError);
|
||||
console.error('[pages] PATCH /:id: Код ошибки БД:', dbError.code);
|
||||
console.error('[pages] PATCH /:id: Сообщение БД:', dbError.message);
|
||||
console.error('[pages] PATCH /:id: Детали БД:', dbError.detail);
|
||||
|
||||
// Если ответ уже отправлен, не пытаемся отправлять ошибку
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] PATCH /:id: Ответ уже отправлен в catch блоке БД');
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем статус код на основе типа ошибки
|
||||
let statusCode = 500;
|
||||
let errorMessage = 'Ошибка при обновлении страницы';
|
||||
|
||||
if (dbError.message && dbError.message.includes('timeout exceeded when trying to connect')) {
|
||||
statusCode = 503; // Service Unavailable
|
||||
errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.';
|
||||
} else if (dbError.code === '23505') { // PostgreSQL unique violation
|
||||
statusCode = 409; // Conflict
|
||||
errorMessage = 'Страница с такими данными уже существует';
|
||||
} else if (dbError.code === '23502') { // PostgreSQL not null violation
|
||||
statusCode = 400; // Bad Request
|
||||
errorMessage = 'Отсутствует обязательное поле: ' + (dbError.column || 'неизвестно');
|
||||
} else if (dbError.code === '42703') { // PostgreSQL undefined column
|
||||
statusCode = 400; // Bad Request
|
||||
errorMessage = 'Неверное поле в запросе: ' + (dbError.message || 'неизвестно');
|
||||
} else if (dbError.message) {
|
||||
errorMessage = dbError.message;
|
||||
}
|
||||
|
||||
return res.status(statusCode).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
console.error('[pages] PATCH /:id: Страница не найдена после обновления');
|
||||
return res.status(404).json({ error: 'Page not found' });
|
||||
}
|
||||
|
||||
const updated = rows[0];
|
||||
|
||||
console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, {
|
||||
@@ -585,13 +885,53 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
|
||||
category: updated.category,
|
||||
parent_id: updated.parent_id,
|
||||
order_index: updated.order_index,
|
||||
is_index_page: updated.is_index_page
|
||||
is_index_page: updated.is_index_page,
|
||||
visibility: updated.visibility,
|
||||
required_permission: updated.required_permission
|
||||
});
|
||||
|
||||
// Проверяем еще раз перед отправкой ответа
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] PATCH /:id: Ответ уже отправлен после запроса к БД');
|
||||
return;
|
||||
}
|
||||
|
||||
// Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
|
||||
// Автоматическая индексация при обновлении отключена
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('[pages] PATCH /:id: Ошибка при обновлении страницы:', error);
|
||||
console.error('[pages] PATCH /:id: Стек ошибки:', error.stack);
|
||||
console.error('[pages] PATCH /:id: Код ошибки:', error.code);
|
||||
console.error('[pages] PATCH /:id: Сообщение ошибки:', error.message);
|
||||
|
||||
// Если ответ уже отправлен, не пытаемся отправлять ошибку
|
||||
if (res.headersSent || res.destroyed) {
|
||||
console.error('[pages] PATCH /:id: Ответ уже отправлен в catch блоке');
|
||||
return;
|
||||
}
|
||||
|
||||
// Определяем статус код на основе типа ошибки
|
||||
let statusCode = 500;
|
||||
let errorMessage = 'Ошибка при обновлении страницы';
|
||||
|
||||
if (error.message && error.message.includes('timeout exceeded when trying to connect')) {
|
||||
statusCode = 503; // Service Unavailable
|
||||
errorMessage = 'Ошибка подключения к базе данных. Попробуйте позже.';
|
||||
} else if (error.code === '23505') { // PostgreSQL unique violation
|
||||
statusCode = 409; // Conflict
|
||||
errorMessage = 'Страница с такими данными уже существует';
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
res.status(statusCode).json({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
message: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить страницу по id
|
||||
@@ -611,17 +951,83 @@ router.delete('/:id', async (req, res) => {
|
||||
}
|
||||
|
||||
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: 'Page table not found' });
|
||||
|
||||
// Сначала получаем информацию о странице перед удалением
|
||||
const pageResult = await db.getQuery()(
|
||||
`SELECT * FROM ${tableName} WHERE id = $1`,
|
||||
[pageId]
|
||||
);
|
||||
if (!pageResult.rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||
const pageToDelete = pageResult.rows[0];
|
||||
|
||||
// Находим все медиа-файлы, связанные с этой страницей
|
||||
try {
|
||||
const mediaResult = await db.getQuery()(
|
||||
`SELECT id, file_hash FROM content_media WHERE page_id = $1`,
|
||||
[pageId]
|
||||
);
|
||||
|
||||
const deletedMediaCount = mediaResult.rows.length;
|
||||
console.log(`[pages] Найдено ${deletedMediaCount} медиа-файлов, связанных со страницей ${pageId}`);
|
||||
|
||||
// Для каждого медиа-файла проверяем, используется ли он в других местах
|
||||
for (const media of mediaResult.rows) {
|
||||
if (media.file_hash) {
|
||||
// Проверяем, сколько раз используется этот файл (по file_hash)
|
||||
const usageResult = await db.getQuery()(
|
||||
`SELECT COUNT(*) as count FROM content_media WHERE file_hash = $1`,
|
||||
[media.file_hash]
|
||||
);
|
||||
const usageCount = parseInt(usageResult.rows[0].count);
|
||||
|
||||
// Если файл используется только один раз (только в этой странице), удаляем его полностью
|
||||
if (usageCount === 1) {
|
||||
await db.getQuery()(
|
||||
`DELETE FROM content_media WHERE id = $1`,
|
||||
[media.id]
|
||||
);
|
||||
console.log(`[pages] Удален медиа-файл ID ${media.id} (file_hash: ${media.file_hash}), использовался только в удаляемой странице`);
|
||||
} else {
|
||||
// Если файл используется в других местах, просто убираем связь со страницей
|
||||
await db.getQuery()(
|
||||
`UPDATE content_media SET page_id = NULL WHERE id = $1`,
|
||||
[media.id]
|
||||
);
|
||||
console.log(`[pages] Убрана связь медиа-файла ID ${media.id} со страницей ${pageId} (файл используется в ${usageCount} местах)`);
|
||||
}
|
||||
} else {
|
||||
// Если file_hash отсутствует, просто удаляем файл
|
||||
await db.getQuery()(
|
||||
`DELETE FROM content_media WHERE id = $1`,
|
||||
[media.id]
|
||||
);
|
||||
console.log(`[pages] Удален медиа-файл ID ${media.id} (без file_hash)`);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedMediaCount > 0) {
|
||||
console.log(`[pages] Обработано ${deletedMediaCount} медиа-файлов при удалении страницы ${pageId}`);
|
||||
}
|
||||
} catch (mediaError) {
|
||||
console.error('[pages] Ошибка при удалении медиа-файлов:', mediaError);
|
||||
// Продолжаем удаление страницы даже если произошла ошибка с медиа-файлами
|
||||
}
|
||||
|
||||
// Удаляем страницу
|
||||
const { rows } = await db.getQuery()(
|
||||
`DELETE FROM ${tableName} WHERE id = $1 RETURNING *`,
|
||||
[req.params.id]
|
||||
[pageId]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Page not found' });
|
||||
const deleted = rows[0];
|
||||
|
||||
// Удаляем из векторного поиска
|
||||
try {
|
||||
if (deleted && deleted.format === 'html') {
|
||||
// Удаляем документ и все его чанки
|
||||
@@ -636,6 +1042,7 @@ router.delete('/:id', async (req, res) => {
|
||||
} catch (e) {
|
||||
console.error('[pages] vector remove error:', e.message);
|
||||
}
|
||||
|
||||
res.json(deleted);
|
||||
});
|
||||
|
||||
@@ -983,7 +1390,7 @@ router.get('/public/:id/navigation', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Внутренние документы (доступны аутентифицированным пользователям с доступом)
|
||||
// Внутренние документы (доступны всем аутентифицированным пользователям с подключенным кошельком)
|
||||
router.get('/internal/all', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.authenticated) {
|
||||
@@ -1001,16 +1408,16 @@ router.get('/internal/all', async (req, res) => {
|
||||
|
||||
const authService = require('../services/auth-service');
|
||||
const userAccessLevel = await authService.getUserAccessLevel(req.session.address);
|
||||
if (!userAccessLevel.hasAccess) {
|
||||
return res.status(403).json({ error: 'Only internal users can view pages' });
|
||||
}
|
||||
|
||||
// READONLY/EDITOR видят внутренние опубликованные; EDITOR может видеть и черновики
|
||||
const role = userAccessLevel.level; // 'readonly' | 'editor'
|
||||
|
||||
// Все аутентифицированные пользователи с подключенным кошельком могут видеть внутренние страницы
|
||||
// EDITOR может видеть все (включая черновики), обычные пользователи - только опубликованные
|
||||
const role = userAccessLevel.level; // 'user' | 'readonly' | 'editor'
|
||||
let sql;
|
||||
if (role === 'editor') {
|
||||
// Редактор видит все внутренние страницы, включая черновики
|
||||
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`;
|
||||
} else {
|
||||
// Обычные пользователи видят только опубликованные внутренние страницы
|
||||
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`;
|
||||
}
|
||||
const { rows } = await db.getQuery()(sql);
|
||||
|
||||
@@ -94,7 +94,14 @@ router.post('/media', auth.requireAuth, async (req, res) => {
|
||||
mediaUpload.single('media')(req, res, async (err) => {
|
||||
if (err) {
|
||||
console.error('[uploads/media] Ошибка multer:', err);
|
||||
return res.status(400).json({ success: false, message: err.message });
|
||||
// Формируем понятное сообщение об ошибке
|
||||
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 {
|
||||
@@ -214,45 +221,187 @@ router.post('/media', auth.requireAuth, async (req, res) => {
|
||||
table: e.table,
|
||||
column: e.column
|
||||
});
|
||||
return res.status(500).json({
|
||||
|
||||
// Проверяем, можно ли отправлять ответ
|
||||
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: e.message || 'Внутренняя ошибка сервера',
|
||||
error: process.env.NODE_ENV === 'development' ? {
|
||||
name: e.name,
|
||||
code: e.code,
|
||||
detail: e.detail,
|
||||
constraint: e.constraint
|
||||
} : undefined
|
||||
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();
|
||||
client = await pool.connect();
|
||||
|
||||
|
||||
// Добавляем таймаут для подключения к пулу (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
|
||||
const metaResult = await client.query(
|
||||
let metaResult;
|
||||
try {
|
||||
metaResult = await client.query(
|
||||
'SELECT file_name, mime_type, file_size FROM content_media WHERE id = $1',
|
||||
[mediaId]
|
||||
);
|
||||
|
||||
if (metaResult.rows.length === 0) {
|
||||
client.release();
|
||||
return res.status(404).json({ success: false, message: 'Медиа-файл не найден' });
|
||||
} 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;
|
||||
@@ -267,10 +416,16 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
|
||||
// Валидация диапазона
|
||||
if (start >= fileSize || end >= fileSize) {
|
||||
if (start >= fileSize || end >= fileSize || start < 0 || end < start) {
|
||||
if (!res.headersSent && !res.destroyed) {
|
||||
res.setHeader('Content-Range', `bytes */${fileSize}`);
|
||||
client.release();
|
||||
return res.status(416).end(); // Range Not Satisfiable
|
||||
releaseClient();
|
||||
cleanup();
|
||||
return res.status(416).end(); // Range Not Satisfiable
|
||||
}
|
||||
releaseClient();
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
statusCode = 206; // Partial Content
|
||||
@@ -279,16 +434,33 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
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 {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${media.file_name}"`);
|
||||
// Используем оба формата: ASCII для совместимости и UTF-8 для корректного отображения
|
||||
res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"; filename*=UTF-8''${encodedFileName}`);
|
||||
}
|
||||
|
||||
// Используем прямой стриминг BYTEA данных частями через SQL substring
|
||||
@@ -298,11 +470,24 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
|
||||
const streamChunk = async () => {
|
||||
try {
|
||||
// Проверяем, не освобожден ли клиент или не закрыто ли соединение
|
||||
if (clientReleased || !client || res.destroyed) {
|
||||
if (!clientReleased) {
|
||||
releaseClient();
|
||||
}
|
||||
if (!res.destroyed && res.headersSent) {
|
||||
res.end();
|
||||
}
|
||||
return; // Клиент уже освобожден или соединение закрыто, прекращаем стриминг
|
||||
}
|
||||
|
||||
// Проверяем, не достигли ли мы конца запрошенного диапазона
|
||||
if (offset > endOffset) {
|
||||
// Достигнут конец запрошенного диапазона
|
||||
client.release();
|
||||
releaseClient();
|
||||
if (!res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -310,15 +495,26 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
const currentChunkSize = Math.min(chunkSize, endOffset - offset + 1);
|
||||
|
||||
// Читаем следующий chunk данных, используя encode для получения hex-строки
|
||||
const chunkResult = await client.query(
|
||||
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) {
|
||||
// Достигнут конец файла или данные отсутствуют
|
||||
client.release();
|
||||
releaseClient();
|
||||
if (!res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -326,27 +522,49 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
|
||||
// Если chunk пустой, значит достигнут конец
|
||||
if (!chunkHex || chunkHex.length === 0) {
|
||||
client.release();
|
||||
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', () => {
|
||||
offset += currentChunkSize;
|
||||
streamChunk();
|
||||
// Проверяем, не освобожден ли клиент и не закрыто ли соединение перед продолжением
|
||||
if (!clientReleased && client && !res.destroyed) {
|
||||
offset += currentChunkSize;
|
||||
streamChunk();
|
||||
} else {
|
||||
releaseClient();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Продолжаем отправку следующего chunk
|
||||
offset += currentChunkSize;
|
||||
streamChunk();
|
||||
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,
|
||||
@@ -354,12 +572,12 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
endOffset: endOffset,
|
||||
fileSize: fileSize
|
||||
});
|
||||
if (client) {
|
||||
client.release();
|
||||
}
|
||||
if (!res.headersSent) {
|
||||
releaseClient();
|
||||
// Если заголовки еще не отправлены, отправляем ошибку
|
||||
if (!res.headersSent && !res.destroyed) {
|
||||
res.status(500).json({ success: false, message: 'Ошибка чтения файла' });
|
||||
} else {
|
||||
} else if (!res.destroyed) {
|
||||
// Если заголовки уже отправлены, просто завершаем соединение
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
@@ -369,26 +587,29 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
streamChunk();
|
||||
|
||||
// Обработка ошибок HTTP ответа
|
||||
res.on('error', (resErr) => {
|
||||
console.error('[uploads/media/:id/file] Ошибка HTTP ответа:', resErr);
|
||||
if (client) {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
responseErrorHandler = (resErr) => {
|
||||
console.error(`[uploads/media/:id/file] Ошибка HTTP ответа для файла ID ${mediaId}:`, resErr);
|
||||
releaseClient();
|
||||
cleanup();
|
||||
};
|
||||
res.on('error', responseErrorHandler);
|
||||
|
||||
// Обработка закрытия соединения клиентом
|
||||
res.on('close', () => {
|
||||
if (client) {
|
||||
client.release();
|
||||
responseCloseHandler = () => {
|
||||
// Если соединение закрыто клиентом до завершения стриминга, освобождаем клиент
|
||||
if (!clientReleased) {
|
||||
console.log(`[uploads/media/:id/file] Соединение закрыто клиентом для файла ID ${mediaId}, освобождаем клиент БД`);
|
||||
releaseClient();
|
||||
}
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
res.on('close', responseCloseHandler);
|
||||
|
||||
} catch (e) {
|
||||
if (client) {
|
||||
client.release();
|
||||
}
|
||||
releaseClient();
|
||||
cleanup();
|
||||
|
||||
console.error('[uploads/media/:id/file] Ошибка получения файла:', {
|
||||
console.error(`[uploads/media/:id/file] Ошибка получения файла ID ${mediaId}:`, {
|
||||
message: e.message,
|
||||
stack: e.stack,
|
||||
name: e.name,
|
||||
@@ -396,12 +617,21 @@ router.get('/media/:id/file', async (req, res) => {
|
||||
detail: e.detail,
|
||||
constraint: e.constraint,
|
||||
table: e.table,
|
||||
column: e.column
|
||||
column: e.column,
|
||||
mediaId: mediaId
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
|
||||
// Проверяем, можно ли отправлять ответ
|
||||
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: e.message || 'Внутренняя ошибка сервера',
|
||||
message: message,
|
||||
error: process.env.NODE_ENV === 'development' ? {
|
||||
name: e.name,
|
||||
code: e.code,
|
||||
@@ -491,7 +721,27 @@ router.get('/media', auth.requireAuth, async (req, res) => {
|
||||
offset: parseInt(offset)
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -559,7 +809,28 @@ router.patch('/media/:id', auth.requireAuth, async (req, res) => {
|
||||
|
||||
return res.json({ success: true, data: rows[0] });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -613,7 +884,28 @@ router.delete('/media/:id', auth.requireAuth, async (req, res) => {
|
||||
|
||||
return res.json({ success: true, message: 'Медиа-файл удален' });
|
||||
} catch (e) {
|
||||
return res.status(500).json({ success: false, message: e.message });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -146,12 +146,29 @@ server.listen(PORT, '0.0.0.0', async () => {
|
||||
});
|
||||
|
||||
// Обработка ошибок
|
||||
process.on('unhandledRejection', (err) => {
|
||||
logger.error('Unhandled Rejection:', err);
|
||||
process.on('unhandledRejection', (err, promise) => {
|
||||
logger.error('Unhandled Rejection:', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
promise: promise
|
||||
});
|
||||
// Не завершаем процесс, так как это может быть временная ошибка подключения к БД
|
||||
// В production лучше логировать и продолжать работу
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.error('Unhandled Rejection Details:', err);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error('Uncaught Exception:', err);
|
||||
logger.error('Uncaught Exception:', {
|
||||
error: err.message,
|
||||
stack: err.stack
|
||||
});
|
||||
// Для критических ошибок лучше завершить процесс после логирования
|
||||
// Но даем время на логирование
|
||||
setTimeout(() => {
|
||||
process.exit(1);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Запускаем мониторинг памяти в production
|
||||
|
||||
@@ -105,10 +105,10 @@ class DatabaseConnectionManager {
|
||||
user: config.db_user,
|
||||
password: config.db_password,
|
||||
ssl: false,
|
||||
max: 10,
|
||||
min: 0,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
max: 100, // Увеличиваем максимальное количество клиентов (было 10)
|
||||
min: 10, // Минимальное количество клиентов для лучшей производительности (было 0)
|
||||
idleTimeoutMillis: 180000, // Увеличиваем до 180 сек (было 30)
|
||||
connectionTimeoutMillis: 180000, // Увеличиваем таймаут подключения до 180 сек (было 2)
|
||||
maxUses: 7500,
|
||||
allowExitOnIdle: true,
|
||||
maxLifetimeSeconds: 0
|
||||
|
||||
@@ -15,6 +15,12 @@ class DeployParamsService {
|
||||
database: process.env.DB_NAME || 'dapp_db',
|
||||
password: process.env.DB_PASSWORD || 'dapp_password',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
max: 100, // Увеличиваем максимальное количество клиентов
|
||||
min: 10, // Минимальное количество клиентов
|
||||
idleTimeoutMillis: 180000, // Время жизни неактивного клиента (180 сек)
|
||||
connectionTimeoutMillis: 180000, // Таймаут подключения (180 сек)
|
||||
maxUses: 7500,
|
||||
allowExitOnIdle: true,
|
||||
});
|
||||
// Используем глобальный экземпляр encryptedDb
|
||||
}
|
||||
|
||||
@@ -25,11 +25,21 @@ async function getUserTokenBalances(address) {
|
||||
const encryptionUtils = require('../utils/encryptionUtils');
|
||||
const encryptionKey = encryptionUtils.getEncryptionKey();
|
||||
|
||||
// Получаем токены и RPC с расшифровкой
|
||||
const tokensResult = await db.getQuery()(
|
||||
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
||||
[encryptionKey]
|
||||
);
|
||||
// Получаем токены и RPC с расшифровкой (с таймаутом)
|
||||
let tokensResult;
|
||||
try {
|
||||
const queryPromise = db.getQuery()(
|
||||
'SELECT id, min_balance, readonly_threshold, editor_threshold, created_at, updated_at, decrypt_text(name_encrypted, $1) as name, decrypt_text(address_encrypted, $1) as address, decrypt_text(network_encrypted, $1) as network FROM auth_tokens',
|
||||
[encryptionKey]
|
||||
);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Database query timeout')), 30000)
|
||||
);
|
||||
tokensResult = await Promise.race([queryPromise, timeoutPromise]);
|
||||
} catch (error) {
|
||||
logger.error('[tokenBalanceService] Ошибка получения токенов из БД:', error.message);
|
||||
return []; // Возвращаем пустой массив при ошибке БД
|
||||
}
|
||||
const tokens = tokensResult.rows;
|
||||
|
||||
// Убрано - используем rpcService вместо прямого запроса к БД
|
||||
|
||||
Reference in New Issue
Block a user