Импорт пресетов сборок из ZIP-пакета
Release / release (push) Successful in 30s
CI / test (push) Successful in 23s

Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает
его сама: находит серверный .ini, песочницу и точки появления, показывает,
что нашла (сколько модов, карт, параметров), и только по подтверждению
раскладывает файлы в новый или существующий профиль.

Порты, пароли и название сервера при этом можно оставить свои — они относятся
к машине, а не к сборке; у нового профиля они берутся из активного. Прежние
конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий
профиль отклоняется: PZ перезаписал бы файлы своими при остановке.

Посторонние .ini из архива (настройки модов) за конфиг сервера не
принимаются: он опознаётся по знакомым параметрам.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 17:18:44 +03:00
co-authored by Claude Opus 5
parent 1e659678b9
commit aec932f68f
12 changed files with 1022 additions and 7 deletions
+237
View File
@@ -0,0 +1,237 @@
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
}
+2
View File
@@ -117,6 +117,8 @@ func (s *Server) Handler() http.Handler {
"PUT /api/profiles/{id}": s.handleUpdateProfile,
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
"POST /api/presets/preview": s.handlePreviewPreset,
"POST /api/presets/import": s.handleImportPreset,
"GET /api/mods": s.handleGetMods,
"PUT /api/mods": s.handlePutMods,
"POST /api/mods/lookup": s.handleLookupMods,