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

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

@@ -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>