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 }