Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает его сама: находит серверный .ini, песочницу и точки появления, показывает, что нашла (сколько модов, карт, параметров), и только по подтверждению раскладывает файлы в новый или существующий профиль. Порты, пароли и название сервера при этом можно оставить свои — они относятся к машине, а не к сборке; у нового профиля они берутся из активного. Прежние конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий профиль отклоняется: PZ перезаписал бы файлы своими при остановке. Посторонние .ini из архива (настройки модов) за конфиг сервера не принимаются: он опознаётся по знакомым параметрам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user