package api import ( "errors" "fmt" "io" "net/http" "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" 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 id == s.cfg.Active && s.sup.State() != pzserver.StateStopped { 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 } if createNew { 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)) result["profile"] = id result["created"] = createNew 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)) } raw := [][2]any{ {s.cfg.SandboxConfigPathFor(id), files.Sandbox}, {spawnRegionsPath(s.cfg, id), files.SpawnRegions}, } for _, pair := range raw { data, _ := pair[1].([]byte) if data == nil { continue } path := pair[0].(string) if err := pzconfig.SaveRaw(path, data); err != nil { return nil, err } written = append(written, filepath.Base(path)) } return map[string]any{"files": written, "kept": kept, "kept_from": keptFrom}, nil } // 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() if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") { writeError(w, r, http.StatusBadRequest, "ожидается ZIP-архив пакета настроек") 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.FromZip(data) if err != nil { writeError(w, r, http.StatusBadRequest, err.Error()) return nil, false } return sets, true }