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

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

@@ -42,28 +42,15 @@ func matchWildcardAlias(pattern, host string) bool {
}
// Поддержка wildcard: *.example.com, example.*, *example*, *
// Заменяем * на регулярное выражение
pattern = strings.ReplaceAll(pattern, ".", "\\.")
pattern = strings.ReplaceAll(pattern, "*", ".*")
pattern = "^" + pattern + "$"
// Паттерн *.example.com -> должен совпадать с sub.example.com
// Простая проверка без regexp (более быстрая)
return matchSimplePattern(pattern, host)
}
// Простая проверка паттерна (без использования regexp для скорости)
func matchSimplePattern(pattern, host string) bool {
// Убираем ^ и $ добавленные выше
pattern = strings.TrimPrefix(pattern, "^")
pattern = strings.TrimSuffix(pattern, "$")
// Если паттерн = .* (любой хост)
if pattern == ".*" {
// Если паттерн = * (любой хост)
if pattern == "*" {
return true
}
// Разбиваем паттерн на части по .*
parts := strings.Split(pattern, ".*")
// Разбиваем паттерн на части по звёздочке
parts := strings.Split(pattern, "*")
// Проверяем каждую часть
currentPos := 0
@@ -79,14 +66,14 @@ func matchSimplePattern(pattern, host string) bool {
}
// Для первой части проверяем что она в начале (если паттерн не начинается с *)
if i == 0 && !strings.HasPrefix(pattern, ".*") {
if i == 0 && !strings.HasPrefix(pattern, "*") {
if idx != 0 {
return false
}
}
// Для последней части проверяем что она в конце (если паттерн не кончается на *)
if i == len(parts)-1 && !strings.HasSuffix(pattern, ".*") {
if i == len(parts)-1 && !strings.HasSuffix(pattern, "*") {
if currentPos+idx+len(part) != len(host) {
return false
}
@@ -98,40 +85,41 @@ func matchSimplePattern(pattern, host string) bool {
return true
}
func Alias_check(r *http.Request) (alias_found bool, host string) {
alias_found = false
for _, site := range config.ConfigData.Site_www {
for _, alias := range site.Alias {
// Поддержка wildcard паттернов
if matchWildcardAlias(alias, r.Host) {
alias_found = true
return alias_found, site.Host
} else {
alias_found = false
}
}
}
return alias_found, ""
}
func Alias_Run(r *http.Request) (rhost string) {
requestHost := r.Host
var host string
host = r.Host
alias_check, alias := Alias_check(r)
if alias_check {
host = alias
// Убираем порт если есть (например :80 или :443)
if colonIndex := strings.Index(requestHost, ":"); colonIndex != -1 {
requestHost = requestHost[:colonIndex]
}
return host
// Приоритет 1: Проверяем точное совпадение с site.Host
for _, site := range config.ConfigData.Site_www {
if site.Host == requestHost {
return site.Host
}
}
// Приоритет 2: Проверяем точные alias (без wildcard)
for _, site := range config.ConfigData.Site_www {
for _, alias := range site.Alias {
if !strings.Contains(alias, "*") && alias == requestHost {
return site.Host
}
}
}
// Приоритет 3: Проверяем wildcard alias
for _, site := range config.ConfigData.Site_www {
for _, alias := range site.Alias {
if strings.Contains(alias, "*") && matchWildcardAlias(alias, requestHost) {
return site.Host
}
}
}
// Не нашли совпадений - возвращаем как есть
return requestHost
}
// Получает список root_file для сайта из конфигурации

View File

@@ -62,6 +62,19 @@
}
}
/* Delete Button Variant */
.delete-btn {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
color: var(--accent-red);
&:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.2);
}
}
/* Icon Button - Квадратная кнопка с иконкой */
.icon-btn {
width: 32px;

View File

@@ -91,11 +91,15 @@
.modal-footer {
display: flex;
justify-content: flex-end;
gap: var(--space-lg);
gap: 8px;
padding: 20px var(--space-lg);
border-top: 1px solid var(--glass-border);
}
.modal-footer .action-btn {
margin: 0;
}
/* Notification Modal */
.notification {
position: fixed;

View File

@@ -48,7 +48,7 @@
/* Footer */
.footer {
margin-top: var(--space-3xl);
margin-top: var(--space-lg);
padding: 20px;
text-align: center;
color: var(--text-muted);

View File

@@ -23,4 +23,5 @@
/* 4. Pages */
@import 'pages/dashboard.css';
@import 'pages/vaccess.css';
@import 'pages/site-creator.css';

View File

@@ -0,0 +1,272 @@
/* ============================================
Site Creator Page
Страница создания нового сайта
============================================ */
/* Form Section */
.form-section {
padding: var(--space-xl);
background: rgba(139, 92, 246, 0.02);
border-radius: var(--radius-xl);
border: 1px solid var(--glass-border);
transition: all var(--transition-base);
}
.form-section:hover {
background: rgba(139, 92, 246, 0.04);
border-color: rgba(139, 92, 246, 0.2);
}
/* Subsection Title (внутри формы) */
.form-subsection-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 var(--space-lg) 0;
display: flex;
align-items: center;
gap: var(--space-md);
padding-bottom: var(--space-sm);
border-bottom: 1px solid rgba(139, 92, 246, 0.1);
i {
color: var(--accent-purple-light);
font-size: 16px;
}
}
/* Второй и последующие подзаголовки - добавляем отступ сверху */
.form-subsection-title:not(:first-of-type) {
margin-top: var(--space-xl);
}
/* Custom Select Styling */
.form-input[type="text"],
.form-input[type="number"] {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-image: none;
}
/* Кастомный Select */
.custom-select {
position: relative;
width: 100%;
}
.custom-select-trigger {
padding: 10px 40px 10px 14px;
background: var(--glass-bg-dark);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-size: var(--text-base);
cursor: pointer;
transition: all var(--transition-base);
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.custom-select-trigger:hover {
border-color: rgba(139, 92, 246, 0.4);
background-color: rgba(139, 92, 246, 0.05);
}
.custom-select.open .custom-select-trigger {
border-color: rgba(139, 92, 246, 0.6);
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
background-color: rgba(139, 92, 246, 0.03);
}
.custom-select-value {
flex: 1;
}
.custom-select-arrow {
color: var(--accent-purple-light);
font-size: 12px;
transition: transform var(--transition-base);
}
.custom-select.open .custom-select-arrow {
transform: rotate(180deg);
}
/* Dropdown */
.custom-select-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #1a1d2e;
border: 1px solid rgba(139, 92, 246, 0.3);
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
max-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-10px);
transition: all var(--transition-base);
z-index: 1000;
}
.custom-select.open .custom-select-dropdown {
max-height: 300px;
overflow-y: auto;
opacity: 1;
transform: translateY(0);
}
/* Scrollbar для dropdown */
.custom-select-dropdown::-webkit-scrollbar {
width: 6px;
}
.custom-select-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.custom-select-dropdown::-webkit-scrollbar-thumb {
background: rgba(139, 92, 246, 0.3);
border-radius: 3px;
}
.custom-select-dropdown::-webkit-scrollbar-thumb:hover {
background: rgba(139, 92, 246, 0.5);
}
/* Option */
.custom-select-option {
padding: 10px 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-base);
font-size: var(--text-base);
}
.custom-select-option:hover {
background: rgba(139, 92, 246, 0.1);
color: var(--text-primary);
}
.custom-select-option.selected {
background: rgba(139, 92, 246, 0.2);
color: var(--accent-purple-light);
font-weight: var(--font-semibold);
}
.custom-select-option.selected::before {
content: '✓ ';
margin-right: 8px;
}
/* File Upload Styling */
.file-upload-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.file-input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.file-upload-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-md);
padding: 12px 20px;
background: var(--glass-bg-dark);
border: 2px dashed var(--glass-border);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: var(--text-base);
font-weight: var(--font-medium);
cursor: pointer;
transition: all var(--transition-base);
text-align: center;
i {
color: var(--accent-purple-light);
font-size: 18px;
}
&:hover {
background: rgba(139, 92, 246, 0.05);
border-color: rgba(139, 92, 246, 0.4);
color: var(--text-primary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
.file-input:focus + .file-upload-btn {
border-color: rgba(139, 92, 246, 0.6);
box-shadow: 0 0 16px rgba(139, 92, 246, 0.2);
}
/* Drag over state */
.file-upload-wrapper.drag-over .file-upload-btn {
background: rgba(139, 92, 246, 0.15);
border-color: rgba(139, 92, 246, 0.7);
border-style: solid;
color: var(--text-primary);
transform: scale(1.02);
box-shadow: 0 0 20px rgba(139, 92, 246, 0.3);
}
.file-upload-wrapper.drag-over .file-upload-btn i {
animation: bounce 0.6s ease infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
/* File uploaded state */
.file-uploaded {
border-style: solid;
border-color: rgba(16, 185, 129, 0.5);
background: rgba(16, 185, 129, 0.05);
&:hover {
border-color: rgba(16, 185, 129, 0.6);
background: rgba(16, 185, 129, 0.1);
}
i {
color: var(--accent-green);
}
}
/* File status */
#certFileStatus,
#keyFileStatus,
#caFileStatus {
font-size: var(--text-sm);
font-weight: var(--font-medium);
display: flex;
align-items: center;
gap: var(--space-sm);
i {
font-size: 14px;
}
}

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();
}

View File

@@ -129,7 +129,13 @@
</section>
<section class="section" id="sectionSites">
<h2 class="section-title">Список сайтов</h2>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2 class="section-title" style="margin-bottom: 0;">Список сайтов</h2>
<button class="action-btn" id="addSiteBtn">
<i class="fas fa-plus"></i>
<span>Добавить сайт</span>
</button>
</div>
<div class="table-container">
<table class="data-table" id="sitesTable">
<thead>
@@ -481,6 +487,160 @@
</div>
</div>
</section>
<section class="section" id="sectionAddSite" style="display: none;">
<div class="vaccess-page">
<!-- Хлебные крошки -->
<div class="breadcrumbs">
<div class="breadcrumbs-left">
<button class="breadcrumb-item" onclick="backToMainFromAddSite()">
<i class="fas fa-arrow-left"></i> Назад
</button>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-item active">Добавить новый сайт</span>
</div>
</div>
<!-- Заголовок -->
<div class="vaccess-header">
<div class="vaccess-title-block">
<h2 class="vaccess-title">
<i class="fas fa-plus-circle"></i>
<span>Создание нового сайта</span>
</h2>
<p class="vaccess-subtitle">Заполните информацию о сайте и при необходимости загрузите SSL сертификаты</p>
</div>
<div class="vaccess-actions">
<button class="action-btn save-btn" id="createSiteBtn">
<i class="fas fa-check"></i>
<span>Создать сайт</span>
</button>
</div>
</div>
<!-- Форма создания сайта -->
<div class="vaccess-tab-content">
<div class="site-creator-form">
<!-- Единая форма -->
<div class="form-section">
<div class="settings-form">
<!-- Основная информация -->
<h3 class="form-subsection-title"><i class="fas fa-info-circle"></i> Основная информация</h3>
<div class="form-group">
<label class="form-label">Название сайта: <span style="color: #e74c3c;">*</span></label>
<input type="text" class="form-input" id="newSiteName" placeholder="Мой новый сайт">
</div>
<div class="form-group">
<label class="form-label">Host (домен): <span style="color: #e74c3c;">*</span></label>
<input type="text" class="form-input" id="newSiteHost" placeholder="example.com">
<small style="color: #95a5a6; display: block; margin-top: 5px;">
<i class="fas fa-info-circle"></i> Введите домен без протокола (например: example.com или 192.168.1.100)
</small>
</div>
<div class="form-group">
<label class="form-label">Alias (псевдонимы):</label>
<input type="text" class="form-input" id="newSiteAliasInput" placeholder="*.example.com, www.example.com, alias.com">
<small style="color: #95a5a6; display: block; margin-top: 5px;">
<i class="fas fa-info-circle"></i> Введите псевдонимы через запятую
</small>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Root файл: <span style="color: #e74c3c;">*</span></label>
<select class="form-input" id="newSiteRootFile">
<option value="index.html">index.html</option>
<option value="index.php">index.php</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Статус:</label>
<select class="form-input" id="newSiteStatus">
<option value="active">Active (Активен)</option>
<option value="inactive">Inactive (Отключен)</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Root file routing:</label>
<div class="toggle-wrapper">
<label class="toggle-switch">
<input type="checkbox" id="newSiteRouting" checked>
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">Включён</span>
</div>
<small style="color: #95a5a6; display: block; margin-top: 5px;">
<i class="fas fa-info-circle"></i> Если включено, все запросы к несуществующим файлам будут перенаправляться на root файл
</small>
</div>
<!-- SSL Сертификаты -->
<h3 class="form-subsection-title"><i class="fas fa-lock"></i> SSL Сертификаты (опционально)</h3>
<div class="form-group">
<label class="form-label">Режим сертификата:</label>
<select class="form-input" id="certMode" onchange="toggleCertUpload()">
<option value="none">Без сертификата (fallback)</option>
<option value="upload">Загрузить файлы сертификата</option>
</select>
</div>
<div id="certUploadBlock" style="display: none;">
<div class="form-group">
<label class="form-label">Certificate (*.crt): <span style="color: #e74c3c;">*</span></label>
<div class="file-upload-wrapper">
<input type="file" class="file-input" id="certFile" accept=".crt,.pem" onchange="handleCertFileSelect(this, 'certificate')">
<label for="certFile" class="file-upload-btn">
<i class="fas fa-file-upload"></i>
<span id="certFileName">Выберите файл...</span>
</label>
</div>
<div id="certFileStatus" style="margin-top: 8px;"></div>
</div>
<div class="form-group">
<label class="form-label">Private Key (*.key): <span style="color: #e74c3c;">*</span></label>
<div class="file-upload-wrapper">
<input type="file" class="file-input" id="keyFile" accept=".key,.pem" onchange="handleCertFileSelect(this, 'privatekey')">
<label for="keyFile" class="file-upload-btn">
<i class="fas fa-file-upload"></i>
<span id="keyFileName">Выберите файл...</span>
</label>
</div>
<div id="keyFileStatus" style="margin-top: 8px;"></div>
</div>
<div class="form-group">
<label class="form-label">CA Bundle (*.crt):</label>
<div class="file-upload-wrapper">
<input type="file" class="file-input" id="caFile" accept=".crt,.pem" onchange="handleCertFileSelect(this, 'cabundle')">
<label for="caFile" class="file-upload-btn">
<i class="fas fa-file-upload"></i>
<span id="caFileName">Выберите файл...</span>
</label>
</div>
<div id="caFileStatus" style="margin-top: 8px;"></div>
<small style="color: #95a5a6; display: block; margin-top: 5px;">
<i class="fas fa-info-circle"></i> CA Bundle опционален, но рекомендуется для полной цепочки сертификации
</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">

View File

@@ -7,6 +7,10 @@ import {vaccess} from '../models';
export function CheckServicesReady():Promise<boolean>;
export function CreateNewSite(arg1:string):Promise<string>;
export function DeleteSite(arg1:string):Promise<string>;
export function DisableProxyService():Promise<string>;
export function EnableProxyService():Promise<string>;
@@ -25,6 +29,8 @@ export function OpenSiteFolder(arg1:string):Promise<string>;
export function ReloadConfig():Promise<string>;
export function ReloadSSLCertificates():Promise<string>;
export function RestartAllServices():Promise<string>;
export function SaveConfig(arg1:string):Promise<string>;
@@ -52,3 +58,5 @@ export function StopPHPService():Promise<string>;
export function StopServer():Promise<string>;
export function UpdateSiteCache():Promise<string>;
export function UploadCertificate(arg1:string,arg2:string,arg3:string):Promise<string>;

View File

@@ -6,6 +6,14 @@ export function CheckServicesReady() {
return window['go']['admin']['App']['CheckServicesReady']();
}
export function CreateNewSite(arg1) {
return window['go']['admin']['App']['CreateNewSite'](arg1);
}
export function DeleteSite(arg1) {
return window['go']['admin']['App']['DeleteSite'](arg1);
}
export function DisableProxyService() {
return window['go']['admin']['App']['DisableProxyService']();
}
@@ -42,6 +50,10 @@ export function ReloadConfig() {
return window['go']['admin']['App']['ReloadConfig']();
}
export function ReloadSSLCertificates() {
return window['go']['admin']['App']['ReloadSSLCertificates']();
}
export function RestartAllServices() {
return window['go']['admin']['App']['RestartAllServices']();
}
@@ -97,3 +109,7 @@ export function StopServer() {
export function UpdateSiteCache() {
return window['go']['admin']['App']['UpdateSiteCache']();
}
export function UploadCertificate(arg1, arg2, arg3) {
return window['go']['admin']['App']['UploadCertificate'](arg1, arg2, arg3);
}

View File

@@ -318,3 +318,49 @@ func (a *App) UpdateSiteCache() string {
webserver.UpdateSiteStatusCache()
return "Cache updated"
}
func (a *App) CreateNewSite(siteJSON string) string {
var siteData sites.SiteInfo
err := json.Unmarshal([]byte(siteJSON), &siteData)
if err != nil {
return "Error: Invalid JSON - " + err.Error()
}
err = sites.CreateNewSite(siteData)
if err != nil {
return "Error: " + err.Error()
}
config.LoadConfig()
return "Site created successfully"
}
func (a *App) UploadCertificate(host, certType, certDataBase64 string) string {
certData, err := tools.DecodeBase64(certDataBase64)
if err != nil {
return "Error: Invalid base64 data - " + err.Error()
}
err = sites.UploadSiteCertificate(host, certType, certData)
if err != nil {
return "Error: " + err.Error()
}
webserver.ReloadCertificates()
return "Certificate uploaded successfully"
}
func (a *App) ReloadSSLCertificates() string {
webserver.ReloadCertificates()
return "SSL certificates reloaded"
}
func (a *App) DeleteSite(host string) string {
err := sites.DeleteSite(host)
if err != nil {
return "Error: " + err.Error()
}
config.LoadConfig()
return "Site deleted successfully"
}

View File

@@ -0,0 +1,312 @@
package sites
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
config "vServer/Backend/config"
tools "vServer/Backend/tools"
)
//go:embed templates/index.tmpl
var indexTemplate string
// CreateNewSite создаёт новый сайт со всей необходимой структурой
func CreateNewSite(siteData SiteInfo) error {
// 1. Валидация данных
if err := ValidateSiteData(siteData); err != nil {
return err
}
// 2. Создание структуры папок
if err := CreateSiteFolder(siteData.Host); err != nil {
return fmt.Errorf("ошибка создания папок: %w", err)
}
// 3. Создание стартового файла
if err := CreateStarterFile(siteData.Host, siteData.RootFile); err != nil {
return fmt.Errorf("ошибка создания стартового файла: %w", err)
}
// 4. Создание пустого vAccess.conf
if err := CreateVAccessFile(siteData.Host); err != nil {
return fmt.Errorf("ошибка создания vAccess.conf: %w", err)
}
// 5. Добавление сайта в конфиг
if err := AddSiteToConfig(siteData); err != nil {
return fmt.Errorf("ошибка добавления в конфиг: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("✅ Новый сайт создан: %s (%s)", siteData.Name, siteData.Host), "logs_config.log", true)
return nil
}
// ValidateSiteData проверяет данные нового сайта
func ValidateSiteData(siteData SiteInfo) error {
// Проверка обязательных полей
if strings.TrimSpace(siteData.Host) == "" {
return errors.New("поле Host обязательно для заполнения")
}
if strings.TrimSpace(siteData.Name) == "" {
return errors.New("поле Name обязательно для заполнения")
}
if strings.TrimSpace(siteData.RootFile) == "" {
return errors.New("поле RootFile обязательно для заполнения")
}
// Проверка уникальности host
for _, site := range config.ConfigData.Site_www {
if strings.EqualFold(site.Host, siteData.Host) {
return fmt.Errorf("сайт с host '%s' уже существует", siteData.Host)
}
}
// Проверка валидности status
if siteData.Status != "active" && siteData.Status != "inactive" {
return errors.New("status должен быть 'active' или 'inactive'")
}
return nil
}
// CreateSiteFolder создаёт структуру папок для нового сайта
func CreateSiteFolder(host string) error {
// Создаём путь: WebServer/www/{host}/public_www/
folderPath := filepath.Join("WebServer", "www", host, "public_www")
absPath, err := tools.AbsPath(folderPath)
if err != nil {
return err
}
// Создаём все необходимые папки
if err := os.MkdirAll(absPath, 0755); err != nil {
return fmt.Errorf("не удалось создать папку: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("📁 Создана папка: %s", folderPath), "logs_config.log", false)
return nil
}
// CreateStarterFile создаёт стартовый файл (index.html или index.php)
func CreateStarterFile(host, rootFile string) error {
filePath := filepath.Join("WebServer", "www", host, "public_www", rootFile)
// Получаем абсолютный путь БЕЗ проверки существования
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("ошибка получения абсолютного пути: %w", err)
}
// Генерируем контент из шаблона
content := generateTemplate(host, rootFile)
// Записываем файл
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return fmt.Errorf("не удалось создать файл: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("📄 Создан стартовый файл: %s", rootFile), "logs_config.log", false)
return nil
}
// CreateVAccessFile создаёт пустой конфиг vAccess
func CreateVAccessFile(host string) error {
filePath := filepath.Join("WebServer", "www", host, "vAccess.conf")
// Получаем абсолютный путь БЕЗ проверки существования
absPath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("ошибка получения абсолютного пути: %w", err)
}
content := `# vAccess Configuration
# Правила применяются сверху вниз
# Пример правила (закомментировано):
# type: Disable
# type_file: *.php
# path_access: /uploads/*
# url_error: 404
`
if err := os.WriteFile(absPath, []byte(content), 0644); err != nil {
return fmt.Errorf("не удалось создать vAccess.conf: %w", err)
}
tools.Logs_file(0, "SITES", "🔒 Создан vAccess.conf", "logs_config.log", false)
return nil
}
// AddSiteToConfig добавляет новый сайт в config.json
func AddSiteToConfig(siteData SiteInfo) error {
// Создаём новую запись
newSite := config.Site_www{
Name: siteData.Name,
Host: siteData.Host,
Alias: siteData.Alias,
Status: siteData.Status,
Root_file: siteData.RootFile,
Root_file_routing: siteData.RootFileRouting,
}
// Добавляем в массив
config.ConfigData.Site_www = append(config.ConfigData.Site_www, newSite)
// Сохраняем конфиг в файл
if err := SaveConfig(); err != nil {
return err
}
tools.Logs_file(0, "SITES", "💾 Конфигурация обновлена", "logs_config.log", false)
return nil
}
// SaveConfig сохраняет текущую конфигурацию в файл
func SaveConfig() error {
// Форматируем JSON с отступами
jsonData, err := json.MarshalIndent(config.ConfigData, "", " ")
if err != nil {
return fmt.Errorf("ошибка форматирования JSON: %w", err)
}
// Получаем абсолютный путь к файлу конфига
absPath, err := tools.AbsPath(config.ConfigPath)
if err != nil {
return err
}
// Записываем в файл
if err := os.WriteFile(absPath, jsonData, 0644); err != nil {
return fmt.Errorf("ошибка записи файла: %w", err)
}
return nil
}
// UploadSiteCertificate загружает SSL сертификат для сайта
func UploadSiteCertificate(host, certType string, certData []byte) error {
// Создаём папку для сертификатов
certDir := filepath.Join("WebServer", "cert", host)
absCertDir, err := tools.AbsPath(certDir)
if err != nil {
return err
}
if err := os.MkdirAll(absCertDir, 0755); err != nil {
return fmt.Errorf("не удалось создать папку для сертификатов: %w", err)
}
// Определяем имя файла
var fileName string
switch certType {
case "certificate":
fileName = "certificate.crt"
case "privatekey":
fileName = "private.key"
case "cabundle":
fileName = "ca_bundle.crt"
default:
return fmt.Errorf("неизвестный тип сертификата: %s", certType)
}
// Путь к файлу сертификата
certFilePath := filepath.Join(absCertDir, fileName)
// Записываем файл
if err := os.WriteFile(certFilePath, certData, 0644); err != nil {
return fmt.Errorf("не удалось сохранить сертификат: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("🔒 Загружен сертификат: %s для %s", fileName, host), "logs_config.log", true)
return nil
}
// DeleteSiteCertificates удаляет сертификаты сайта
func DeleteSiteCertificates(host string) error {
certDir := filepath.Join("WebServer", "cert", host)
// Получаем абсолютный путь к папке сертификатов
absCertDir, err := filepath.Abs(certDir)
if err != nil {
return fmt.Errorf("ошибка получения пути: %w", err)
}
// Проверяем, существует ли папка
if _, err := os.Stat(absCertDir); os.IsNotExist(err) {
return nil // Папки нет - ничего удалять не нужно
}
// Удаляем папку со всем содержимым
if err := os.RemoveAll(absCertDir); err != nil {
return fmt.Errorf("не удалось удалить папку сертификатов: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("🗑️ Удалены сертификаты для: %s", host), "logs_config.log", true)
return nil
}
// DeleteSite полностью удаляет сайт
func DeleteSite(host string) error {
// 1. Проверяем, существует ли сайт в конфиге
siteIndex := -1
for i, site := range config.ConfigData.Site_www {
if site.Host == host {
siteIndex = i
break
}
}
if siteIndex == -1 {
return fmt.Errorf("сайт с host '%s' не найден в конфигурации", host)
}
// 2. Удаляем папку сайта
siteDir := filepath.Join("WebServer", "www", host)
absSiteDir, err := filepath.Abs(siteDir)
if err != nil {
return fmt.Errorf("ошибка получения пути: %w", err)
}
if _, err := os.Stat(absSiteDir); err == nil {
if err := os.RemoveAll(absSiteDir); err != nil {
return fmt.Errorf("не удалось удалить папку сайта: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("🗑️ Удалена папка сайта: %s", siteDir), "logs_config.log", false)
}
// 3. Удаляем сертификаты
if err := DeleteSiteCertificates(host); err != nil {
// Логируем ошибку, но продолжаем удаление
tools.Logs_file(1, "SITES", fmt.Sprintf("Ошибка удаления сертификатов: %v", err), "logs_config.log", false)
}
// 4. Удаляем из конфига
config.ConfigData.Site_www = append(
config.ConfigData.Site_www[:siteIndex],
config.ConfigData.Site_www[siteIndex+1:]...,
)
// 5. Сохраняем конфиг
if err := SaveConfig(); err != nil {
return fmt.Errorf("ошибка сохранения конфигурации: %w", err)
}
tools.Logs_file(0, "SITES", fmt.Sprintf("✅ Сайт '%s' полностью удалён", host), "logs_config.log", true)
return nil
}
// generateTemplate генерирует шаблон для нового сайта
func generateTemplate(host, rootFile string) string {
// Для всех типов файлов используем один HTML шаблон
return strings.ReplaceAll(indexTemplate, "{{.Host}}", host)
}

View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Host}} - Добро пожаловать!</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #1a252f 0%, #2c3e50 50%, #34495e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
}
.container {
text-align: center;
padding: 3rem;
background: rgba(26, 37, 47, 0.95);
border-radius: 16px;
backdrop-filter: blur(15px);
border: 1px solid rgba(52, 152, 219, 0.2);
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(52, 152, 219, 0.1);
max-width: 580px;
animation: fadeIn 1s ease-in-out;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #3498db, #2ecc71, #e74c3c, #f39c12);
background-size: 400% 400%;
animation: gradientShift 4s ease infinite;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.logo {
font-size: 3.8rem;
font-weight: 700;
margin-bottom: 1.5rem;
background: linear-gradient(135deg, #3498db, #2ecc71);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 3px;
text-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
position: relative;
}
.welcome-text {
font-size: 1.9rem;
margin-bottom: 1.5rem;
font-weight: 400;
color: #ecf0f1;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.description {
font-size: 1.15rem;
margin-bottom: 2.5rem;
line-height: 1.8;
color: #bdc3c7;
font-weight: 300;
}
.status {
display: inline-flex;
align-items: center;
gap: 15px;
background: linear-gradient(135deg, rgba(46, 204, 113, 0.15), rgba(39, 174, 96, 0.25));
padding: 15px 30px;
border-radius: 12px;
border: 1px solid rgba(46, 204, 113, 0.4);
margin-bottom: 1.5rem;
box-shadow: 0 8px 16px rgba(46, 204, 113, 0.1);
}
.status-dot {
width: 14px;
height: 14px;
background: #2ecc71;
border-radius: 50%;
animation: pulse 2s infinite;
box-shadow: 0 0 10px rgba(46, 204, 113, 0.5);
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.7), 0 0 10px rgba(46, 204, 113, 0.5);
}
70% {
box-shadow: 0 0 0 10px rgba(46, 204, 113, 0), 0 0 10px rgba(46, 204, 113, 0.5);
}
100% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0), 0 0 10px rgba(46, 204, 113, 0.5);
}
}
.status-text {
color: #ecf0f1;
font-weight: 600;
font-size: 1.05rem;
}
.footer {
margin-top: 1.5rem;
font-size: 0.95rem;
opacity: 0.8;
color: #95a5a6;
font-weight: 500;
}
.footer a {
color: #3498db;
text-decoration: none;
transition: color 0.3s ease;
}
.footer a:hover {
color: #2ecc71;
text-decoration: underline;
}
.floating-particles {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
.particle {
position: absolute;
background: rgba(52, 152, 219, 0.15);
border-radius: 50%;
animation: float 8s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(180deg);
}
}
</style>
</head>
<body>
<div class="floating-particles">
<div class="particle" style="width: 18px; height: 18px; top: 15%; left: 10%; animation-delay: 0s;"></div>
<div class="particle" style="width: 14px; height: 14px; top: 65%; left: 85%; animation-delay: 3s;"></div>
<div class="particle" style="width: 10px; height: 10px; top: 85%; left: 20%; animation-delay: 6s;"></div>
<div class="particle" style="width: 22px; height: 22px; top: 25%; left: 75%; animation-delay: 2s;"></div>
<div class="particle" style="width: 12px; height: 12px; top: 75%; left: 50%; animation-delay: 4s;"></div>
<div class="particle" style="width: 16px; height: 16px; top: 40%; left: 15%; animation-delay: 1s;"></div>
</div>
<div class="container">
<div class="logo">{{.Host}}</div>
<div class="welcome-text">Добро пожаловать!</div>
<div class="description">
Ваш сайт успешно создан и готов к работе.<br>
Начните разработку, заменив этот файл своим контентом.
</div>
<div class="status">
<div class="status-dot"></div>
<span class="status-text">Сайт работает</span>
</div>
<div class="footer">
Powered by vServer
<br>
Сайт разработчика: <a href="https://voxsel.ru" target="_blank">voxsel.ru</a>
</div>
</div>
</body>
</html>

View File

@@ -4,8 +4,8 @@ import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
tools "vServer/Backend/tools"
)
func GetVAccessPath(host string, isProxy bool) string {
@@ -18,8 +18,13 @@ func GetVAccessPath(host string, isProxy bool) string {
func GetVAccessConfig(host string, isProxy bool) (*VAccessConfig, error) {
filePath := GetVAccessPath(host, isProxy)
// Получаем абсолютный путь БЕЗ проверки существования
absPath, err := filepath.Abs(filePath)
if err != nil {
return &VAccessConfig{Rules: []VAccessRule{}}, nil
}
// Проверяем существование файла
absPath, _ := tools.AbsPath(filePath)
if _, err := os.Stat(absPath); os.IsNotExist(err) {
// Файл не существует - возвращаем пустую конфигурацию
return &VAccessConfig{Rules: []VAccessRule{}}, nil
@@ -104,11 +109,14 @@ func SaveVAccessConfig(host string, isProxy bool, config *VAccessConfig) error {
dir = fmt.Sprintf("WebServer/www/%s", host)
}
absDir, _ := tools.AbsPath(dir)
absDir, err := filepath.Abs(dir)
if err != nil {
return err
}
os.MkdirAll(absDir, 0755)
// Получаем абсолютный путь к файлу
absPath, err := tools.AbsPath(filePath)
absPath, err := filepath.Abs(filePath)
if err != nil {
return err
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

View File

@@ -1,6 +1,7 @@
package tools
import (
"encoding/base64"
"fmt"
"net"
"time"
@@ -74,6 +75,11 @@ func Error_check(err error, message string) bool {
return true
}
// DecodeBase64 декодирует строку из base64
func DecodeBase64(encoded string) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("ошибка декодирования base64: %w", err)
}
return decoded, nil
}

View File

@@ -61,7 +61,7 @@ Write-Success "Зависимости установлены"
Write-ProgressBar 50
Write-Host ""
Write-Step 3 4 "Проверка Wails CLI..."
Write-Step 3 5 "Проверка Wails CLI..."
$null = wails version 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Info "Установка Wails CLI..."
@@ -70,10 +70,17 @@ if ($LASTEXITCODE -ne 0) {
} else {
Write-Success "Найден"
}
Write-ProgressBar 75
Write-ProgressBar 60
Write-Host ""
Write-Step 4 4 "Сборка приложения..."
Write-Step 4 5 "Генерация биндингов..."
Write-Info "Создание TypeScript/JS биндингов для Go методов..."
wails generate module 2>&1 | Out-Null
Write-Success "Биндинги сгенерированы"
Write-ProgressBar 80
Write-Host ""
Write-Step 5 5 "Сборка приложения..."
Write-Info "Компиляция (может занять ~10 сек)..."
wails build -f admin.go 2>&1 | Out-Null

BIN
vSerf.exe

Binary file not shown.