Добавление и Удаление сайта

Backend (Go):
- Добавлен полный функционал создания сайтов
- Добавлен функционал удаления сайтов
- Новые API методы в admin.go:
- Добавлен шаблон стартовой страницы
- Добавлена функция DecodeBase64

Исправления критических ошибок:
- Исправлена работа wildcard алиасов (*.domain.com) в handler.go
- Исправлены ошибки "файл не найден" при создании файлов

Frontend (JavaScript + HTML + CSS):
- Добавлена страница создания сайта
- Добавлена кнопка "Удалить сайт" в редактировании
- Мелкие доработки стилей

Build:
- Обновлён build_admin.ps1 - добавлен шаг генерации биндингов (wails generate module)

Fixes:
- #fix Wildcard алиасы (*.domain.com) теперь работают корректно
- #fix Удалён порт из host при проверке алиасов
- #fix Приоритет точных доменов над wildcard
- #fix Ошибки "файл не найден" при создании сайтов/vAccess
- #fix Секция добавления сайта теперь скрывается при навигации
This commit is contained in:
2025-11-14 14:18:26 +07:00
parent 0ed6a6007d
commit 4b13923375
22 changed files with 1823 additions and 57 deletions

View File

@@ -136,6 +136,58 @@ class WailsAPI {
log(`Ошибка открытия папки: ${error.message}`, 'error');
}
}
/**
* Создать новый сайт
*/
async createNewSite(siteJSON) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.CreateNewSite(siteJSON);
} catch (error) {
log(`Ошибка создания сайта: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Загрузить сертификат для сайта
*/
async uploadCertificate(host, certType, certDataBase64) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.UploadCertificate(host, certType, certDataBase64);
} catch (error) {
log(`Ошибка загрузки сертификата: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Перезагрузить SSL сертификаты
*/
async reloadSSLCertificates() {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.ReloadSSLCertificates();
} catch (error) {
log(`Ошибка перезагрузки сертификатов: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
/**
* Удалить сайт
*/
async deleteSite(host) {
if (!this.checkAvailability()) return 'Error: API недоступен';
try {
return await window.go.admin.App.DeleteSite(host);
} catch (error) {
log(`Ошибка удаления сайта: ${error.message}`, 'error');
return `Error: ${error.message}`;
}
}
}
// Экспортируем единственный экземпляр

View File

@@ -0,0 +1,393 @@
/* ============================================
Site Creator Component
Управление созданием новых сайтов
============================================ */
import { api } from '../api/wails.js';
import { configAPI } from '../api/config.js';
import { $, hide, show } from '../utils/dom.js';
import { notification } from '../ui/notification.js';
import { isWailsAvailable } from '../utils/helpers.js';
import { initCustomSelects } from '../ui/custom-select.js';
/**
* Класс для создания новых сайтов
*/
export class SiteCreator {
constructor() {
this.aliases = [];
this.certificates = {
certificate: null,
privatekey: null,
cabundle: null
};
}
/**
* Открыть страницу создания сайта
*/
open() {
// Скрываем все секции
this.hideAllSections();
// Показываем страницу создания
show($('sectionAddSite'));
// Очищаем форму
this.resetForm();
// Привязываем обработчики
this.attachEventListeners();
// Инициализируем кастомные select'ы
setTimeout(() => initCustomSelects(), 100);
}
/**
* Скрыть все секции
*/
hideAllSections() {
hide($('sectionServices'));
hide($('sectionSites'));
hide($('sectionProxy'));
hide($('sectionSettings'));
hide($('sectionVAccessEditor'));
hide($('sectionAddSite'));
}
/**
* Вернуться на главную
*/
backToMain() {
this.hideAllSections();
show($('sectionServices'));
show($('sectionSites'));
show($('sectionProxy'));
}
/**
* Очистить форму
*/
resetForm() {
$('newSiteName').value = '';
$('newSiteHost').value = '';
$('newSiteAliasInput').value = '';
$('newSiteRootFile').value = 'index.html';
$('newSiteStatus').value = 'active';
$('newSiteRouting').checked = true;
$('certMode').value = 'none';
this.aliases = [];
this.certificates = {
certificate: null,
privatekey: null,
cabundle: null
};
// Скрываем блок загрузки сертификатов
hide($('certUploadBlock'));
// Очищаем статусы файлов
$('certFileStatus').innerHTML = '';
$('keyFileStatus').innerHTML = '';
$('caFileStatus').innerHTML = '';
// Очищаем labels файлов
if ($('certFileName')) $('certFileName').textContent = 'Выберите файл...';
if ($('keyFileName')) $('keyFileName').textContent = 'Выберите файл...';
if ($('caFileName')) $('caFileName').textContent = 'Выберите файл...';
// Очищаем input файлов
if ($('certFile')) $('certFile').value = '';
if ($('keyFile')) $('keyFile').value = '';
if ($('caFile')) $('caFile').value = '';
// Убираем класс uploaded
const labels = document.querySelectorAll('.file-upload-btn');
labels.forEach(label => label.classList.remove('file-uploaded'));
}
/**
* Привязать обработчики событий
*/
attachEventListeners() {
const createBtn = $('createSiteBtn');
if (createBtn) {
createBtn.onclick = async () => await this.createSite();
}
// Drag & Drop для файлов сертификатов
this.setupDragAndDrop();
}
/**
* Настроить Drag & Drop для файлов
*/
setupDragAndDrop() {
const fileWrappers = [
{ wrapper: document.querySelector('label[for="certFile"]')?.parentElement, input: $('certFile'), type: 'certificate' },
{ wrapper: document.querySelector('label[for="keyFile"]')?.parentElement, input: $('keyFile'), type: 'privatekey' },
{ wrapper: document.querySelector('label[for="caFile"]')?.parentElement, input: $('caFile'), type: 'cabundle' }
];
fileWrappers.forEach(({ wrapper, input, type }) => {
if (!wrapper || !input) return;
// Предотвращаем стандартное поведение
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
wrapper.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
});
});
// Подсветка при наведении файла
['dragenter', 'dragover'].forEach(eventName => {
wrapper.addEventListener(eventName, () => {
wrapper.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
wrapper.addEventListener(eventName, () => {
wrapper.classList.remove('drag-over');
});
});
// Обработка dropped файла
wrapper.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
// Создаём объект DataTransfer и присваиваем файлы input'у
const dataTransfer = new DataTransfer();
dataTransfer.items.add(files[0]);
input.files = dataTransfer.files;
// Триггерим событие change
const event = new Event('change', { bubbles: true });
input.dispatchEvent(event);
}
});
});
}
/**
* Парсить aliases из строки (через запятую)
*/
parseAliases() {
const input = $('newSiteAliasInput');
const value = input?.value.trim();
if (!value) {
this.aliases = [];
return;
}
// Разделяем по запятой и очищаем
this.aliases = value
.split(',')
.map(alias => alias.trim())
.filter(alias => alias.length > 0);
}
/**
* Переключить видимость блока загрузки сертификатов
*/
toggleCertUpload() {
const mode = $('certMode')?.value;
const block = $('certUploadBlock');
if (mode === 'upload') {
show(block);
} else {
hide(block);
}
}
/**
* Обработать выбор файла сертификата
*/
handleCertFile(input, certType) {
const file = input.files[0];
const statusId = certType === 'certificate' ? 'certFileStatus' :
certType === 'privatekey' ? 'keyFileStatus' : 'caFileStatus';
const labelId = certType === 'certificate' ? 'certFileName' :
certType === 'privatekey' ? 'keyFileName' : 'caFileName';
const statusDiv = $(statusId);
const labelSpan = $(labelId);
const labelBtn = input.nextElementSibling; // label элемент
if (!file) {
this.certificates[certType] = null;
statusDiv.innerHTML = '';
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
return;
}
// Проверяем размер файла (макс 1MB)
if (file.size > 1024 * 1024) {
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Файл слишком большой (макс 1MB)</span>';
this.certificates[certType] = null;
input.value = '';
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
return;
}
// Обновляем UI
if (labelSpan) labelSpan.textContent = file.name;
if (labelBtn) labelBtn.classList.add('file-uploaded');
// Читаем файл
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target.result;
// Сохраняем как base64
this.certificates[certType] = btoa(content);
statusDiv.innerHTML = `<span style="color: #2ecc71;"><i class="fas fa-check-circle"></i> Загружен успешно</span>`;
};
reader.onerror = () => {
statusDiv.innerHTML = '<span style="color: #e74c3c;"><i class="fas fa-times-circle"></i> Ошибка чтения файла</span>';
this.certificates[certType] = null;
if (labelSpan) labelSpan.textContent = 'Выберите файл...';
if (labelBtn) labelBtn.classList.remove('file-uploaded');
};
reader.readAsText(file);
}
/**
* Валидация формы
*/
validateForm() {
const name = $('newSiteName')?.value.trim();
const host = $('newSiteHost')?.value.trim();
const rootFile = $('newSiteRootFile')?.value;
const certMode = $('certMode')?.value;
if (!name) {
notification.error('❌ Укажите название сайта');
return false;
}
if (!host) {
notification.error('❌ Укажите host (домен)');
return false;
}
if (!rootFile) {
notification.error('❌ Укажите root файл');
return false;
}
// Проверка сертификатов если режим загрузки
if (certMode === 'upload') {
if (!this.certificates.certificate) {
notification.error('❌ Загрузите файл certificate.crt');
return false;
}
if (!this.certificates.privatekey) {
notification.error('❌ Загрузите файл private.key');
return false;
}
}
return true;
}
/**
* Создать сайт
*/
async createSite() {
if (!this.validateForm()) {
return;
}
if (!isWailsAvailable()) {
notification.error('Wails API недоступен');
return;
}
const createBtn = $('createSiteBtn');
const originalText = createBtn.querySelector('span').textContent;
try {
createBtn.disabled = true;
createBtn.querySelector('span').textContent = 'Создание...';
// Парсим aliases из поля ввода
this.parseAliases();
// Собираем данные сайта
const siteData = {
name: $('newSiteName').value.trim(),
host: $('newSiteHost').value.trim(),
alias: this.aliases,
status: $('newSiteStatus').value,
root_file: $('newSiteRootFile').value,
root_file_routing: $('newSiteRouting').checked
};
// Создаём сайт
const siteJSON = JSON.stringify(siteData);
const result = await api.createNewSite(siteJSON);
if (result.startsWith('Error')) {
notification.error(result, 3000);
return;
}
notification.success('✅ Сайт успешно создан!', 1500);
// Загружаем сертификаты если нужно
const certMode = $('certMode').value;
if (certMode === 'upload') {
createBtn.querySelector('span').textContent = 'Загрузка сертификатов...';
// Загружаем certificate
if (this.certificates.certificate) {
await api.uploadCertificate(siteData.host, 'certificate', this.certificates.certificate);
}
// Загружаем private key
if (this.certificates.privatekey) {
await api.uploadCertificate(siteData.host, 'privatekey', this.certificates.privatekey);
}
// Загружаем ca bundle если есть
if (this.certificates.cabundle) {
await api.uploadCertificate(siteData.host, 'cabundle', this.certificates.cabundle);
}
notification.success('🔒 Сертификаты загружены!', 1500);
}
// Перезапускаем HTTP/HTTPS
createBtn.querySelector('span').textContent = 'Перезапуск серверов...';
await configAPI.stopHTTPService();
await configAPI.stopHTTPSService();
await new Promise(resolve => setTimeout(resolve, 500));
await configAPI.startHTTPService();
await configAPI.startHTTPSService();
notification.success('🚀 Серверы перезапущены! Сайт готов к работе!', 2000);
// Возвращаемся на главную
setTimeout(() => {
this.backToMain();
// Перезагружаем список сайтов
if (window.sitesManager) {
window.sitesManager.load();
}
}, 1000);
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
} finally {
createBtn.disabled = false;
createBtn.querySelector('span').textContent = originalText;
}
}
}

View File

@@ -12,7 +12,10 @@ import { ServicesManager } from './components/services.js';
import { SitesManager } from './components/sites.js';
import { ProxyManager } from './components/proxy.js';
import { VAccessManager } from './components/vaccess.js';
import { SiteCreator } from './components/site-creator.js';
import { api } from './api/wails.js';
import { configAPI } from './api/config.js';
import { initCustomSelects } from './ui/custom-select.js';
import { $ } from './utils/dom.js';
/**
@@ -26,6 +29,7 @@ class App {
this.sitesManager = new SitesManager();
this.proxyManager = new ProxyManager();
this.vAccessManager = new VAccessManager();
this.siteCreator = new SiteCreator();
this.isWails = isWailsAvailable();
@@ -69,6 +73,9 @@ class App {
// Привязываем кнопки
this.setupButtons();
// Инициализируем кастомные select'ы
initCustomSelects();
log('Приложение запущено');
}
@@ -96,6 +103,14 @@ class App {
* Привязать кнопки
*/
setupButtons() {
// Кнопка добавления сайта
const addSiteBtn = $('addSiteBtn');
if (addSiteBtn) {
addSiteBtn.addEventListener('click', () => {
this.siteCreator.open();
});
}
// Кнопка сохранения настроек
const saveSettingsBtn = $('saveSettingsBtn');
if (saveSettingsBtn) {
@@ -128,6 +143,23 @@ class App {
* Настроить глобальные обработчики
*/
setupGlobalHandlers() {
// Глобальная ссылка на sitesManager
window.sitesManager = this.sitesManager;
window.siteCreator = this.siteCreator;
// Для SiteCreator
window.backToMainFromAddSite = () => {
this.siteCreator.backToMain();
};
window.toggleCertUpload = () => {
this.siteCreator.toggleCertUpload();
};
window.handleCertFileSelect = (input, certType) => {
this.siteCreator.handleCertFile(input, certType);
};
// Для vAccess
window.editVAccess = (host, isProxy) => {
this.vAccessManager.open(host, isProxy);
@@ -248,6 +280,10 @@ class App {
window.openSiteFolder = async (host) => {
await this.sitesManager.handleAction('open-folder', { getAttribute: () => host });
};
window.deleteSiteConfirm = async () => {
await this.deleteSiteConfirm();
};
}
/**
@@ -382,6 +418,9 @@ class App {
modal.open('Редактировать сайт', content);
window.currentEditType = 'site';
window.currentEditIndex = index;
// Добавляем кнопку удаления в футер модального окна
this.addDeleteButtonToModal();
}
/**
@@ -446,6 +485,9 @@ class App {
modal.open('Редактировать прокси', content);
window.currentEditType = 'proxy';
window.currentEditIndex = index;
// Убираем кнопку удаления (для прокси не нужна)
this.removeDeleteButtonFromModal();
}
/**
@@ -574,6 +616,94 @@ class App {
modal.close();
}
}
/**
* Добавить кнопку удаления в модальное окно
*/
addDeleteButtonToModal() {
const footer = document.querySelector('.modal-footer');
if (!footer) return;
// Удаляем старую кнопку удаления если есть
const oldDeleteBtn = footer.querySelector('#modalDeleteBtn');
if (oldDeleteBtn) oldDeleteBtn.remove();
// Создаём кнопку удаления
const deleteBtn = document.createElement('button');
deleteBtn.className = 'action-btn delete-btn';
deleteBtn.id = 'modalDeleteBtn';
deleteBtn.innerHTML = `
<i class="fas fa-trash"></i>
<span>Удалить сайт</span>
`;
deleteBtn.onclick = () => this.deleteSiteConfirm();
// Вставляем перед кнопкой "Отмена"
const cancelBtn = footer.querySelector('#modalCancelBtn');
if (cancelBtn) {
footer.insertBefore(deleteBtn, cancelBtn);
}
}
/**
* Удалить кнопку удаления из модального окна
*/
removeDeleteButtonFromModal() {
const deleteBtn = document.querySelector('#modalDeleteBtn');
if (deleteBtn) deleteBtn.remove();
}
/**
* Подтверждение удаления сайта
*/
async deleteSiteConfirm() {
const index = window.currentEditIndex;
const site = this.sitesManager.sitesData[index];
if (!site) return;
// Подтверждение
const confirmed = confirm(
`⚠️ ВНИМАНИЕ!\n\n` +
`Вы действительно хотите удалить сайт "${site.name}" (${site.host})?\n\n` +
`Будут удалены:\n` +
`• Папка сайта: WebServer/www/${site.host}/\n` +
`• SSL сертификаты (если есть)\n` +
`• Запись в конфигурации\n\n` +
`Это действие НЕОБРАТИМО!`
);
if (!confirmed) return;
try {
notification.show('Удаление сайта...', 'info', 1000);
const result = await api.deleteSite(site.host);
if (result.startsWith('Error')) {
notification.error(result, 3000);
return;
}
notification.success('✅ Сайт успешно удалён!', 1500);
// Перезапускаем HTTP/HTTPS
notification.show('Перезапуск серверов...', 'success', 800);
await configAPI.stopHTTPService();
await configAPI.stopHTTPSService();
await sleep(500);
await configAPI.startHTTPService();
await configAPI.startHTTPSService();
notification.success('🚀 Серверы перезапущены!', 1000);
// Закрываем модальное окно и обновляем список
modal.close();
await this.sitesManager.load();
} catch (error) {
notification.error('Ошибка: ' + error.message, 3000);
}
}
}
// Инициализация приложения при загрузке DOM

View File

@@ -0,0 +1,140 @@
/* ============================================
Custom Select Component
Кастомные выпадающие списки
============================================ */
import { $ } from '../utils/dom.js';
/**
* Инициализация всех кастомных select'ов на странице
*/
export function initCustomSelects() {
const selects = document.querySelectorAll('select.form-input');
selects.forEach(select => {
if (!select.dataset.customized) {
createCustomSelect(select);
}
});
}
/**
* Создать кастомный select из нативного
*/
function createCustomSelect(selectElement) {
// Помечаем как обработанный
selectElement.dataset.customized = 'true';
// Создаём контейнер
const wrapper = document.createElement('div');
wrapper.className = 'custom-select';
// Получаем выбранное значение
const selectedOption = selectElement.options[selectElement.selectedIndex];
const selectedText = selectedOption ? selectedOption.text : '';
// Создаём кнопку (видимая часть)
const button = document.createElement('div');
button.className = 'custom-select-trigger';
button.innerHTML = `
<span class="custom-select-value">${selectedText}</span>
<i class="fas fa-chevron-down custom-select-arrow"></i>
`;
// Создаём выпадающий список
const dropdown = document.createElement('div');
dropdown.className = 'custom-select-dropdown';
// Заполняем опции
Array.from(selectElement.options).forEach((option, index) => {
const item = document.createElement('div');
item.className = 'custom-select-option';
item.textContent = option.text;
item.dataset.value = option.value;
item.dataset.index = index;
if (option.selected) {
item.classList.add('selected');
}
// Клик по опции
item.addEventListener('click', () => {
selectOption(selectElement, wrapper, item, index);
});
dropdown.appendChild(item);
});
// Клик по кнопке - открыть/закрыть
button.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown(wrapper);
});
// Собираем вместе
wrapper.appendChild(button);
wrapper.appendChild(dropdown);
// Скрываем оригинальный select
selectElement.style.display = 'none';
// Вставляем кастомный select после оригинального
selectElement.parentNode.insertBefore(wrapper, selectElement.nextSibling);
// Закрываем при клике вне
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
closeDropdown(wrapper);
}
});
}
/**
* Открыть/закрыть dropdown
*/
function toggleDropdown(wrapper) {
const isOpen = wrapper.classList.contains('open');
// Закрываем все открытые
document.querySelectorAll('.custom-select.open').forEach(el => {
el.classList.remove('open');
});
if (!isOpen) {
wrapper.classList.add('open');
}
}
/**
* Закрыть dropdown
*/
function closeDropdown(wrapper) {
wrapper.classList.remove('open');
}
/**
* Выбрать опцию
*/
function selectOption(selectElement, wrapper, optionElement, index) {
// Обновляем оригинальный select
selectElement.selectedIndex = index;
// Триггерим событие change
const event = new Event('change', { bubbles: true });
selectElement.dispatchEvent(event);
// Обновляем UI
const valueSpan = wrapper.querySelector('.custom-select-value');
valueSpan.textContent = optionElement.textContent;
// Убираем selected у всех опций
wrapper.querySelectorAll('.custom-select-option').forEach(opt => {
opt.classList.remove('selected');
});
// Добавляем selected к выбранной
optionElement.classList.add('selected');
// Закрываем dropdown
closeDropdown(wrapper);
}

View File

@@ -16,7 +16,8 @@ export class Navigation {
sites: $('sectionSites'),
proxy: $('sectionProxy'),
settings: $('sectionSettings'),
vaccess: $('sectionVAccessEditor')
vaccess: $('sectionVAccessEditor'),
addSite: $('sectionAddSite')
};
this.init();
}