ваше сообщение коммита
This commit is contained in:
@@ -9,9 +9,9 @@ http {
|
||||
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
|
||||
client_max_body_size 0;
|
||||
|
||||
# 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=api_limit_per_ip:10m rate=50r/s;
|
||||
# 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=api_limit_per_ip:10m rate=50r/s;
|
||||
|
||||
# HTTP сервер для локальной разработки (БЕЗ SSL)
|
||||
server {
|
||||
@@ -30,8 +30,8 @@ http {
|
||||
|
||||
# Основной location
|
||||
location / {
|
||||
# Rate limiting для основных страниц
|
||||
limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
# Rate limiting для основных страниц (отключено)
|
||||
# limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -59,8 +59,8 @@ http {
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
# Rate limiting для API (более строгое)
|
||||
limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
# Rate limiting для API (отключено)
|
||||
# limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
|
||||
proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
|
||||
proxy_connect_timeout 120s;
|
||||
|
||||
@@ -9,9 +9,9 @@ http {
|
||||
# Убираем ограничение по размеру загружаемых файлов (база данных масштабируется)
|
||||
client_max_body_size 0;
|
||||
|
||||
# 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=api_limit_per_ip:10m rate=5r/s;
|
||||
# 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=api_limit_per_ip:10m rate=5r/s;
|
||||
|
||||
# Блокировка известных сканеров и вредоносных ботов
|
||||
map $http_user_agent $bad_bot {
|
||||
@@ -108,8 +108,8 @@ http {
|
||||
|
||||
# Основной location
|
||||
location / {
|
||||
# Rate limiting для основных страниц
|
||||
limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
# Rate limiting для основных страниц (отключено)
|
||||
# limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
@@ -134,8 +134,8 @@ http {
|
||||
|
||||
# API
|
||||
location /api/ {
|
||||
# Rate limiting для API (более строгое)
|
||||
limit_req zone=api_limit_per_ip burst=10 nodelay;
|
||||
# Rate limiting для API (отключено)
|
||||
# limit_req zone=api_limit_per_ip burst=10 nodelay;
|
||||
|
||||
proxy_pass http://${BACKEND_CONTAINER}:8000/api/;
|
||||
proxy_connect_timeout 120s;
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
@@ -438,10 +438,58 @@ watch(() => props.pageId, (newId, oldId) => {
|
||||
}
|
||||
}, { 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(() => {
|
||||
if (props.pageId) {
|
||||
loadPage();
|
||||
}
|
||||
setupVideoErrorHandlers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -24,19 +24,19 @@ import api from '../../api/axios';
|
||||
|
||||
// Функция для загрузки и регистрации модуля изменения размера изображений
|
||||
async function loadImageResizeModule() {
|
||||
try {
|
||||
try {
|
||||
// Используем динамический импорт для совместимости с Vite
|
||||
const module = await import('quill-image-resize-module');
|
||||
const ImageResize = module.default || module.ImageResize || module;
|
||||
if (ImageResize && typeof ImageResize === 'function') {
|
||||
Quill.register('modules/imageResize', ImageResize);
|
||||
Quill.register('modules/imageResize', ImageResize);
|
||||
return true;
|
||||
} else if (ImageResize && ImageResize.default && typeof ImageResize.default === 'function') {
|
||||
Quill.register('modules/imageResize', ImageResize.default);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
|
||||
} catch (error) {
|
||||
console.warn('[RichTextEditor] Не удалось загрузить модуль изменения размера изображений:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -81,22 +81,22 @@ onMounted(async () => {
|
||||
|
||||
// Конфигурация модулей
|
||||
const modulesConfig = {
|
||||
toolbar: {
|
||||
container: toolbarOptions,
|
||||
handlers: {
|
||||
'image': handleImageClick,
|
||||
'video': handleVideoClick
|
||||
}
|
||||
toolbar: {
|
||||
container: toolbarOptions,
|
||||
handlers: {
|
||||
'image': handleImageClick,
|
||||
'video': handleVideoClick
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем imageResize только если модуль загружен
|
||||
if (imageResizeLoaded) {
|
||||
modulesConfig.imageResize = {
|
||||
parchment: Quill.import('parchment'),
|
||||
modules: ['Resize', 'DisplaySize', 'Toolbar']
|
||||
parchment: Quill.import('parchment'),
|
||||
modules: ['Resize', 'DisplaySize', 'Toolbar']
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация Quill
|
||||
quill = new Quill(editorContainer.value, {
|
||||
@@ -108,12 +108,21 @@ onMounted(async () => {
|
||||
// Устанавливаем начальное значение
|
||||
if (props.modelValue) {
|
||||
quill.root.innerHTML = props.modelValue;
|
||||
// Обрабатываем существующие видео и настраиваем обработчики
|
||||
setTimeout(() => {
|
||||
wrapExistingVideos();
|
||||
setupVideoLoadingHandlers();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Слушаем изменения
|
||||
quill.on('text-change', () => {
|
||||
const html = quill.root.innerHTML;
|
||||
emit('update:modelValue', html);
|
||||
// Настраиваем обработчики для новых видео после изменений
|
||||
setTimeout(() => {
|
||||
setupVideoLoadingHandlers();
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -121,9 +130,46 @@ onMounted(async () => {
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (quill && 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() {
|
||||
const input = document.createElement('input');
|
||||
@@ -188,7 +234,48 @@ function handleImageClick() {
|
||||
response: error.response?.data,
|
||||
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');
|
||||
|
||||
if (isLocalFile) {
|
||||
// Для локальных файлов вставляем тег <video> напрямую (best practice)
|
||||
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>`;
|
||||
// Для локальных файлов вставляем тег <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);
|
||||
|
||||
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
|
||||
setupVideoLoadingHandlers();
|
||||
setTimeout(() => {
|
||||
setupVideoLoadingHandlers();
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
setupVideoLoadingHandlers();
|
||||
}, 200);
|
||||
} else {
|
||||
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill
|
||||
quill.insertEmbed(range.index, 'video', fullUrl);
|
||||
@@ -269,9 +365,113 @@ function handleVideoClick() {
|
||||
console.error('[RichTextEditor] Детали ошибки:', {
|
||||
message: error.message,
|
||||
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 {
|
||||
@@ -288,9 +488,18 @@ function handleVideoClick() {
|
||||
const isLocalFile = url.includes('/api/uploads/media/') && url.includes('/file');
|
||||
|
||||
if (isLocalFile) {
|
||||
// Для локальных файлов вставляем тег <video> напрямую (best practice)
|
||||
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>`;
|
||||
// Для локальных файлов вставляем тег <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);
|
||||
|
||||
// Настраиваем обработчики событий для видео после вставки (сразу и с задержкой для надежности)
|
||||
setupVideoLoadingHandlers();
|
||||
setTimeout(() => {
|
||||
setupVideoLoadingHandlers();
|
||||
}, 50);
|
||||
setTimeout(() => {
|
||||
setupVideoLoadingHandlers();
|
||||
}, 200);
|
||||
} else {
|
||||
// Для внешних URL (YouTube, Vimeo) используем iframe через Quill
|
||||
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(() => {
|
||||
if (quill) {
|
||||
quill = null;
|
||||
@@ -455,5 +745,66 @@ defineExpose({
|
||||
cursor: nwse-resize;
|
||||
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>
|
||||
|
||||
|
||||
@@ -27,10 +27,11 @@ export default {
|
||||
const res = await api.get(`/pages/${id}`);
|
||||
return res.data;
|
||||
},
|
||||
async updatePage(id, data) {
|
||||
console.log('[pagesService] updatePage:', { id, data });
|
||||
async updatePage(id, data, isFormData = false) {
|
||||
console.log('[pagesService] updatePage:', { id, isFormData });
|
||||
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);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,9 +33,9 @@ const normalizeDomainToAscii = (domain) => {
|
||||
|
||||
// Функция для генерации nginx конфигурации
|
||||
function getNginxConfig(domain, serverPort) {
|
||||
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=api_limit_per_ip:10m rate=50r/s;
|
||||
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=api_limit_per_ip:10m rate=50r/s;
|
||||
|
||||
# Блокировка известных сканеров и вредоносных ботов
|
||||
map $http_user_agent $bad_bot {
|
||||
@@ -82,8 +82,8 @@ server {
|
||||
|
||||
# Основной location для фронтенда
|
||||
location / {
|
||||
# Rate limiting для основных страниц
|
||||
limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
# Rate limiting для основных страниц (отключено)
|
||||
# limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
|
||||
proxy_pass http://localhost:${serverPort};
|
||||
proxy_set_header Host $host;
|
||||
@@ -102,8 +102,8 @@ server {
|
||||
|
||||
# API проксирование к backend через туннель
|
||||
location /api/ {
|
||||
# Rate limiting для API (более строгое)
|
||||
limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
# Rate limiting для API (отключено)
|
||||
# limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
|
||||
proxy_pass http://localhost:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
@@ -472,9 +472,9 @@ app.post('/tunnel/create', async (req, res) => {
|
||||
await ssh.execCommand(\`\${installPackages} \${mailPackages}\`);
|
||||
|
||||
// Создание конфигурации NGINX с полной защитой
|
||||
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=api_limit_per_ip:10m rate=50r/s;
|
||||
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=api_limit_per_ip:10m rate=50r/s;
|
||||
|
||||
# Блокировка известных сканеров и вредоносных ботов
|
||||
map $http_user_agent $bad_bot {
|
||||
@@ -521,8 +521,8 @@ server {
|
||||
|
||||
# Основной location для фронтенда
|
||||
location / {
|
||||
# Rate limiting для основных страниц
|
||||
limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
# Rate limiting для основных страниц (отключено)
|
||||
# limit_req zone=req_limit_per_ip burst=20 nodelay;
|
||||
|
||||
proxy_pass http://localhost:\${serverPort};
|
||||
proxy_set_header Host $host;
|
||||
@@ -541,8 +541,8 @@ server {
|
||||
|
||||
# API проксирование к backend через туннель
|
||||
location /api/ {
|
||||
# Rate limiting для API (более строгое)
|
||||
limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
# Rate limiting для API (отключено)
|
||||
# limit_req zone=api_limit_per_ip burst=100 nodelay;
|
||||
|
||||
proxy_pass http://localhost:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
@@ -26,6 +26,15 @@
|
||||
<p>{{ isEditMode ? 'Редактируйте существующую страницу' : 'Создайте новую страницу для вашего DLE' }}</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +288,24 @@ function goBack() {
|
||||
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) {
|
||||
const f = e.target.files && e.target.files[0];
|
||||
if (f) {
|
||||
@@ -432,8 +459,14 @@ async function handleSubmit() {
|
||||
fd.append('status', form.value.status);
|
||||
fd.append('settings', JSON.stringify(form.value.settings));
|
||||
fd.append('visibility', form.value.visibility);
|
||||
// Всегда отправляем required_permission:
|
||||
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
|
||||
// - Если visibility = internal, отправляем значение или пустую строку
|
||||
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
|
||||
fd.append('required_permission', form.value.requiredPermission.trim());
|
||||
} else {
|
||||
// Явно устанавливаем пустое значение для public страниц
|
||||
fd.append('required_permission', '');
|
||||
}
|
||||
fd.append('format', form.value.format);
|
||||
if (fileBlob.value) {
|
||||
@@ -470,8 +503,14 @@ async function handleSubmit() {
|
||||
fd.append('status', form.value.status);
|
||||
fd.append('settings', JSON.stringify(form.value.settings));
|
||||
fd.append('visibility', form.value.visibility);
|
||||
// Всегда отправляем required_permission:
|
||||
// - Если visibility = public, отправляем пустую строку (будет установлен null на бэкенде)
|
||||
// - Если visibility = internal, отправляем значение или пустую строку
|
||||
if (form.value.visibility === 'internal' && form.value.requiredPermission) {
|
||||
fd.append('required_permission', form.value.requiredPermission.trim());
|
||||
} else {
|
||||
// Явно устанавливаем пустое значение для public страниц
|
||||
fd.append('required_permission', '');
|
||||
}
|
||||
fd.append('format', form.value.format);
|
||||
fd.append('file', fileBlob.value);
|
||||
|
||||
@@ -112,11 +112,12 @@ const filtered = computed(() => {
|
||||
}
|
||||
|
||||
// Фильтр по правам доступа
|
||||
// Если у документа нет required_permission, он доступен всем аутентифицированным пользователям
|
||||
if (!p.required_permission) {
|
||||
return false; // Документ без прав не показываем
|
||||
return true; // Документ без прав доступен всем
|
||||
}
|
||||
|
||||
// Проверяем права пользователя
|
||||
// Проверяем права пользователя для документов с указанными правами
|
||||
if (p.required_permission === PERMISSIONS.VIEW_BASIC_DOCS) {
|
||||
return hasPermission(PERMISSIONS.VIEW_BASIC_DOCS);
|
||||
}
|
||||
@@ -129,7 +130,8 @@ const filtered = computed(() => {
|
||||
return hasPermission(PERMISSIONS.MANAGE_LEGAL_DOCS);
|
||||
}
|
||||
|
||||
return false;
|
||||
// Если required_permission указан, но не распознан, показываем документ
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -39,10 +39,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
Редактировать
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,13 +151,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, watch, nextTick, computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import BaseLayout from '../../components/BaseLayout.vue';
|
||||
import pagesService from '../../services/pagesService';
|
||||
import api from '../../api/axios';
|
||||
import { useAuthContext } from '../../composables/useAuth';
|
||||
import { usePermissions } from '../../composables/usePermissions';
|
||||
import { PERMISSIONS } from '../../composables/permissions';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -184,9 +189,31 @@ const router = useRouter();
|
||||
// Состояние
|
||||
const page = ref(null);
|
||||
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);
|
||||
|
||||
// Удалять может ТОЛЬКО редактор (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() {
|
||||
router.push({ name: 'content-create', query: { edit: route.params.id } });
|
||||
@@ -202,14 +229,22 @@ async function reindex() {
|
||||
}
|
||||
|
||||
async function deletePage() {
|
||||
if (confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить.')) {
|
||||
// Дополнительная проверка прав на стороне клиента
|
||||
if (!canManageLegalDocs.value) {
|
||||
alert('У вас нет прав для удаления страниц. Требуются права редактора.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Вы уверены, что хотите удалить эту страницу? Это действие нельзя отменить. Все связанные файлы также будут удалены.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await pagesService.deletePage(route.params.id);
|
||||
router.push({ name: 'content-list' });
|
||||
} catch (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(() => {
|
||||
loadPage();
|
||||
setupVideoErrorHandlers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import BaseLayout from '../../components/BaseLayout.vue';
|
||||
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(() => {
|
||||
loadPage();
|
||||
setupVideoErrorHandlers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user