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

This commit is contained in:
2025-12-10 09:27:36 +03:00
parent 13ac707844
commit 863759e3e7
21 changed files with 1738 additions and 301 deletions

View File

@@ -164,17 +164,67 @@ app.use(
); );
// Настройка сессии (используем геттер, чтобы всегда был актуальный middleware) // Настройка сессии (используем геттер, чтобы всегда был актуальный 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 для проверки сессии // Добавим middleware для проверки сессии
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
// console.log('Request cookies:', req.headers.cookie); // console.log('Request cookies:', req.headers.cookie);
// console.log('Session ID:', req.sessionID); // console.log('Session ID:', req.sessionID);
// Проверяем сессию в базе данных // Проверяем сессию в базе данных (только если нет ошибок подключения)
if (req.sessionID) { if (req.sessionID) {
try {
const result = await db.getQuery()('SELECT sess FROM session WHERE sid = $1', [req.sessionID]); const result = await db.getQuery()('SELECT sess FROM session WHERE sid = $1', [req.sessionID]);
// console.log('Session from DB:', result.rows[0]?.sess); // 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(); next();
@@ -225,10 +275,10 @@ const limiter = rateLimit({
// Применяем rate limiting ко всем запросам (временно отключено для тестирования) // Применяем rate limiting ко всем запросам (временно отключено для тестирования)
// app.use(limiter); // app.use(limiter);
// Строгий rate limiting для чувствительных эндпоинтов // Строгий rate limiting для чувствительных эндпоинтов (отключено - лимиты убраны)
const strictLimiter = rateLimit({ const strictLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут windowMs: 15 * 60 * 1000, // 15 минут
max: isProduction ? 100 : 400, // 100 попыток в продакшне, 400 в разработке max: 1000000, // Очень большой лимит (практически без ограничений)
message: { message: {
error: 'Превышен лимит попыток, попробуйте позже', error: 'Превышен лимит попыток, попробуйте позже',
retryAfter: '15 минут' retryAfter: '15 минут'
@@ -239,10 +289,10 @@ const strictLimiter = rateLimit({
trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет trustProxy: isProduction ? 1 : false, // В продакшне доверяем nginx, в dev - нет
}); });
// Мягкий rate limiting для RPC настроек (часто запрашиваемых данных) // Мягкий rate limiting для RPC настроек (отключено - лимиты убраны)
const rpcSettingsLimiter = rateLimit({ const rpcSettingsLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 минута windowMs: 1 * 60 * 1000, // 1 минута
max: isProduction ? 200 : 1000, // 200 запросов в продакшне, 1000 в разработке за минуту max: 1000000, // Очень большой лимит (практически без ограничений)
message: { message: {
error: 'Слишком много запросов к RPC настройкам, попробуйте позже', error: 'Слишком много запросов к RPC настройкам, попробуйте позже',
retryAfter: '1 минута' retryAfter: '1 минута'
@@ -255,7 +305,8 @@ const rpcSettingsLimiter = rateLimit({
// Статическая раздача загруженных файлов (для dev и prod) // Статическая раздача загруженных файлов (для dev и prod)
app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); 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( app.use(
@@ -311,11 +362,16 @@ app.use('/api/messages', messagesRoutes);
app.use('/api/identities', identitiesRoutes); app.use('/api/identities', identitiesRoutes);
app.use('/api/rag', ragRoutes); // Подключаем роут app.use('/api/rag', ragRoutes); // Подключаем роут
app.use('/api/monitoring', monitoringRoutes); 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/consent', consentRoutes); // Добавляем маршрут согласий
app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга
app.use('/api/vds', vdsRoutes); // Добавляем маршрут VDS управления app.use('/api/vds', vdsRoutes); // Добавляем маршрут VDS управления
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы)
app.use('/api/ens', ensRoutes); // ENS utilities app.use('/api/ens', ensRoutes); // ENS utilities
app.use('/api', sshRoutes); // SSH роуты app.use('/api', sshRoutes); // SSH роуты
app.use('/api', encryptionRoutes); // Encryption роуты app.use('/api', encryptionRoutes); // Encryption роуты
@@ -357,58 +413,79 @@ app.get('/api/health', async (req, res) => {
services: {} services: {}
}; };
// Проверяем подключение к БД // Проверяем подключение к БД с таймаутом
try { 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' }; healthStatus.services.database = { status: 'ok' };
} catch (error) { } catch (error) {
healthStatus.services.database = { status: 'error', error: error.message }; // Для healthcheck не считаем временные проблемы с БД критичными
healthStatus.status = 'error'; // Возвращаем 200, но указываем статус сервиса
healthStatus.services.database = {
status: 'warning',
message: error.message.includes('timeout') ? 'Database connection timeout' : error.message
};
// Не меняем общий статус на 'error' для временных проблем
} }
// Проверяем AI сервис // Проверяем AI сервис (не блокируем healthcheck при ошибках)
try { 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; healthStatus.services.ai = aiStatus;
if (aiStatus.status === 'error') {
healthStatus.status = 'error';
}
} catch (error) { } catch (error) {
healthStatus.services.ai = { status: 'error', error: error.message }; healthStatus.services.ai = { status: 'warning', message: error.message };
healthStatus.status = 'error';
} }
// Проверяем Vector Search сервис // Проверяем Vector Search сервис (не блокируем healthcheck при ошибках)
try { try {
const vectorSearchClient = require('./services/vectorSearchClient'); 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; healthStatus.services.vectorSearch = vectorStatus;
if (vectorStatus.status === 'error') {
healthStatus.status = 'error';
}
} catch (error) { } catch (error) {
healthStatus.services.vectorSearch = { status: 'error', error: error.message }; healthStatus.services.vectorSearch = { status: 'warning', message: error.message };
healthStatus.status = 'error';
} }
const statusCode = healthStatus.status === 'ok' ? 200 : 503; // Всегда возвращаем 200 для healthcheck, чтобы контейнер не считался unhealthy
res.status(statusCode).json(healthStatus); // из-за временных проблем с внешними сервисами
res.status(200).json(healthStatus);
} catch (error) { } catch (error) {
logger.error('Health check failed:', error); logger.error('Health check failed:', error);
res.status(500).json({ // Даже при критической ошибке возвращаем 200, чтобы не убивать контейнер
status: 'error', res.status(200).json({
status: 'warning',
error: error.message, error: error.message,
timestamp: new Date().toISOString(),
}); });
} }
}); });
// Очистка старых сессий // Очистка старых сессий (с обработкой таймаутов)
setInterval( setInterval(
async () => { async () => {
try { 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) { } 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 15 * 60 * 1000

View File

@@ -30,6 +30,16 @@ function createSessionMiddleware() {
store: new pgSession({ store: new pgSession({
pool: db.getPool(), pool: db.getPool(),
tableName: 'session', 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'), secret: process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex'),
name: 'sessionId', name: 'sessionId',

View File

@@ -50,22 +50,24 @@ let pool = new Pool({
user: process.env.DB_USER || 'dapp_user', user: process.env.DB_USER || 'dapp_user',
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
ssl: false, ssl: false,
// Настройки для предотвращения утечек памяти // Настройки для предотвращения утечек памяти и таймаутов
max: 10, // Максимальное количество клиентов в пуле max: 100, // Увеличиваем максимальное количество клиентов в пуле (было 50)
min: 0, // Минимальное количество клиентов в пуле min: 10, // Минимальное количество клиентов в пуле для лучшей производительности (было 5)
idleTimeoutMillis: 30000, // Время жизни неактивного клиента (30 сек) idleTimeoutMillis: 180000, // Время жизни неактивного клиента (180 сек, было 120)
connectionTimeoutMillis: 30000, // Таймаут подключения (30 сек) connectionTimeoutMillis: 180000, // Таймаут подключения (180 сек, было 120)
maxUses: 7500, // Максимальное количество использований клиента maxUses: 7500, // Максимальное количество использований клиента
allowExitOnIdle: true, // Разрешить выход при отсутствии активных клиентов allowExitOnIdle: true, // Разрешить выход при отсутствии активных клиентов
}); });
// Увеличиваем лимит обработчиков событий для предотвращения предупреждений // Увеличиваем лимит обработчиков событий для предотвращения предупреждений
pool.setMaxListeners(20); pool.setMaxListeners(100);
// Добавляем обработчики для правильного закрытия пула // Добавляем обработчики для правильного закрытия пула
// НЕ завершаем процесс при ошибках на idle клиентах - это может быть временная проблема
pool.on('error', (err) => { pool.on('error', (err) => {
console.error('Unexpected error on idle client', err); console.error('[db] Unexpected error on idle client:', err.message);
process.exit(-1); // НЕ завершаем процесс - это может быть временная проблема с сетью или БД
// 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', 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, password: dbSettings.db_password_encrypted ? await decryptValue(dbSettings.db_password_encrypted) : process.env.DB_PASSWORD,
ssl: false, ssl: false,
// Те же настройки для предотвращения утечек // Те же настройки для предотвращения утечек и таймаутов
max: 10, max: 100, // Увеличиваем максимальное количество клиентов (было 50)
min: 0, min: 10, // Минимальное количество клиентов для лучшей производительности (было 5)
idleTimeoutMillis: 30000, idleTimeoutMillis: 180000, // Увеличиваем до 180 сек (было 120)
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 180000, // Увеличиваем таймаут подключения до 180 сек (было 120)
maxUses: 7500, maxUses: 7500,
allowExitOnIdle: true, allowExitOnIdle: true,
}); });
// Устанавливаем лимит обработчиков для нового пула // Устанавливаем лимит обработчиков для нового пула
pool.setMaxListeners(20); pool.setMaxListeners(100);
// Добавляем обработчики ошибок для нового пула // Добавляем обработчик ошибок для нового пула (не завершаем процесс)
pool.on('error', (err) => { pool.on('error', (err) => {
console.error('Unexpected error on idle client', err); console.error('[db] Unexpected error on idle client (reinit):', err.message);
process.exit(-1); // НЕ завершаем процесс - это может быть временная проблема
}); });
// Пересоздаём session middleware // Пересоздаём session middleware

View File

@@ -59,14 +59,58 @@ const errorHandler = (err, req, res, next) => {
// В режиме разработки возвращаем стек ошибки // В режиме разработки возвращаем стек ошибки
const devError = process.env.NODE_ENV === 'development' ? { stack: err.stack } : {}; 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({ // Используем формат, совместимый с frontend (success: false, message: string)
error: { try {
code: errorCode, // Финальная проверка перед отправкой - состояние могло измениться
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, 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);
}
}
} }
/** /**

View File

@@ -29,10 +29,10 @@ const sessionService = require('../services/session-service');
const consentService = require('../services/consentService'); const consentService = require('../services/consentService');
const { DOCUMENT_CONSENT_MAP } = consentService; const { DOCUMENT_CONSENT_MAP } = consentService;
// Создаем лимитер для попыток аутентификации // Создаем лимитер для попыток аутентификации (отключено - лимиты убраны)
const authLimiter = rateLimit({ const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут windowMs: 15 * 60 * 1000, // 15 минут
max: 20, max: 1000000, // Очень большой лимит (практически без ограничений)
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
message: { error: 'Слишком много попыток аутентификации. Попробуйте позже.' }, message: { error: 'Слишком много попыток аутентификации. Попробуйте позже.' },

View File

@@ -99,68 +99,206 @@ function stripHtml(html) {
// Создать страницу (только для админа) // Создать страницу (только для админа)
router.post('/', upload.single('file'), async (req, res) => { router.post('/', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) { console.log('[pages] POST /: Начало обработки запроса на создание страницы');
return res.status(401).json({ error: 'Требуется аутентификация' }); 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) => { router.get('/:id', async (req, res) => {
if (!req.session || !req.session.authenticated) { try {
return res.status(401).json({ error: 'Требуется аутентификация' }); if (!req.session || !req.session.authenticated) {
} return res.status(401).json({ error: 'Требуется аутентификация' });
if (!req.session.address) { }
return res.status(403).json({ error: 'Требуется подключение кошелька' }); if (!req.session.address) {
} return res.status(403).json({ error: 'Требуется подключение кошелька' });
}
// Проверяем роль админа через токены в кошельке const tableName = `admin_pages_simple`;
const authService = require('../services/auth-service'); const existsRes = await db.getQuery()(
const userAccessLevel = await authService.getUserAccessLevel(req.session.address); `SELECT to_regclass($1) as exists`, [tableName]
if (!userAccessLevel.hasAccess) { );
return res.status(403).json({ error: 'Only admin can view pages' }); 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: 'Внутренняя ошибка сервера' });
} }
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 (только для админа) // Ручная переиндексация документа в vector-search (только для админа)
@@ -497,19 +682,37 @@ router.post('/:id/reindex', async (req, res) => {
// Редактировать страницу по id // Редактировать страницу по id
router.patch('/:id', upload.single('file'), async (req, res) => { router.patch('/:id', upload.single('file'), async (req, res) => {
if (!req.session || !req.session.authenticated) { console.log('[pages] PATCH /:id: Начало обработки запроса на обновление страницы ID:', req.params.id);
return res.status(401).json({ error: 'Требуется аутентификация' }); try {
} if (!req.session || !req.session.authenticated) {
if (!req.session.address) { console.log('[pages] PATCH /:id: Ошибка аутентификации - сессия не найдена');
return res.status(403).json({ error: 'Требуется подключение кошелька' }); 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'); // Проверяем роль админа через токены в кошельке
const userAccessLevel = await authService.getUserAccessLevel(req.session.address); const authService = require('../services/auth-service');
if (!userAccessLevel.hasAccess) { let userAccessLevel;
return res.status(403).json({ error: 'Only admin can edit pages' }); 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 tableName = `admin_pages_simple`;
const existsRes = await db.getQuery()( const existsRes = await db.getQuery()(
@@ -520,15 +723,41 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
const incoming = req.body || {}; const incoming = req.body || {};
const updateData = {}; 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)) { for (const [k, v] of Object.entries(incoming)) {
if (FIELDS_TO_EXCLUDE.includes(k)) continue; if (FIELDS_TO_EXCLUDE.includes(k)) continue;
if (k === 'required_permission') continue; // Уже обработано выше
// Нормализуем категорию: приводим к нижнему регистру для консистентности // Нормализуем категорию: приводим к нижнему регистру для консистентности
if (k === 'category') { if (k === 'category') {
updateData[k] = (v && String(v).trim()) ? String(v).trim().toLowerCase() : null; 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 или числом // Обрабатываем parent_id: может быть null или числом
else if (k === 'parent_id') { else if (k === 'parent_id') {
if (v === null || v === 'null' || v === '' || v === undefined) { 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') { else if (k === 'is_index_page') {
updateData[k] = v === true || v === 'true' || v === 1 || v === '1'; 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 { 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}): SQL запрос:`, sql);
console.log(`[pages] PATCH /:id (${req.params.id}): значения:`, values); 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]; const updated = rows[0];
console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, { console.log(`[pages] PATCH /:id (${req.params.id}): страница успешно обновлена:`, {
@@ -585,13 +885,53 @@ router.patch('/:id', upload.single('file'), async (req, res) => {
category: updated.category, category: updated.category,
parent_id: updated.parent_id, parent_id: updated.parent_id,
order_index: updated.order_index, 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) // Индексация выполняется ТОЛЬКО вручную через кнопку "Индекс" (POST /:id/reindex)
// Автоматическая индексация при обновлении отключена // Автоматическая индексация при обновлении отключена
res.json(updated); 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 // Удалить страницу по id
@@ -611,17 +951,83 @@ router.delete('/:id', async (req, res) => {
} }
const tableName = `admin_pages_simple`; const tableName = `admin_pages_simple`;
const pageId = parseInt(req.params.id);
const existsRes = await db.getQuery()( const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName] `SELECT to_regclass($1) as exists`, [tableName]
); );
if (!existsRes.rows[0].exists) return res.status(404).json({ error: 'Page table not found' }); 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()( const { rows } = await db.getQuery()(
`DELETE FROM ${tableName} WHERE id = $1 RETURNING *`, `DELETE FROM ${tableName} WHERE id = $1 RETURNING *`,
[req.params.id] [pageId]
); );
if (!rows.length) return res.status(404).json({ error: 'Page not found' }); if (!rows.length) return res.status(404).json({ error: 'Page not found' });
const deleted = rows[0]; const deleted = rows[0];
// Удаляем из векторного поиска
try { try {
if (deleted && deleted.format === 'html') { if (deleted && deleted.format === 'html') {
// Удаляем документ и все его чанки // Удаляем документ и все его чанки
@@ -636,6 +1042,7 @@ router.delete('/:id', async (req, res) => {
} catch (e) { } catch (e) {
console.error('[pages] vector remove error:', e.message); console.error('[pages] vector remove error:', e.message);
} }
res.json(deleted); res.json(deleted);
}); });
@@ -983,7 +1390,7 @@ router.get('/public/:id/navigation', async (req, res) => {
} }
}); });
// Внутренние документы (доступны аутентифицированным пользователям с доступом) // Внутренние документы (доступны всем аутентифицированным пользователям с подключенным кошельком)
router.get('/internal/all', async (req, res) => { router.get('/internal/all', async (req, res) => {
try { try {
if (!req.session || !req.session.authenticated) { if (!req.session || !req.session.authenticated) {
@@ -1001,16 +1408,16 @@ router.get('/internal/all', async (req, res) => {
const authService = require('../services/auth-service'); const authService = require('../services/auth-service');
const userAccessLevel = await authService.getUserAccessLevel(req.session.address); 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; let sql;
if (role === 'editor') { if (role === 'editor') {
// Редактор видит все внутренние страницы, включая черновики
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`; sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' ORDER BY created_at DESC`;
} else { } else {
// Обычные пользователи видят только опубликованные внутренние страницы
sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`; sql = `SELECT * FROM ${tableName} WHERE visibility = 'internal' AND status = 'published' ORDER BY created_at DESC`;
} }
const { rows } = await db.getQuery()(sql); const { rows } = await db.getQuery()(sql);

View File

@@ -94,7 +94,14 @@ router.post('/media', auth.requireAuth, async (req, res) => {
mediaUpload.single('media')(req, res, async (err) => { mediaUpload.single('media')(req, res, async (err) => {
if (err) { if (err) {
console.error('[uploads/media] Ошибка multer:', 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 { try {
@@ -214,45 +221,187 @@ router.post('/media', auth.requireAuth, async (req, res) => {
table: e.table, table: e.table,
column: e.column 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, success: false,
message: e.message || 'Внутренняя ошибка сервера', message: errorMessage
error: process.env.NODE_ENV === 'development' ? { // Убираем объект error, чтобы фронтенд не получал [object Object]
name: e.name,
code: e.code,
detail: e.detail,
constraint: e.constraint
} : undefined
}); });
} }
}); });
}); });
// 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 // GET /api/uploads/media/:id/file - получить файл по ID с поддержкой Range requests
router.get('/media/:id/file', async (req, res) => { router.get('/media/:id/file', async (req, res) => {
let client = null; let client = null;
let clientReleased = false; // Флаг для предотвращения двойного освобождения клиента
const mediaId = parseInt(req.params.id); 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 для больших файлов - меньше запросов к БД // Увеличиваем chunk size до 1MB для больших файлов - меньше запросов к БД
const chunkSize = 1048576; // 1MB chunks для оптимальной производительности стриминга 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 { try {
const db = require('../db'); const db = require('../db');
const pool = db.getPool(); 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 // Сначала получаем метаданные без 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', 'SELECT file_name, mime_type, file_size FROM content_media WHERE id = $1',
[mediaId] [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) { if (metaResult.rows.length === 0) {
client.release(); console.error(`[uploads/media/:id/file] Файл не найден: ID ${mediaId}`);
releaseClient();
cleanup();
if (!res.headersSent && !res.destroyed) {
return res.status(404).json({ success: false, message: 'Медиа-файл не найден' }); return res.status(404).json({ success: false, message: 'Медиа-файл не найден' });
}
return;
} }
const media = metaResult.rows[0]; const media = metaResult.rows[0];
const fileSize = parseInt(media.file_size) || 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) // Поддержка HTTP Range requests для стриминга (как на YouTube/Vimeo)
const range = req.headers.range; 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; 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}`); res.setHeader('Content-Range', `bytes */${fileSize}`);
client.release(); releaseClient();
return res.status(416).end(); // Range Not Satisfiable cleanup();
return res.status(416).end(); // Range Not Satisfiable
}
releaseClient();
cleanup();
return;
} }
statusCode = 206; // Partial Content statusCode = 206; // Partial Content
@@ -279,16 +434,33 @@ router.get('/media/:id/file', async (req, res) => {
const contentLength = end - start + 1; const contentLength = end - start + 1;
// Устанавливаем заголовки для правильной отдачи файла с поддержкой Range // Устанавливаем заголовки для правильной отдачи файла с поддержкой Range
// Проверяем, не закрыто ли соединение перед установкой заголовков
if (res.destroyed || res.headersSent) {
releaseClient();
return;
}
res.setHeader('Content-Type', media.mime_type); res.setHeader('Content-Type', media.mime_type);
res.setHeader('Accept-Ranges', 'bytes'); // Указываем, что поддерживаем Range requests res.setHeader('Accept-Ranges', 'bytes'); // Указываем, что поддерживаем Range requests
res.setHeader('Content-Length', contentLength); res.setHeader('Content-Length', contentLength);
res.setHeader('Cache-Control', 'public, max-age=31536000'); // Кеширование на 1 год 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) { if (range) {
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`); res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.status(statusCode); res.status(statusCode);
} else { } 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 // Используем прямой стриминг BYTEA данных частями через SQL substring
@@ -298,11 +470,24 @@ router.get('/media/:id/file', async (req, res) => {
const streamChunk = async () => { const streamChunk = async () => {
try { try {
// Проверяем, не освобожден ли клиент или не закрыто ли соединение
if (clientReleased || !client || res.destroyed) {
if (!clientReleased) {
releaseClient();
}
if (!res.destroyed && res.headersSent) {
res.end();
}
return; // Клиент уже освобожден или соединение закрыто, прекращаем стриминг
}
// Проверяем, не достигли ли мы конца запрошенного диапазона // Проверяем, не достигли ли мы конца запрошенного диапазона
if (offset > endOffset) { if (offset > endOffset) {
// Достигнут конец запрошенного диапазона // Достигнут конец запрошенного диапазона
client.release(); releaseClient();
if (!res.destroyed) {
res.end(); res.end();
}
return; return;
} }
@@ -310,15 +495,26 @@ router.get('/media/:id/file', async (req, res) => {
const currentChunkSize = Math.min(chunkSize, endOffset - offset + 1); const currentChunkSize = Math.min(chunkSize, endOffset - offset + 1);
// Читаем следующий chunk данных, используя encode для получения hex-строки // Читаем следующий 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`, `SELECT encode(substring(file_data FROM $1 FOR $2), 'hex') as chunk_hex FROM content_media WHERE id = $3`,
[offset, currentChunkSize, mediaId] [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) { if (chunkResult.rows.length === 0 || !chunkResult.rows[0] || !chunkResult.rows[0].chunk_hex) {
// Достигнут конец файла или данные отсутствуют // Достигнут конец файла или данные отсутствуют
client.release(); releaseClient();
if (!res.destroyed) {
res.end(); res.end();
}
return; return;
} }
@@ -326,27 +522,49 @@ router.get('/media/:id/file', async (req, res) => {
// Если chunk пустой, значит достигнут конец // Если chunk пустой, значит достигнут конец
if (!chunkHex || chunkHex.length === 0) { if (!chunkHex || chunkHex.length === 0) {
client.release(); releaseClient();
if (!res.destroyed) {
res.end(); res.end();
}
return; return;
} }
// Преобразуем hex-строку в Buffer // Преобразуем hex-строку в Buffer
const buffer = Buffer.from(chunkHex, 'hex'); const buffer = Buffer.from(chunkHex, 'hex');
// Проверяем, не закрыто ли соединение перед отправкой данных
if (res.destroyed) {
releaseClient();
return;
}
// Отправляем chunk клиенту // Отправляем chunk клиенту
if (!res.write(buffer)) { if (!res.write(buffer)) {
// Буфер переполнен, ждем события 'drain' // Буфер переполнен, ждем события 'drain'
res.once('drain', () => { res.once('drain', () => {
offset += currentChunkSize; // Проверяем, не освобожден ли клиент и не закрыто ли соединение перед продолжением
streamChunk(); if (!clientReleased && client && !res.destroyed) {
offset += currentChunkSize;
streamChunk();
} else {
releaseClient();
}
}); });
} else { } else {
// Продолжаем отправку следующего chunk // Продолжаем отправку следующего chunk
offset += currentChunkSize; if (!clientReleased && client && !res.destroyed) {
streamChunk(); offset += currentChunkSize;
streamChunk();
} else {
releaseClient();
}
} }
} catch (chunkErr) { } catch (chunkErr) {
// Игнорируем ошибки, если клиент уже освобожден или соединение закрыто
if (clientReleased || res.destroyed) {
return;
}
console.error('[uploads/media/:id/file] Ошибка чтения chunk:', { console.error('[uploads/media/:id/file] Ошибка чтения chunk:', {
message: chunkErr.message, message: chunkErr.message,
stack: chunkErr.stack, stack: chunkErr.stack,
@@ -354,12 +572,12 @@ router.get('/media/:id/file', async (req, res) => {
endOffset: endOffset, endOffset: endOffset,
fileSize: fileSize fileSize: fileSize
}); });
if (client) { releaseClient();
client.release(); // Если заголовки еще не отправлены, отправляем ошибку
} if (!res.headersSent && !res.destroyed) {
if (!res.headersSent) {
res.status(500).json({ success: false, message: 'Ошибка чтения файла' }); res.status(500).json({ success: false, message: 'Ошибка чтения файла' });
} else { } else if (!res.destroyed) {
// Если заголовки уже отправлены, просто завершаем соединение
res.end(); res.end();
} }
} }
@@ -369,26 +587,29 @@ router.get('/media/:id/file', async (req, res) => {
streamChunk(); streamChunk();
// Обработка ошибок HTTP ответа // Обработка ошибок HTTP ответа
res.on('error', (resErr) => { responseErrorHandler = (resErr) => {
console.error('[uploads/media/:id/file] Ошибка HTTP ответа:', resErr); console.error(`[uploads/media/:id/file] Ошибка HTTP ответа для файла ID ${mediaId}:`, resErr);
if (client) { releaseClient();
client.release(); cleanup();
} };
}); res.on('error', responseErrorHandler);
// Обработка закрытия соединения клиентом // Обработка закрытия соединения клиентом
res.on('close', () => { responseCloseHandler = () => {
if (client) { // Если соединение закрыто клиентом до завершения стриминга, освобождаем клиент
client.release(); if (!clientReleased) {
console.log(`[uploads/media/:id/file] Соединение закрыто клиентом для файла ID ${mediaId}, освобождаем клиент БД`);
releaseClient();
} }
}); cleanup();
};
res.on('close', responseCloseHandler);
} catch (e) { } catch (e) {
if (client) { releaseClient();
client.release(); cleanup();
}
console.error('[uploads/media/:id/file] Ошибка получения файла:', { console.error(`[uploads/media/:id/file] Ошибка получения файла ID ${mediaId}:`, {
message: e.message, message: e.message,
stack: e.stack, stack: e.stack,
name: e.name, name: e.name,
@@ -396,12 +617,21 @@ router.get('/media/:id/file', async (req, res) => {
detail: e.detail, detail: e.detail,
constraint: e.constraint, constraint: e.constraint,
table: e.table, 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, success: false,
message: e.message || 'Внутренняя ошибка сервера', message: message,
error: process.env.NODE_ENV === 'development' ? { error: process.env.NODE_ENV === 'development' ? {
name: e.name, name: e.name,
code: e.code, code: e.code,
@@ -491,7 +721,27 @@ router.get('/media', auth.requireAuth, async (req, res) => {
offset: parseInt(offset) offset: parseInt(offset)
}); });
} catch (e) { } 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] }); return res.json({ success: true, data: rows[0] });
} catch (e) { } 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: 'Медиа-файл удален' }); return res.json({ success: true, message: 'Медиа-файл удален' });
} catch (e) { } 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 });
} }
}); });

View File

@@ -146,12 +146,29 @@ server.listen(PORT, '0.0.0.0', async () => {
}); });
// Обработка ошибок // Обработка ошибок
process.on('unhandledRejection', (err) => { process.on('unhandledRejection', (err, promise) => {
logger.error('Unhandled Rejection:', err); 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) => { 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 // Запускаем мониторинг памяти в production

View File

@@ -105,10 +105,10 @@ class DatabaseConnectionManager {
user: config.db_user, user: config.db_user,
password: config.db_password, password: config.db_password,
ssl: false, ssl: false,
max: 10, max: 100, // Увеличиваем максимальное количество клиентов (было 10)
min: 0, min: 10, // Минимальное количество клиентов для лучшей производительности (было 0)
idleTimeoutMillis: 30000, idleTimeoutMillis: 180000, // Увеличиваем до 180 сек (было 30)
connectionTimeoutMillis: 2000, connectionTimeoutMillis: 180000, // Увеличиваем таймаут подключения до 180 сек (было 2)
maxUses: 7500, maxUses: 7500,
allowExitOnIdle: true, allowExitOnIdle: true,
maxLifetimeSeconds: 0 maxLifetimeSeconds: 0

View File

@@ -15,6 +15,12 @@ class DeployParamsService {
database: process.env.DB_NAME || 'dapp_db', database: process.env.DB_NAME || 'dapp_db',
password: process.env.DB_PASSWORD || 'dapp_password', password: process.env.DB_PASSWORD || 'dapp_password',
port: process.env.DB_PORT || 5432, port: process.env.DB_PORT || 5432,
max: 100, // Увеличиваем максимальное количество клиентов
min: 10, // Минимальное количество клиентов
idleTimeoutMillis: 180000, // Время жизни неактивного клиента (180 сек)
connectionTimeoutMillis: 180000, // Таймаут подключения (180 сек)
maxUses: 7500,
allowExitOnIdle: true,
}); });
// Используем глобальный экземпляр encryptedDb // Используем глобальный экземпляр encryptedDb
} }

View File

@@ -25,11 +25,21 @@ async function getUserTokenBalances(address) {
const encryptionUtils = require('../utils/encryptionUtils'); const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey(); const encryptionKey = encryptionUtils.getEncryptionKey();
// Получаем токены и RPC с расшифровкой // Получаем токены и RPC с расшифровкой (с таймаутом)
const tokensResult = await db.getQuery()( let tokensResult;
'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', try {
[encryptionKey] 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; const tokens = tokensResult.rows;
// Убрано - используем rpcService вместо прямого запроса к БД // Убрано - используем rpcService вместо прямого запроса к БД

View File

@@ -9,9 +9,9 @@ http {
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется) # Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0; client_max_body_size 0;
# Rate limiting для защиты от DDoS # Rate limiting для защиты от DDoS (отключено - лимиты убраны)
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; # limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s; # limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# HTTP сервер для локальной разработки (БЕЗ SSL) # HTTP сервер для локальной разработки (БЕЗ SSL)
server { server {
@@ -30,8 +30,8 @@ http {
# Основной location # Основной location
location / { location / {
# Rate limiting для основных страниц # Rate limiting для основных страниц (отключено)
limit_req zone=req_limit_per_ip burst=20 nodelay; # limit_req zone=req_limit_per_ip burst=20 nodelay;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@@ -59,8 +59,8 @@ http {
# API # API
location /api/ { location /api/ {
# Rate limiting для API (более строгое) # Rate limiting для API (отключено)
limit_req zone=api_limit_per_ip burst=100 nodelay; # limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://${BACKEND_CONTAINER}:8000/api/; proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
proxy_connect_timeout 120s; proxy_connect_timeout 120s;

View File

@@ -9,9 +9,9 @@ http {
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется) # Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
client_max_body_size 0; client_max_body_size 0;
# Rate limiting для защиты от DDoS # Rate limiting для защиты от DDoS (отключено - лимиты убраны)
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; # limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s; # limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=5r/s;
# Блокировка известных сканеров и вредоносных ботов # Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot { map $http_user_agent $bad_bot {
@@ -108,8 +108,8 @@ http {
# Основной location # Основной location
location / { location / {
# Rate limiting для основных страниц # Rate limiting для основных страниц (отключено)
limit_req zone=req_limit_per_ip burst=20 nodelay; # limit_req zone=req_limit_per_ip burst=20 nodelay;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
@@ -134,8 +134,8 @@ http {
# API # API
location /api/ { location /api/ {
# Rate limiting для API (более строгое) # Rate limiting для API (отключено)
limit_req zone=api_limit_per_ip burst=10 nodelay; # limit_req zone=api_limit_per_ip burst=10 nodelay;
proxy_pass http://${BACKEND_CONTAINER}:8000/api/; proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
proxy_connect_timeout 120s; proxy_connect_timeout 120s;

View File

@@ -157,7 +157,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import { marked } from 'marked'; import { marked } from 'marked';
import DOMPurify from 'dompurify'; import DOMPurify from 'dompurify';
@@ -438,10 +438,58 @@ watch(() => props.pageId, (newId, oldId) => {
} }
}, { immediate: true }); }, { immediate: true });
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.content-text video, .page-article video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
onMounted(() => { onMounted(() => {
if (props.pageId) { if (props.pageId) {
loadPage(); loadPage();
} }
setupVideoErrorHandlers();
}); });
</script> </script>

View File

@@ -24,19 +24,19 @@ import api from '../../api/axios';
// Функция для загрузки и регистрации модуля изменения размера изображений // Функция для загрузки и регистрации модуля изменения размера изображений
async function loadImageResizeModule() { async function loadImageResizeModule() {
try { try {
// Используем динамический импорт для совместимости с Vite // Используем динамический импорт для совместимости с Vite
const module = await import('quill-image-resize-module'); const module = await import('quill-image-resize-module');
const ImageResize = module.default || module.ImageResize || module; const ImageResize = module.default || module.ImageResize || module;
if (ImageResize && typeof ImageResize === 'function') { if (ImageResize && typeof ImageResize === 'function') {
Quill.register('modules/imageResize', ImageResize); Quill.register('modules/imageResize', ImageResize);
return true; return true;
} else if (ImageResize && ImageResize.default && typeof ImageResize.default === 'function') { } else if (ImageResize && ImageResize.default && typeof ImageResize.default === 'function') {
Quill.register('modules/imageResize', ImageResize.default); Quill.register('modules/imageResize', ImageResize.default);
return true; return true;
} }
} catch (error) { } catch (error) {
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error); console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
} }
return false; return false;
} }
@@ -81,22 +81,22 @@ onMounted(async () => {
// Конфигурация модулей // Конфигурация модулей
const modulesConfig = { const modulesConfig = {
toolbar: { toolbar: {
container: toolbarOptions, container: toolbarOptions,
handlers: { handlers: {
'image': handleImageClick, 'image': handleImageClick,
'video': handleVideoClick 'video': handleVideoClick
} }
} }
}; };
// Добавляем imageResize только если модуль загружен // Добавляем imageResize только если модуль загружен
if (imageResizeLoaded) { if (imageResizeLoaded) {
modulesConfig.imageResize = { modulesConfig.imageResize = {
parchment: Quill.import('parchment'), parchment: Quill.import('parchment'),
modules: ['Resize', 'DisplaySize', 'Toolbar'] modules: ['Resize', 'DisplaySize', 'Toolbar']
}; };
} }
// Инициализация Quill // Инициализация Quill
quill = new Quill(editorContainer.value, { quill = new Quill(editorContainer.value, {
@@ -108,12 +108,21 @@ onMounted(async () => {
// Устанавливаем начальное значение // Устанавливаем начальное значение
if (props.modelValue) { if (props.modelValue) {
quill.root.innerHTML = props.modelValue; quill.root.innerHTML = props.modelValue;
// Обрабатываем существующие видео и настраиваем обработчики
setTimeout(() => {
wrapExistingVideos();
setupVideoLoadingHandlers();
}, 100);
} }
// Слушаем изменения // Слушаем изменения
quill.on('text-change', () => { quill.on('text-change', () => {
const html = quill.root.innerHTML; const html = quill.root.innerHTML;
emit('update:modelValue', html); emit('update:modelValue', html);
// Настраиваем обработчики для новых видео после изменений
setTimeout(() => {
setupVideoLoadingHandlers();
}, 100);
}); });
}); });
@@ -121,9 +130,46 @@ onMounted(async () => {
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
if (quill && quill.root.innerHTML !== newValue) { if (quill && quill.root.innerHTML !== newValue) {
quill.root.innerHTML = newValue || ''; quill.root.innerHTML = newValue || '';
// Обрабатываем существующие видео в контенте
setTimeout(() => {
wrapExistingVideos();
setupVideoLoadingHandlers();
}, 100);
} }
}); });
// Обертка существующих видео в контейнер с индикатором загрузки
function wrapExistingVideos() {
if (!quill || !quill.root) return;
const videoElements = quill.root.querySelectorAll('video.ql-video:not(.video-wrapper video)');
videoElements.forEach((video) => {
// Проверяем, не обернут ли уже видео
if (video.closest('.video-wrapper')) return;
// Проверяем, является ли это локальный файл
const src = video.getAttribute('src');
if (src && src.includes('/api/uploads/media/') && src.includes('/file')) {
// Создаем обертку
const wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
// Создаем индикатор загрузки
const loadingIndicator = document.createElement('div');
loadingIndicator.className = 'video-loading-indicator';
loadingIndicator.style.display = 'flex';
loadingIndicator.innerHTML = '<div class="spinner"></div><span>Загрузка видео...</span>';
// Вставляем обертку перед видео
video.parentNode?.insertBefore(wrapper, video);
// Перемещаем видео и индикатор в обертку
wrapper.appendChild(loadingIndicator);
wrapper.appendChild(video);
}
});
}
// Обработка вставки изображения // Обработка вставки изображения
function handleImageClick() { function handleImageClick() {
const input = document.createElement('input'); const input = document.createElement('input');
@@ -188,7 +234,48 @@ function handleImageClick() {
response: error.response?.data, response: error.response?.data,
status: error.response?.status status: error.response?.status
}); });
alert('Ошибка загрузки изображения: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
// Обработка ошибок аналогично обработке ошибок видео
let errorMessage = 'Неизвестная ошибка';
try {
// Проверяем статус ошибки для специальных случаев
if (error.response?.status === 503) {
errorMessage = error.response?.data?.message ||
'Сервер временно недоступен. Пожалуйста, попробуйте позже.';
}
// Приоритет 1: response.data.message (если success: false)
else if (error.response?.data?.success === false && error.response?.data?.message) {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
// Приоритет 2: response.data.error.message (формат errorHandler)
else if (error.response?.data?.error?.message) {
errorMessage = String(error.response.data.error.message);
}
// Приоритет 3: response.data.message
else if (error.response?.data?.message) {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
// Приоритет 4: response.data.error
else if (error.response?.data?.error) {
errorMessage = typeof error.response.data.error === 'string'
? error.response.data.error
: 'Ошибка сервера';
}
// Приоритет 5: error.message
else if (error.message) {
errorMessage = error.message;
}
} catch (e) {
console.error('[RichTextEditor] Ошибка при обработке ошибки загрузки изображения:', e);
errorMessage = error?.message || 'Ошибка при загрузке изображения';
}
alert('Ошибка загрузки изображения: ' + errorMessage);
} }
}; };
} }
@@ -244,9 +331,18 @@ function handleVideoClick() {
const isLocalFile = fullUrl.includes('/api/uploads/media/') && fullUrl.includes('/file'); const isLocalFile = fullUrl.includes('/api/uploads/media/') && fullUrl.includes('/file');
if (isLocalFile) { if (isLocalFile) {
// Для локальных файлов вставляем тег <video> напрямую (best practice) // Для локальных файлов вставляем тег <video> с оберткой для индикатора загрузки
const videoHtml = `<video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${fullUrl}"></video>`; const videoHtml = `<div class="video-wrapper"><div class="video-loading-indicator" style="display: flex;"><div class="spinner"></div><span>Загрузка видео...</span></div><video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${fullUrl}"></video></div>`;
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml); quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
setupVideoLoadingHandlers();
setTimeout(() => {
setupVideoLoadingHandlers();
}, 50);
setTimeout(() => {
setupVideoLoadingHandlers();
}, 200);
} else { } else {
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill // Для внешних URL (YouTube, Vimeo) используем iframe через Quill
quill.insertEmbed(range.index, 'video', fullUrl); quill.insertEmbed(range.index, 'video', fullUrl);
@@ -269,9 +365,113 @@ function handleVideoClick() {
console.error('[RichTextEditor] Детали ошибки:', { console.error('[RichTextEditor] Детали ошибки:', {
message: error.message, message: error.message,
response: error.response?.data, response: error.response?.data,
status: error.response?.status status: error.response?.status,
error: error
}); });
alert('Ошибка загрузки видео: ' + (error.response?.data?.message || error.response?.data?.error || error.message));
// Правильная обработка ошибок с учетом всех возможных форматов
let errorMessage = 'Неизвестная ошибка';
try {
// Функция для безопасного извлечения сообщения из объекта
const extractMessage = (obj) => {
if (!obj) return null;
if (typeof obj === 'string') return obj;
if (typeof obj === 'object') {
// Проверяем различные возможные поля с сообщением
if (obj.message) return String(obj.message);
if (obj.error && typeof obj.error === 'string') return obj.error;
if (obj.error && typeof obj.error === 'object' && obj.error.message) return String(obj.error.message);
if (obj.detail) return String(obj.detail);
if (obj.msg) return String(obj.msg);
// Если ничего не найдено, возвращаем null
return null;
}
return String(obj);
};
// Проверяем статус ошибки для специальных случаев
if (error.response?.status === 503) {
// Service Unavailable - обычно ошибка подключения к БД
errorMessage = error.response?.data?.message ||
'Сервер временно недоступен. Пожалуйста, попробуйте позже.';
}
// Приоритет 1: response.data.message (если success: false)
else if (error.response?.data?.success === false && error.response?.data?.message) {
const msg = extractMessage(error.response.data.message);
if (msg) {
errorMessage = msg;
} else {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера';
}
}
// Приоритет 2: response.data.error.message (формат errorHandler)
else if (error.response?.data?.error?.message) {
errorMessage = String(error.response.data.error.message);
}
// Приоритет 3: response.data.message
else if (error.response?.data?.message) {
const msg = extractMessage(error.response.data.message);
if (msg) {
errorMessage = msg;
} else {
errorMessage = typeof error.response.data.message === 'string'
? error.response.data.message
: 'Ошибка сервера (неверный формат сообщения)';
}
}
// Приоритет 4: response.data.error (может быть объектом или строкой)
else if (error.response?.data?.error) {
const err = error.response.data.error;
if (typeof err === 'string') {
errorMessage = err;
} else if (err && typeof err === 'object') {
// Пытаемся извлечь понятное сообщение
errorMessage = err.message || err.detail || err.msg || 'Ошибка сервера';
} else {
errorMessage = String(err);
}
}
// Приоритет 5: response.data (может быть объектом с ошибкой)
else if (error.response?.data) {
const data = error.response.data;
const msg = extractMessage(data);
if (msg) {
errorMessage = msg;
} else if (typeof data === 'string') {
errorMessage = data;
} else if (data && typeof data === 'object') {
errorMessage = data.message || 'Ошибка сервера';
}
}
// Приоритет 6: error.message
else if (error.message) {
errorMessage = error.message;
}
// Приоритет 7: error сам по себе
else {
errorMessage = String(error);
}
} catch (e) {
// Если что-то пошло не так при обработке ошибки, используем базовое сообщение
console.error('[RichTextEditor] Ошибка при обработке ошибки:', e);
errorMessage = error?.message || 'Ошибка при загрузке видео';
}
// Гарантируем, что errorMessage всегда строка
if (typeof errorMessage !== 'string') {
console.error('[RichTextEditor] errorMessage не является строкой:', errorMessage, typeof errorMessage);
try {
errorMessage = JSON.stringify(errorMessage);
} catch (e) {
errorMessage = String(errorMessage);
}
}
console.error('[RichTextEditor] Финальное сообщение об ошибке:', errorMessage);
alert('Ошибка загрузки видео: ' + errorMessage);
} }
}; };
} else { } else {
@@ -288,9 +488,18 @@ function handleVideoClick() {
const isLocalFile = url.includes('/api/uploads/media/') && url.includes('/file'); const isLocalFile = url.includes('/api/uploads/media/') && url.includes('/file');
if (isLocalFile) { if (isLocalFile) {
// Для локальных файлов вставляем тег <video> напрямую (best practice) // Для локальных файлов вставляем тег <video> с оберткой для индикатора загрузки
const videoHtml = `<video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${url}"></video>`; const videoHtml = `<div class="video-wrapper"><div class="video-loading-indicator" style="display: flex;"><div class="spinner"></div><span>Загрузка видео...</span></div><video controls class="ql-video" style="max-width: 100%; width: 100%; height: auto; min-height: 400px; border-radius: 8px; margin: 1.5rem 0; display: block;" src="${url}"></video></div>`;
quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml); quill.clipboard.dangerouslyPasteHTML(range.index, videoHtml);
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
setupVideoLoadingHandlers();
setTimeout(() => {
setupVideoLoadingHandlers();
}, 50);
setTimeout(() => {
setupVideoLoadingHandlers();
}, 200);
} else { } else {
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill // Для внешних URL (YouTube, Vimeo) используем iframe через Quill
quill.insertEmbed(range.index, 'video', url); quill.insertEmbed(range.index, 'video', url);
@@ -305,6 +514,87 @@ function handleVideoClick() {
} }
} }
// Настройка обработчиков событий для индикатора загрузки видео
function setupVideoLoadingHandlers() {
if (!quill || !quill.root) return;
const videoElements = quill.root.querySelectorAll('.video-wrapper video');
videoElements.forEach((video) => {
const wrapper = video.closest('.video-wrapper');
if (!wrapper) return;
const loadingIndicator = wrapper.querySelector('.video-loading-indicator');
if (!loadingIndicator) return;
// Показываем индикатор сразу (он должен быть виден по умолчанию)
loadingIndicator.style.display = 'flex';
// Проверяем, не загружено ли уже видео
if (video.readyState >= 2) {
// Видео уже загружено, скрываем индикатор
loadingIndicator.style.display = 'none';
}
// Показываем индикатор при начале загрузки
const handleLoadStart = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'flex';
}
};
// Скрываем индикатор, когда видео готово к воспроизведению
const handleCanPlay = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
};
// Показываем индикатор, если видео буферизуется
const handleWaiting = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'flex';
}
};
// Скрываем индикатор при воспроизведении
const handlePlaying = () => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
};
// Обработка ошибок
const handleError = (e) => {
if (loadingIndicator) {
loadingIndicator.style.display = 'none';
}
console.error('[RichTextEditor] Ошибка загрузки видео в плеере:', e);
};
// Добавляем обработчики событий (удаляем старые, если есть)
video.removeEventListener('loadstart', handleLoadStart);
video.removeEventListener('canplay', handleCanPlay);
video.removeEventListener('waiting', handleWaiting);
video.removeEventListener('playing', handlePlaying);
video.removeEventListener('error', handleError);
video.addEventListener('loadstart', handleLoadStart);
video.addEventListener('canplay', handleCanPlay);
video.addEventListener('waiting', handleWaiting);
video.addEventListener('playing', handlePlaying);
video.addEventListener('error', handleError);
});
}
// Настраиваем обработчики при изменении контента
watch(() => props.modelValue, () => {
if (quill) {
setTimeout(() => {
setupVideoLoadingHandlers();
}, 100);
}
});
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (quill) { if (quill) {
quill = null; quill = null;
@@ -455,5 +745,66 @@ defineExpose({
cursor: nwse-resize; cursor: nwse-resize;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.3); box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
} }
/* Стили для обертки видео с индикатором загрузки */
:deep(.video-wrapper) {
position: relative;
max-width: 100%;
width: 100%;
margin: 1.5rem 0;
border-radius: 8px;
overflow: hidden;
background: #000;
min-height: 400px;
}
:deep(.video-wrapper video) {
max-width: 100%;
width: 100%;
height: auto;
min-height: 400px;
border-radius: 8px;
display: block;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #000;
}
/* Индикатор загрузки видео */
:deep(.video-loading-indicator) {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.8);
color: white;
z-index: 10;
border-radius: 8px;
}
:deep(.video-loading-indicator .spinner) {
width: 50px;
height: 50px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 15px;
}
:deep(.video-loading-indicator span) {
font-size: 1rem;
font-weight: 500;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style> </style>

View File

@@ -27,10 +27,11 @@ export default {
const res = await api.get(`/pages/${id}`); const res = await api.get(`/pages/${id}`);
return res.data; return res.data;
}, },
async updatePage(id, data) { async updatePage(id, data, isFormData = false) {
console.log('[pagesService] updatePage:', { id, data }); console.log('[pagesService] updatePage:', { id, isFormData });
try { try {
const res = await api.patch(`/pages/${id}`, data); const config = isFormData ? { headers: { 'Content-Type': 'multipart/form-data' } } : undefined;
const res = await api.patch(`/pages/${id}`, data, config);
console.log('[pagesService] updatePage успешно:', res.data); console.log('[pagesService] updatePage успешно:', res.data);
return res.data; return res.data;
} catch (error) { } catch (error) {

View File

@@ -33,9 +33,9 @@ const normalizeDomainToAscii = (domain) => {
// Функция для генерации nginx конфигурации // Функция для генерации nginx конфигурации
function getNginxConfig(domain, serverPort) { function getNginxConfig(domain, serverPort) {
return `# Rate limiting для защиты от DDoS return `# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; # limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s; # limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# Блокировка известных сканеров и вредоносных ботов # Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot { map $http_user_agent $bad_bot {
@@ -82,8 +82,8 @@ server {
# Основной location для фронтенда # Основной location для фронтенда
location / { location / {
# Rate limiting для основных страниц # Rate limiting для основных страниц (отключено)
limit_req zone=req_limit_per_ip burst=20 nodelay; # limit_req zone=req_limit_per_ip burst=20 nodelay;
proxy_pass http://localhost:${serverPort}; proxy_pass http://localhost:${serverPort};
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -102,8 +102,8 @@ server {
# API проксирование к backend через туннель # API проксирование к backend через туннель
location /api/ { location /api/ {
# Rate limiting для API (более строгое) # Rate limiting для API (отключено)
limit_req zone=api_limit_per_ip burst=100 nodelay; # limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://localhost:8000/api/; proxy_pass http://localhost:8000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -472,9 +472,9 @@ app.post('/tunnel/create', async (req, res) => {
await ssh.execCommand(\`\${installPackages} \${mailPackages}\`); await ssh.execCommand(\`\${installPackages} \${mailPackages}\`);
// Создание конфигурации NGINX с полной защитой // Создание конфигурации NGINX с полной защитой
const nginxConfig = \`# Rate limiting для защиты от DDoS const nginxConfig = \`# Rate limiting для защиты от DDoS (отключено - лимиты убраны)
limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s; # limit_req_zone $binary_remote_addr zone=req_limit_per_ip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s; # limit_req_zone $binary_remote_addr zone=api_limit_per_ip:10m rate=50r/s;
# Блокировка известных сканеров и вредоносных ботов # Блокировка известных сканеров и вредоносных ботов
map $http_user_agent $bad_bot { map $http_user_agent $bad_bot {
@@ -521,8 +521,8 @@ server {
# Основной location для фронтенда # Основной location для фронтенда
location / { location / {
# Rate limiting для основных страниц # Rate limiting для основных страниц (отключено)
limit_req zone=req_limit_per_ip burst=20 nodelay; # limit_req zone=req_limit_per_ip burst=20 nodelay;
proxy_pass http://localhost:\${serverPort}; proxy_pass http://localhost:\${serverPort};
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -541,8 +541,8 @@ server {
# API проксирование к backend через туннель # API проксирование к backend через туннель
location /api/ { location /api/ {
# Rate limiting для API (более строгое) # Rate limiting для API (отключено)
limit_req zone=api_limit_per_ip burst=100 nodelay; # limit_req zone=api_limit_per_ip burst=100 nodelay;
proxy_pass http://localhost:8000/api/; proxy_pass http://localhost:8000/api/;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -26,6 +26,15 @@
<p>{{ isEditMode ? 'Редактируйте существующую страницу' : 'Создайте новую страницу для вашего DLE' }}</p> <p>{{ isEditMode ? 'Редактируйте существующую страницу' : 'Создайте новую страницу для вашего DLE' }}</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button
v-if="isEditMode && canManageLegalDocs && address"
class="btn btn-danger"
@click="deletePage"
type="button"
>
<i class="fas fa-trash"></i>
Удалить
</button>
<button class="close-btn" @click="goBack">×</button> <button class="close-btn" @click="goBack">×</button>
</div> </div>
</div> </div>
@@ -279,6 +288,24 @@ function goBack() {
router.push({ name: 'content-list' }); router.push({ name: 'content-list' });
} }
async function deletePage() {
if (!isEditMode.value || !editId.value) {
return;
}
if (!confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить. Все связанные файлы также будут удалены.')) {
return;
}
try {
await pagesService.deletePage(editId.value);
router.push({ name: 'content-list' });
} catch (error) {
console.error('Ошибка удаления страницы:', error);
alert('Ошибка при удалении страницы: ' + (error?.response?.data?.error || error?.message || 'Неизвестная ошибка'));
}
}
function onFileChange(e) { function onFileChange(e) {
const f = e.target.files && e.target.files[0]; const f = e.target.files && e.target.files[0];
if (f) { if (f) {
@@ -432,8 +459,14 @@ async function handleSubmit() {
fd.append('status', form.value.status); fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings)); fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility); fd.append('visibility', form.value.visibility);
// Всегда отправляем required_permission:
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
// - Если visibility = internal, отправляем значение или пустую строку
if (form.value.visibility === 'internal' && form.value.requiredPermission) { if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim()); fd.append('required_permission', form.value.requiredPermission.trim());
} else {
// Явно устанавливаем пустое значение для public страниц
fd.append('required_permission', '');
} }
fd.append('format', form.value.format); fd.append('format', form.value.format);
if (fileBlob.value) { if (fileBlob.value) {
@@ -470,8 +503,14 @@ async function handleSubmit() {
fd.append('status', form.value.status); fd.append('status', form.value.status);
fd.append('settings', JSON.stringify(form.value.settings)); fd.append('settings', JSON.stringify(form.value.settings));
fd.append('visibility', form.value.visibility); fd.append('visibility', form.value.visibility);
// Всегда отправляем required_permission:
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
// - Если visibility = internal, отправляем значение или пустую строку
if (form.value.visibility === 'internal' && form.value.requiredPermission) { if (form.value.visibility === 'internal' && form.value.requiredPermission) {
fd.append('required_permission', form.value.requiredPermission.trim()); fd.append('required_permission', form.value.requiredPermission.trim());
} else {
// Явно устанавливаем пустое значение для public страниц
fd.append('required_permission', '');
} }
fd.append('format', form.value.format); fd.append('format', form.value.format);
fd.append('file', fileBlob.value); fd.append('file', fileBlob.value);

View File

@@ -112,11 +112,12 @@ const filtered = computed(() => {
} }
// Фильтр по правам доступа // Фильтр по правам доступа
// Если у документа нет required_permission, он доступен всем аутентифицированным пользователям
if (!p.required_permission) { if (!p.required_permission) {
return false; // Документ без прав не показываем return true; // Документ без прав доступен всем
} }
// Проверяем права пользователя // Проверяем права пользователя для документов с указанными правами
if (p.required_permission === PERMISSIONS.VIEW_BASIC_DOCS) { if (p.required_permission === PERMISSIONS.VIEW_BASIC_DOCS) {
return hasPermission(PERMISSIONS.VIEW_BASIC_DOCS); return hasPermission(PERMISSIONS.VIEW_BASIC_DOCS);
} }
@@ -129,7 +130,8 @@ const filtered = computed(() => {
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS); return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
} }
return false; // Если required_permission указан, но не распознан, показываем документ
return true;
}); });
}); });

View File

@@ -39,10 +39,14 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<button v-if="canEditData && address" class="btn btn-outline" @click="goToEdit"> <button v-if="canEditPage" class="btn btn-outline" @click="goToEdit">
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
Редактировать Редактировать
</button> </button>
<button v-if="canManageLegalDocs && address" class="btn btn-danger" @click="deletePage" type="button">
<i class="fas fa-trash"></i>
Удалить
</button>
<button class="close-btn" @click="goBack">×</button> <button class="close-btn" @click="goBack">×</button>
</div> </div>
</div> </div>
@@ -147,13 +151,14 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue'; import { ref, onMounted, watch, nextTick, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService'; import pagesService from '../../services/pagesService';
import api from '../../api/axios'; import api from '../../api/axios';
import { useAuthContext } from '../../composables/useAuth'; import { useAuthContext } from '../../composables/useAuth';
import { usePermissions } from '../../composables/usePermissions'; import { usePermissions } from '../../composables/usePermissions';
import { PERMISSIONS } from '../../composables/permissions';
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -184,9 +189,31 @@ const router = useRouter();
// Состояние // Состояние
const page = ref(null); const page = ref(null);
const { address } = useAuthContext(); const { address } = useAuthContext();
const { canEditData } = usePermissions(); const { canEditData, hasPermission } = usePermissions();
const canManageLegalDocs = computed(() => {
try {
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
} catch (e) {
console.error('[PageView] Ошибка проверки прав MANAGE_LEGAL_DOCS:', e);
return false;
}
});
const isLoading = ref(false); const isLoading = ref(false);
// Удалять может ТОЛЬКО редактор (MANAGE_LEGAL_DOCS)
const canDeletePage = computed(() => {
const hasPermission = canManageLegalDocs.value;
const hasAddress = !!address.value;
console.log('[PageView] canDeletePage проверка:', { hasPermission, hasAddress, address: address.value });
return hasPermission && hasAddress;
});
// Редактировать может редактор или пользователь с правом редактирования
const canEditPage = computed(() => {
if (!address.value) return false;
return canManageLegalDocs.value || canEditData.value;
});
// Методы // Методы
function goToEdit() { function goToEdit() {
router.push({ name: 'content-create', query: { edit: route.params.id } }); router.push({ name: 'content-create', query: { edit: route.params.id } });
@@ -202,14 +229,22 @@ async function reindex() {
} }
async function deletePage() { async function deletePage() {
if (confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить.')) { // Дополнительная проверка прав на стороне клиента
if (!canManageLegalDocs.value) {
alert('У вас нет прав для удаления страниц. Требуются права редактора.');
return;
}
if (!confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить. Все связанные файлы также будут удалены.')) {
return;
}
try { try {
await pagesService.deletePage(route.params.id); await pagesService.deletePage(route.params.id);
router.push({ name: 'content-list' }); router.push({ name: 'content-list' });
} catch (error) { } catch (error) {
console.error('Ошибка удаления страницы:', error); console.error('Ошибка удаления страницы:', error);
alert('Ошибка при удалении страницы'); alert('Ошибка при удалении страницы: ' + (error?.response?.data?.error || error?.message || 'Неизвестная ошибка'));
}
} }
} }
@@ -281,9 +316,57 @@ async function loadPage() {
} }
} }
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.page-content video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
// Загрузка данных // Загрузка данных
onMounted(() => { onMounted(() => {
loadPage(); loadPage();
setupVideoErrorHandlers();
}); });
</script> </script>

View File

@@ -106,7 +106,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed, watch, nextTick } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import BaseLayout from '../../components/BaseLayout.vue'; import BaseLayout from '../../components/BaseLayout.vue';
import pagesService from '../../services/pagesService'; import pagesService from '../../services/pagesService';
@@ -199,9 +199,57 @@ async function loadPage() {
} }
} }
// Обработка ошибок загрузки видео
function setupVideoErrorHandlers() {
nextTick(() => {
const videoElements = document.querySelectorAll('.content-block video, .page-content video');
videoElements.forEach((video) => {
video.addEventListener('error', (e) => {
console.error('Ошибка загрузки видео:', e);
const error = e.target.error;
let errorMessage = 'Неизвестная ошибка';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Загрузка видео была прервана';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Ошибка сети при загрузке видео';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Ошибка декодирования видео';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Формат видео не поддерживается';
break;
default:
errorMessage = `Ошибка загрузки видео (код: ${error.code})`;
}
}
// Показываем сообщение об ошибке вместо видео
const errorDiv = document.createElement('div');
errorDiv.className = 'video-error';
errorDiv.style.cssText = 'padding: 20px; background: #fee; border: 1px solid #fcc; border-radius: 8px; margin: 1.5rem 0; color: #c33;';
errorDiv.textContent = `${errorMessage}`;
video.parentNode?.replaceChild(errorDiv, video);
});
});
});
}
// Отслеживание изменений контента для добавления обработчиков ошибок
watch(() => page.value?.content, () => {
if (page.value?.content) {
setupVideoErrorHandlers();
}
});
// Загрузка данных // Загрузка данных
onMounted(() => { onMounted(() => {
loadPage(); loadPage();
setupVideoErrorHandlers();
}); });
</script> </script>