В пакете сборки рядом с комплектом сервера лежит пресет песочницы одиночной игры. Наборы сортировались по имени, и пресет песочницы часто оказывался первым — панель предлагала его по умолчанию, а модов и карт в нём нет. Теперь первым идёт набор с конфигом сервера, а в списке выбора видно, что внутри каждого. Вкладки показывают активный профиль, поэтому импорт в новый выглядел так, будто ничего не подтянулось. Появилась галочка «Сделать этот профиль активным»; если её снять, панель говорит, где искать импортированное. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
300 lines
11 KiB
Go
300 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mrleo1nid/pzmanager/internal/config"
|
|
"github.com/mrleo1nid/pzmanager/internal/preset"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzconfig"
|
|
"github.com/mrleo1nid/pzmanager/internal/pzserver"
|
|
)
|
|
|
|
// keepGroups — то, что при импорте разумно оставить своим. Пресет собирали не
|
|
// под эту машину: его порты, пароли и название сервера к ней отношения не
|
|
// имеют, и молча их подменять — верный способ потерять доступ к серверу.
|
|
var keepGroups = map[string][]string{
|
|
"ports": {"DefaultPort", "UDPPort", "RCONPort", "RCONPassword"},
|
|
"access": {"Password", "server_browser_announced_ip", "PublicServerIP", "ServerBrowserAnnouncedIP"},
|
|
"identity": {"PublicName", "PublicDescription"},
|
|
}
|
|
|
|
// handlePreviewPreset разбирает загруженный архив и рассказывает, что в нём
|
|
// нашлось. Ничего не пишет: решение принимает человек.
|
|
func (s *Server) handlePreviewPreset(w http.ResponseWriter, r *http.Request) {
|
|
sets, ok := s.readPresetUpload(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
summaries := make([]preset.Summary, 0, len(sets))
|
|
for _, set := range sets {
|
|
summaries = append(summaries, set.Describe())
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"presets": summaries})
|
|
}
|
|
|
|
// handleImportPreset записывает выбранный набор в профиль — новый или уже
|
|
// существующий.
|
|
func (s *Server) handleImportPreset(w http.ResponseWriter, r *http.Request) {
|
|
sets, ok := s.readPresetUpload(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
files, err := pickPreset(sets, r.FormValue("preset"))
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
createNew := r.FormValue("target") != "existing"
|
|
activate := r.FormValue("activate") == "1"
|
|
id := strings.TrimSpace(r.FormValue("profile"))
|
|
if id == "" && createNew {
|
|
id = config.SanitizeProfileID(files.Name)
|
|
}
|
|
|
|
_, exists := s.cfg.Profile(id)
|
|
switch {
|
|
case createNew && exists:
|
|
writeError(w, r, http.StatusConflict, fmt.Sprintf("профиль %q уже существует", id))
|
|
return
|
|
case !createNew && !exists:
|
|
writeError(w, r, http.StatusNotFound, "профиль не найден")
|
|
return
|
|
}
|
|
|
|
// Живой сервер держит мир и конфиг в памяти и переписывает файлы при
|
|
// остановке — импорт под ним просто пропал бы. Переключать профиль на
|
|
// живом сервере нельзя по той же причине.
|
|
if s.sup.State() != pzserver.StateStopped && (id == s.cfg.Active || activate) {
|
|
writeError(w, r, http.StatusConflict, "сначала остановите игровой сервер")
|
|
return
|
|
}
|
|
|
|
if createNew {
|
|
profile := config.Profile{ID: id, Title: strings.TrimSpace(r.FormValue("title"))}
|
|
if profile.Title == "" {
|
|
profile.Title = files.Name
|
|
}
|
|
if err := s.cfg.AddProfile(profile); err != nil {
|
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
result, err := s.applyPreset(files, id, parseKeep(r.FormValue("keep")))
|
|
if err != nil {
|
|
if createNew {
|
|
// Профиль без файлов настроек только мешал бы: откатываем.
|
|
_ = s.cfg.DeleteProfile(id)
|
|
}
|
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// Вкладки панели показывают активный профиль: без переключения импорт
|
|
// выглядит так, будто моды и настройки не подтянулись.
|
|
switched := false
|
|
if activate && s.cfg.Active != id {
|
|
if err := s.cfg.SetActive(id); err != nil {
|
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
switched = true
|
|
}
|
|
if createNew || switched {
|
|
if err := s.cfg.Save(); err != nil {
|
|
writeError(w, r, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
summary := files.Describe()
|
|
s.sup.AppendLog("manager", fmt.Sprintf("Импортирован пресет %q в профиль %q (модов: %d)",
|
|
files.Name, id, summary.Mods))
|
|
if switched {
|
|
s.sup.AppendLog("manager", fmt.Sprintf("Активный профиль: %s", s.cfg.ActiveProfile().Name()))
|
|
}
|
|
|
|
result["profile"] = id
|
|
result["created"] = createNew
|
|
result["switched"] = switched
|
|
result["summary"] = summary
|
|
writeJSON(w, http.StatusOK, result)
|
|
}
|
|
|
|
// applyPreset раскладывает файлы набора под именем профиля. Прежние версии
|
|
// сохраняются рядом с расширением .bak — так же, как при правке в редакторе.
|
|
func (s *Server) applyPreset(files preset.Files, id string, keep []string) (map[string]any, error) {
|
|
written := []string{}
|
|
kept := []string{}
|
|
keptFrom := ""
|
|
|
|
if files.INI != nil {
|
|
ini := pzconfig.ParseINI(files.INI)
|
|
// Свои значения берём из конфига самого профиля, а у нового профиля
|
|
// их ещё нет — тогда из активного: порты и пароли на машине общие.
|
|
source, from := s.keepSource(id)
|
|
if source != nil {
|
|
for _, key := range keep {
|
|
if source.Has(key) {
|
|
ini.Set(key, source.Get(key))
|
|
kept = append(kept, key)
|
|
}
|
|
}
|
|
if len(kept) > 0 {
|
|
keptFrom = from
|
|
}
|
|
}
|
|
path := s.cfg.ServerConfigPathFor(id)
|
|
if err := pzconfig.SaveINI(path, ini); err != nil {
|
|
return nil, err
|
|
}
|
|
written = append(written, filepath.Base(path))
|
|
}
|
|
|
|
result := map[string]any{"kept": kept, "kept_from": keptFrom}
|
|
|
|
sandboxPath := s.cfg.SandboxConfigPathFor(id)
|
|
switch {
|
|
case files.Sandbox != nil:
|
|
// Готовый файл сервера кладём как есть.
|
|
if err := pzconfig.SaveRaw(sandboxPath, files.Sandbox); err != nil {
|
|
return nil, err
|
|
}
|
|
written = append(written, filepath.Base(sandboxPath))
|
|
case files.SandboxPreset != nil:
|
|
applied, skipped, err := applySandboxPreset(sandboxPath, files.SandboxPreset)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
written = append(written, filepath.Base(sandboxPath))
|
|
result["applied"] = applied
|
|
result["skipped"] = skipped
|
|
}
|
|
|
|
if files.SpawnRegions != nil {
|
|
path := spawnRegionsPath(s.cfg, id)
|
|
if err := pzconfig.SaveRaw(path, files.SpawnRegions); err != nil {
|
|
return nil, err
|
|
}
|
|
written = append(written, filepath.Base(path))
|
|
}
|
|
|
|
result["files"] = written
|
|
return result, nil
|
|
}
|
|
|
|
// applySandboxPreset переносит значения пресета одиночной игры в песочницу
|
|
// профиля. Пресет плоский, а файл сервера — таблица Lua с комментариями игры,
|
|
// поэтому значения переставляются по одному, а сам файл остаётся прежним.
|
|
// Если файла ещё нет (профиль ни разу не запускался), он создаётся целиком.
|
|
func applySandboxPreset(path string, data []byte) (applied, skipped int, err error) {
|
|
values := preset.ParseSandboxPreset(data)
|
|
|
|
sandbox, err := pzconfig.LoadSandbox(path)
|
|
if os.IsNotExist(err) {
|
|
return len(values.Values), 0, pzconfig.SaveRaw(path, values.Lua())
|
|
}
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
|
|
for _, key := range values.Order() {
|
|
// Параметров модов в файле нет, пока моды не установлены и сервер не
|
|
// запускался: такие значения пропускаем и считаем.
|
|
if err := sandbox.Set(key, values.Values[key]); err != nil {
|
|
skipped++
|
|
continue
|
|
}
|
|
applied++
|
|
}
|
|
if applied == 0 {
|
|
return 0, skipped, errors.New("ни один параметр пресета не подошёл к настройкам профиля")
|
|
}
|
|
return applied, skipped, pzconfig.SaveSandbox(path, sandbox)
|
|
}
|
|
|
|
// keepSource выбирает конфиг, из которого берутся сохраняемые значения.
|
|
func (s *Server) keepSource(id string) (*pzconfig.INI, string) {
|
|
candidates := []string{id}
|
|
if id != s.cfg.Active {
|
|
candidates = append(candidates, s.cfg.Active)
|
|
}
|
|
for _, candidate := range candidates {
|
|
ini, err := pzconfig.LoadINI(s.cfg.ServerConfigPathFor(candidate))
|
|
if err == nil {
|
|
return ini, candidate
|
|
}
|
|
}
|
|
return nil, ""
|
|
}
|
|
|
|
// spawnRegionsPath — <профиль>_spawnregions.lua рядом с остальными конфигами.
|
|
func spawnRegionsPath(cfg *config.Config, id string) string {
|
|
return filepath.Join(filepath.Dir(cfg.ServerConfigPathFor(id)), id+"_spawnregions.lua")
|
|
}
|
|
|
|
// parseKeep разбирает список групп, которые человек отметил в панели.
|
|
func parseKeep(value string) []string {
|
|
keys := []string{}
|
|
for _, group := range strings.Split(value, ",") {
|
|
keys = append(keys, keepGroups[strings.TrimSpace(group)]...)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// pickPreset выбирает набор по имени; когда набор один, имя необязательно.
|
|
func pickPreset(sets []preset.Files, name string) (preset.Files, error) {
|
|
if name == "" {
|
|
if len(sets) == 1 {
|
|
return sets[0], nil
|
|
}
|
|
return preset.Files{}, errors.New("в архиве несколько наборов настроек — выберите один")
|
|
}
|
|
for _, set := range sets {
|
|
if set.Name == name {
|
|
return set, nil
|
|
}
|
|
}
|
|
return preset.Files{}, fmt.Errorf("набор %q не найден в архиве", name)
|
|
}
|
|
|
|
// readPresetUpload принимает архив из формы и разбирает его.
|
|
func (s *Server) readPresetUpload(w http.ResponseWriter, r *http.Request) ([]preset.Files, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, preset.MaxArchiveSize)
|
|
if err := r.ParseMultipartForm(8 << 20); err != nil {
|
|
writeError(w, r, http.StatusBadRequest, "файл не принят: "+err.Error())
|
|
return nil, false
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, "файл не выбран")
|
|
return nil, false
|
|
}
|
|
defer file.Close()
|
|
|
|
name := strings.ToLower(header.Filename)
|
|
if !strings.HasSuffix(name, ".zip") && !strings.HasSuffix(name, ".cfg") {
|
|
writeError(w, r, http.StatusBadRequest,
|
|
"ожидается ZIP-пакет настроек или пресет песочницы .cfg")
|
|
return nil, false
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, preset.MaxArchiveSize))
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, "файл не прочитан: "+err.Error())
|
|
return nil, false
|
|
}
|
|
|
|
sets, err := preset.FromUpload(header.Filename, data)
|
|
if err != nil {
|
|
writeError(w, r, http.StatusBadRequest, err.Error())
|
|
return nil, false
|
|
}
|
|
return sets, true
|
|
}
|