diff --git a/README.md b/README.md index 4c414cb..1db41c3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,9 @@ самого конфига, поиск идёт и по названию, и по описанию. - **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка загрузки, добавление по ссылке и разворачивание коллекций Steam целиком. +- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель: + конфиг, песочница и точки появления попадают в новый или существующий + профиль, свои порты и пароли при этом сохраняются. - **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с ротацией, скачиванием и восстановлением (перед восстановлением автоматически снимается страховочная копия). @@ -178,6 +181,40 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он приходит от Project Zomboid. +## Импорт пресетов сборок + +Крупные сборки модов раздают готовый комплект настроек архивом: внутри лежат +папка `Server` с `<имя>.ini`, `<имя>_SandboxVars.lua` и +`<имя>_spawnregions.lua`, а рядом — файлы для одиночной игры, которые серверу +не нужны. + +На вкладке «Профили» есть «Импорт пресета сборки»: выберите ZIP, и панель +покажет, что в нём нашла — название сервера, сколько модов и пакетов +мастерской, какие карты, сколько параметров конфига и песочницы. Ничего не +записывается, пока вы не нажмёте «Импортировать». + +Импортировать можно в новый профиль (идентификатор предлагается по имени +файлов из архива) или поверх существующего. Прежние конфиги остаются рядом с +расширением `.bak`, мир не трогается. Профиль, который сейчас работает, +импорт не принимает — сначала остановите сервер, иначе PZ перезапишет файлы +своими при выходе. + +Галочки «Оставить свои значения» переносят в импортированный конфиг то, что +относится к вашей машине, а не к сборке: + +| группа | параметры | +| --- | --- | +| Порты и RCON | `DefaultPort`, `UDPPort`, `RCONPort`, `RCONPassword` | +| Пароль сервера и объявляемый IP | `Password`, `server_browser_announced_ip` | +| Имя и описание сервера | `PublicName`, `PublicDescription` | + +Для нового профиля эти значения берутся из активного: порты и пароли на +машине общие. Всё остальное — списки модов, карты, правила и песочница — +приходит из пресета. + +Посторонние `.ini` из архива (настройки модов, например) не принимаются за +конфиг сервера: панель опознаёт его по знакомым параметрам. + ## Моды Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что diff --git a/internal/api/handlers_presets.go b/internal/api/handlers_presets.go new file mode 100644 index 0000000..c078c1f --- /dev/null +++ b/internal/api/handlers_presets.go @@ -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 +} diff --git a/internal/api/server.go b/internal/api/server.go index a30e450..e857b5a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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, diff --git a/internal/config/config.go b/internal/config/config.go index 5ada4a8..95f1dab 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -59,6 +59,25 @@ func ValidateProfileID(id string) error { return nil } +// SanitizeProfileID превращает имя файла из чужого пакета настроек в годный +// идентификатор профиля: пробелы и точки в именах пресетов не редкость, а +// идентификатор становится именем конфига и папки мира. +func SanitizeProfileID(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + if b.Len() >= 32 { + break + } + } + return strings.Trim(b.String(), "-") +} + // Config — всё, что менеджеру нужно знать о хосте и о серверах PZ. type Config struct { // Listen — адрес веб-панели. Для доступа только через SSH-туннель diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go index f4c2d03..f84f3e0 100644 --- a/internal/i18n/catalog.go +++ b/internal/i18n/catalog.go @@ -71,13 +71,18 @@ var messages = map[string]string{ "Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM", "Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL", "Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running", - "Автоперезапуск отменён": "Automatic restart cancelled", - "Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.", - "Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart", - "Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f", - "Перезапускаю панель": "Restarting the panel", - "обновлением": "the update", - "перезапуском": "the restart", + "в архиве нет серверных файлов Project Zomboid": "the archive has no Project Zomboid server files", + "распакованный архив слишком велик": "the unpacked archive is too large", + "файл не выбран": "no file selected", + "ожидается ZIP-архив пакета настроек": "a ZIP package of settings is expected", + "в архиве несколько наборов настроек — выберите один": "the archive holds several sets of settings — pick one", + "Автоперезапуск отменён": "Automatic restart cancelled", + "Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.", + "Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart", + "Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f", + "Перезапускаю панель": "Restarting the panel", + "обновлением": "the update", + "перезапуском": "the restart", } var formats = [][2]string{ @@ -174,6 +179,16 @@ var formats = [][2]string{ {"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"}, {"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %s"}, + // --- Импорт пресетов --- + {"архив не читается: %s", "the archive cannot be read: %s"}, + {"в архиве слишком много файлов: %s", "the archive has too many files: %s"}, + {"файл %s слишком велик", "the file %s is too large"}, + {"файл не принят: %s", "the file was not accepted: %s"}, + {"файл не прочитан: %s", "the file was not read: %s"}, + {"набор %s не найден в архиве", "the set %s was not found in the archive"}, + {"Импортирован пресет %s в профиль %s (модов: %s)", + "Preset %s imported into the profile %s (mods: %s)"}, + // --- Бэкапы: ход работы --- {"Создаю архив %s (профиль %s)", "Creating the archive %s (profile %s)"}, {"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"}, diff --git a/internal/preset/preset.go b/internal/preset/preset.go new file mode 100644 index 0000000..a590e29 --- /dev/null +++ b/internal/preset/preset.go @@ -0,0 +1,225 @@ +// Package preset разбирает пакеты настроек, которые раздают сборки модов. +// +// Сборка обычно приходит одним архивом с папкой Server внутри: там лежат +// готовый .ini со списком модов, настройки песочницы и точки появления. Всё +// остальное в архиве (файлы одиночной игры, картинки, changelog) менеджеру +// не нужно и молча пропускается. +package preset + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "io" + "path" + "sort" + "strings" + + "github.com/mrleo1nid/pzmanager/internal/pzconfig" +) + +// Пределы разбора. Архив приходит от человека, но верить ему нельзя: без +// ограничений маленький файл разворачивается в гигабайты. +const ( + // MaxArchiveSize — предел на сам загружаемый файл. + MaxArchiveSize = 32 << 20 + maxFileSize = 16 << 20 + maxTotalSize = 64 << 20 + maxEntries = 10000 +) + +// ErrNoServerFiles возвращается, когда в архиве нет ни одного серверного файла. +var ErrNoServerFiles = errors.New("в архиве нет серверных файлов Project Zomboid") + +// Files — серверная часть пакета: то, что PZ читает при запуске. +type Files struct { + // Name — имя, под которым файлы лежали в архиве (sophie-server-42-2). + Name string + INI []byte + Sandbox []byte + SpawnRegions []byte +} + +// Summary — то, что панель показывает до импорта, чтобы человек понимал, что +// именно он собирается записать поверх своих настроек. +type Summary struct { + Name string `json:"name"` + ServerName string `json:"server_name"` + Maps []string `json:"maps"` + Mods int `json:"mods"` + WorkshopItems int `json:"workshop_items"` + Settings int `json:"settings"` + SandboxSettings int `json:"sandbox_settings"` + HasINI bool `json:"has_ini"` + HasSandbox bool `json:"has_sandbox"` + HasSpawnRegions bool `json:"has_spawn_regions"` +} + +// markers — параметры, по которым .ini опознаётся как конфиг сервера. В +// архивах сборок хватает и других .ini (настройки модов, например), и брать +// их за конфиг нельзя. +var markers = []string{ + "PublicName", "PublicDescription", "DefaultPort", "UDPPort", "MaxPlayers", + "Mods", "WorkshopItems", "Map", "PVP", "Open", "RCONPort", "ServerWelcomeMessage", + "SaveWorldEveryMinutes", "SpawnPoint", +} + +const markersRequired = 3 + +// FromZip достаёт из архива все наборы серверных файлов. Наборов может быть +// несколько: сборки иногда кладут рядом варианты конфигов. +func FromZip(data []byte) ([]Files, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, fmt.Errorf("архив не читается: %w", err) + } + if len(reader.File) > maxEntries { + return nil, fmt.Errorf("в архиве слишком много файлов: %d", len(reader.File)) + } + + sets := map[string]*Files{} + total := 0 + + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + name := path.Base(strings.ReplaceAll(entry.Name, "\\", "/")) + base, kind := classify(name) + if kind == kindOther { + continue + } + if entry.UncompressedSize64 > maxFileSize { + continue + } + if total+int(entry.UncompressedSize64) > maxTotalSize { + return nil, errors.New("распакованный архив слишком велик") + } + + content, err := readEntry(entry) + if err != nil { + return nil, err + } + total += len(content) + + if kind == kindINI && !looksLikeServerConfig(content) { + continue + } + + set := sets[base] + if set == nil { + set = &Files{Name: base} + sets[base] = set + } + switch kind { + case kindINI: + set.INI = content + case kindSandbox: + set.Sandbox = content + case kindSpawnRegions: + set.SpawnRegions = content + } + } + + out := make([]Files, 0, len(sets)) + for _, set := range sets { + // Одни точки появления без конфига и песочницы импортировать некуда. + if set.INI == nil && set.Sandbox == nil { + continue + } + out = append(out, *set) + } + if len(out) == 0 { + return nil, ErrNoServerFiles + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out, nil +} + +type kind int + +const ( + kindOther kind = iota + kindINI + kindSandbox + kindSpawnRegions +) + +// classify определяет по имени файла, часть какого набора перед нами. +func classify(name string) (string, kind) { + lower := strings.ToLower(name) + switch { + case strings.HasSuffix(lower, "_sandboxvars.lua"): + return name[:len(name)-len("_SandboxVars.lua")], kindSandbox + case strings.HasSuffix(lower, "_spawnregions.lua"): + return name[:len(name)-len("_spawnregions.lua")], kindSpawnRegions + case strings.HasSuffix(lower, ".ini"): + return name[:len(name)-len(".ini")], kindINI + } + return "", kindOther +} + +// looksLikeServerConfig отличает конфиг сервера от постороннего .ini. +func looksLikeServerConfig(data []byte) bool { + ini := pzconfig.ParseINI(data) + found := 0 + for _, key := range markers { + if ini.Has(key) { + found++ + } + } + return found >= markersRequired +} + +func readEntry(entry *zip.File) ([]byte, error) { + rc, err := entry.Open() + if err != nil { + return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err) + } + defer rc.Close() + // Заявленный размер может врать, поэтому читаем с запасом в один байт и + // проверяем, что файл на нём кончился. + content, err := io.ReadAll(io.LimitReader(rc, maxFileSize+1)) + if err != nil { + return nil, fmt.Errorf("прочитать %s: %w", entry.Name, err) + } + if len(content) > maxFileSize { + return nil, fmt.Errorf("файл %s слишком велик", entry.Name) + } + return content, nil +} + +// Describe собирает сводку по набору: сколько модов, карт и параметров. +func (f Files) Describe() Summary { + s := Summary{ + Name: f.Name, + HasINI: f.INI != nil, + HasSandbox: f.Sandbox != nil, + HasSpawnRegions: f.SpawnRegions != nil, + Maps: []string{}, + } + if f.INI != nil { + ini := pzconfig.ParseINI(f.INI) + s.ServerName = ini.Get("PublicName") + s.Mods = len(splitList(ini.Get("Mods"))) + s.WorkshopItems = len(splitList(ini.Get("WorkshopItems"))) + s.Maps = splitList(ini.Get("Map")) + s.Settings = len(ini.Keys()) + } + if f.Sandbox != nil { + s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries()) + } + return s +} + +// splitList разбирает перечисления PZ: значения через точку с запятой. +func splitList(value string) []string { + out := []string{} + for _, part := range strings.Split(value, ";") { + if part = strings.TrimSpace(part); part != "" { + out = append(out, part) + } + } + return out +} diff --git a/internal/preset/preset_test.go b/internal/preset/preset_test.go new file mode 100644 index 0000000..5304e82 --- /dev/null +++ b/internal/preset/preset_test.go @@ -0,0 +1,194 @@ +package preset + +import ( + "archive/zip" + "bytes" + "errors" + "strings" + "testing" +) + +// serverINI — сокращённый конфиг сервера: маркеров хватает, чтобы отличить его +// от постороннего .ini. +const serverINI = `PublicName=Sophie Server +PublicDescription=Vanilla++ +DefaultPort=16261 +MaxPlayers=32 +PVP=true +Map=Brandenburg, KY;Muldraugh, KY +Mods=SophieCore;SophieExtras +WorkshopItems=3773856464;2392709985 +` + +const sandboxLua = `SandboxVars = { + VERSION = 5, + Zombies = 3, + ZombieLore = { + Speed = 2, + }, +} +` + +const spawnRegions = `function SpawnRegions() + return { { name = "Muldraugh,KY", file = "media/maps/Muldraugh, KY/spawnpoints.lua" } } +end +` + +func buildZip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for name, content := range files { + f, err := w.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte(content)); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// Пакет сборки — это архив пользовательской папки Zomboid: серверные файлы в +// нём лежат вперемешку с тем, что нужно только одиночной игре. +func TestFromZipPack(t *testing.T) { + data := buildZip(t, map[string]string{ + "readme.txt": "put these folders...", + "changelog.txt": "v2", + "THIS IS WHERE TO PUT THESE FOLDERS.png": "\x89PNG fake", + "Lua/ModOptions.ini": "[SomeMod]\nenabled=true\n", + "Lua/ModFolders.cfg": "folders", + "Sandbox Presets/Sophie-42.cfg": "Version=6\nZombies=4\n", + "Server/sophie-42.ini": serverINI, + "Server/sophie-42_SandboxVars.lua": sandboxLua, + "Server/sophie-42_spawnregions.lua": spawnRegions, + }) + + sets, err := FromZip(data) + if err != nil { + t.Fatal(err) + } + if len(sets) != 1 { + names := []string{} + for _, s := range sets { + names = append(names, s.Name) + } + t.Fatalf("наборов: %d (%s), ожидался один", len(sets), strings.Join(names, ", ")) + } + + set := sets[0] + if set.Name != "sophie-42" { + t.Errorf("имя набора = %q", set.Name) + } + if set.INI == nil || set.Sandbox == nil || set.SpawnRegions == nil { + t.Fatalf("не все файлы найдены: ini=%v sandbox=%v spawn=%v", + set.INI != nil, set.Sandbox != nil, set.SpawnRegions != nil) + } + + got := set.Describe() + if got.ServerName != "Sophie Server" { + t.Errorf("имя сервера = %q", got.ServerName) + } + if got.Mods != 2 || got.WorkshopItems != 2 { + t.Errorf("модов %d, пакетов мастерской %d", got.Mods, got.WorkshopItems) + } + if len(got.Maps) != 2 || got.Maps[0] != "Brandenburg, KY" { + t.Errorf("карты = %v", got.Maps) + } + if got.SandboxSettings == 0 { + t.Error("параметры песочницы не посчитаны") + } + if !got.HasSpawnRegions { + t.Error("точки появления не найдены") + } +} + +// Настройки мода лежат в таком же .ini, и принимать их за конфиг сервера +// нельзя: импорт затёр бы настоящий конфиг мусором. +func TestFromZipIgnoresForeignINI(t *testing.T) { + data := buildZip(t, map[string]string{ + "Lua/ModOptions.ini": "[SomeMod]\nenabled=true\nPVP=true\n", + "notes.txt": "hi", + }) + if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) { + t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err) + } +} + +// Файлы могут лежать в корне архива или в папке с другим именем — важен +// только их суффикс. +func TestFromZipFlatLayout(t *testing.T) { + data := buildZip(t, map[string]string{ + "my-server.ini": serverINI, + "configs/my-server_SandboxVars.lua": sandboxLua, + }) + sets, err := FromZip(data) + if err != nil { + t.Fatal(err) + } + if len(sets) != 1 || sets[0].Name != "my-server" { + t.Fatalf("наборы = %+v", sets) + } + if sets[0].Sandbox == nil { + t.Error("песочница из другой папки не подхвачена") + } +} + +// Несколько вариантов конфигов в одном архиве — человек выбирает сам, поэтому +// набора должно быть два, а не один слипшийся. +func TestFromZipSeveralSets(t *testing.T) { + data := buildZip(t, map[string]string{ + "Server/pvp.ini": serverINI, + "Server/pvp_SandboxVars.lua": sandboxLua, + "Server/pve.ini": serverINI, + "Server/pve_SandboxVars.lua": sandboxLua, + "Server/pve_spawnregions.lua": spawnRegions, + }) + sets, err := FromZip(data) + if err != nil { + t.Fatal(err) + } + if len(sets) != 2 { + t.Fatalf("наборов: %d", len(sets)) + } + // Порядок задан именами: панель показывает их списком. + if sets[0].Name != "pve" || sets[1].Name != "pvp" { + t.Fatalf("порядок наборов: %q, %q", sets[0].Name, sets[1].Name) + } +} + +// Одни точки появления импортировать некуда. +func TestFromZipSpawnRegionsAlone(t *testing.T) { + data := buildZip(t, map[string]string{"Server/x_spawnregions.lua": spawnRegions}) + if _, err := FromZip(data); !errors.Is(err, ErrNoServerFiles) { + t.Fatalf("ожидалась ErrNoServerFiles, получено %v", err) + } +} + +// Имя файла из архива в путь не превращается: берём только последний сегмент, +// поэтому «../» из имени записи никуда не ведёт. +func TestFromZipIgnoresPaths(t *testing.T) { + data := buildZip(t, map[string]string{ + "../../../etc/evil.ini": serverINI, + "..\\..\\windows\\evil_SandboxVars.lua": sandboxLua, + }) + sets, err := FromZip(data) + if err != nil { + t.Fatal(err) + } + for _, set := range sets { + if strings.ContainsAny(set.Name, `/\.`) { + t.Errorf("в имени набора остался путь: %q", set.Name) + } + } +} + +func TestFromZipRejectsGarbage(t *testing.T) { + if _, err := FromZip([]byte("это не архив")); err == nil { + t.Fatal("мусор принят за архив") + } +} diff --git a/internal/pzconfig/ini.go b/internal/pzconfig/ini.go index 62bdfef..d9a3ce0 100644 --- a/internal/pzconfig/ini.go +++ b/internal/pzconfig/ini.go @@ -172,6 +172,16 @@ func SaveINI(path string, ini *INI) error { return writeAtomic(path, ini.Render(), 0o644) } +// SaveRaw кладёт на место файл, который менеджер не разбирает (настройки +// песочницы и точки появления из пакета сборки), с той же страховкой: копия +// прежней версии рядом и атомарная замена. +func SaveRaw(path string, data []byte) error { + if err := backupFile(path); err != nil { + return err + } + return writeAtomic(path, data, 0o644) +} + func backupFile(path string) error { data, err := os.ReadFile(path) if os.IsNotExist(err) { diff --git a/web/static/app.js b/web/static/app.js index 9399d27..c23d14b 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1562,6 +1562,159 @@ $("#profile-add-form").addEventListener("submit", async (event) => { } }); +// --------------------------------------------------------------------------- +// Импорт пресета сборки +// --------------------------------------------------------------------------- + +// Сборки модов раздают готовый комплект настроек одним архивом. Панель сначала +// показывает, что в нём нашла, и только потом что-то пишет: пресет заменяет +// конфиг профиля целиком, и делать это вслепую нельзя. +let presetSets = []; + +// Файл отправляется дважды — на разбор и на импорт. Держать его на сервере +// между запросами не за чем: он уже лежит в браузере. +async function postFile(path, form) { + const response = await fetch(path, { + method: "POST", + headers: { [CSRF_HEADER]: "1" }, + credentials: "same-origin", + body: form, + }); + if (response.status === 401) { + showAuth(false); + throw new Error(t("auth.expired")); + } + const text = await response.text(); + const data = text ? JSON.parse(text) : {}; + if (!response.ok) { + throw new Error(data.error || t("error.http", { status: response.status })); + } + return data; +} + +function presetFile() { + const input = $("#preset-file"); + return input.files && input.files[0] ? input.files[0] : null; +} + +$("#preset-file").addEventListener("change", async () => { + const file = presetFile(); + $("#preset-details").hidden = true; + if (!file) return; + + const form = new FormData(); + form.append("file", file); + toast(t("import.reading")); + try { + const data = await postFile("/api/presets/preview", form); + presetSets = data.presets || []; + renderPresetSets(); + } catch (error) { + fail(error); + } +}); + +function renderPresetSets() { + if (!presetSets.length) return; + + const picker = $("#preset-set"); + picker.innerHTML = presetSets + .map((set) => ``) + .join(""); + // Выбор нужен, только когда в архиве несколько наборов. + $("#preset-pick").hidden = presetSets.length < 2; + + $("#preset-details").hidden = false; + renderPresetSummary(); +} + +function currentPreset() { + const name = $("#preset-set").value; + return presetSets.find((set) => set.name === name) || presetSets[0]; +} + +function renderPresetSummary() { + const set = currentPreset(); + if (!set) return; + + const lines = []; + if (set.server_name) lines.push(t("import.server", { name: set.server_name })); + if (set.has_ini) { + lines.push(t("import.mods", { mods: set.mods, workshop: set.workshop_items })); + if ((set.maps || []).length) lines.push(t("import.maps", { maps: set.maps.join(", ") })); + } else { + lines.push(t("import.noINI")); + } + lines.push(t("import.counts", { settings: set.settings, sandbox: set.sandbox_settings })); + lines.push(t(set.has_spawn_regions ? "import.spawnYes" : "import.spawnNo")); + + $("#preset-summary").replaceChildren(...lines.map((line) => { + const el = document.createElement("div"); + el.textContent = line; + return el; + })); + + // Идентификатор профиля предлагаем по имени файлов из архива: обычно оно и + // называет сборку. + $("#preset-id").value = set.name.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 32); + $("#preset-profile").innerHTML = profiles.list + .map((p) => ``) + .join(""); + $("#preset-profile").value = profiles.active; +} + +$("#preset-set").addEventListener("change", renderPresetSummary); + +$("#preset-target").addEventListener("change", () => { + const existing = $("#preset-target").value === "existing"; + $("#preset-new-fields").hidden = existing; + $("#preset-existing-fields").hidden = !existing; +}); + +$("#btn-preset-import").addEventListener("click", async () => { + const file = presetFile(); + const set = currentPreset(); + if (!file || !set) { + toast(t("import.noFile"), "err"); + return; + } + + const existing = $("#preset-target").value === "existing"; + const profile = existing ? $("#preset-profile").value : $("#preset-id").value.trim(); + if (!confirm(t("import.confirm", { name: set.name, profile }))) return; + + const form = new FormData(); + form.append("file", file); + form.append("preset", set.name); + form.append("target", existing ? "existing" : "new"); + form.append("profile", profile); + form.append("title", set.server_name || set.name); + form.append("keep", $$("#preset-details [data-keep]") + .filter((box) => box.checked) + .map((box) => box.dataset.keep) + .join(",")); + + const button = $("#btn-preset-import"); + button.disabled = true; + try { + const result = await postFile("/api/presets/import", form); + ok(t("import.done", { profile: result.profile, files: (result.files || []).join(", ") })); + if ((result.kept || []).length) { + toast(t("import.kept", { profile: result.kept_from, keys: result.kept.join(", ") })); + } + $("#preset-file").value = ""; + $("#preset-details").hidden = true; + await loadProfiles(); + // Импортированный профиль мог оказаться активным — вкладки показывают + // именно его конфиг. + loadTab(currentTab); + } catch (error) { + fail(error); + } finally { + button.disabled = false; + } +}); + // --------------------------------------------------------------------------- // Бэкапы // --------------------------------------------------------------------------- diff --git a/web/static/i18n.js b/web/static/i18n.js index 8ac965c..7883b8a 100644 --- a/web/static/i18n.js +++ b/web/static/i18n.js @@ -299,6 +299,33 @@ const STRINGS = { "profiles.copyNone": "не копировать", "profiles.create": "Создать профиль", + "import.title": "Импорт пресета сборки", + "import.fileHint": "ZIP-пакет сборки: панель возьмёт из него серверный .ini, настройки песочницы и точки появления, остальное пропустит.", + "import.reading": "Читаю архив…", + "import.set": "Набор из архива", + "import.server": "Сервер: {name}", + "import.mods": "Моды: {mods} · пакеты мастерской: {workshop}", + "import.maps": "Карты: {maps}", + "import.counts": "Настройки сервера: {settings} · песочница: {sandbox}", + "import.spawnYes": "Точки появления: есть", + "import.spawnNo": "Точки появления: нет", + "import.noINI": "Конфига сервера в наборе нет — импортируются только настройки песочницы.", + "import.target": "Куда импортировать", + "import.targetNew": "В новый профиль", + "import.targetExisting": "Поверх существующего профиля", + "import.newID": "Идентификатор нового профиля", + "import.profile": "Профиль", + "import.overwriteHint": "Конфиги профиля будут заменены, прежние останутся рядом с расширением .bak. Мир не трогается.", + "import.keep": "Оставить свои значения вместо тех, что в пресете:", + "import.keepPorts": "Порты и RCON", + "import.keepAccess": "Пароль сервера и объявляемый IP", + "import.keepIdentity": "Имя и описание сервера", + "import.apply": "Импортировать", + "import.noFile": "Выберите ZIP-пакет сборки", + "import.confirm": "Импортировать набор «{name}» в профиль {profile}?\n\nКонфиги профиля будут заменены, прежние останутся рядом с расширением .bak.", + "import.done": "Пресет импортирован в профиль {profile}. Записано файлов: {files}.", + "import.kept": "Свои значения взяты из профиля {profile}: {keys}", + "settings.versionTitle": "Версия панели", "settings.title": "Настройки менеджера", "settings.save": "Сохранить настройки", @@ -596,6 +623,33 @@ const STRINGS = { "profiles.copyNone": "do not copy", "profiles.create": "Create profile", + "import.title": "Import a collection preset", + "import.fileHint": "A collection ZIP package: the panel takes the server .ini, the sandbox settings and the spawn regions out of it and skips the rest.", + "import.reading": "Reading the archive…", + "import.set": "Preset from the archive", + "import.server": "Server: {name}", + "import.mods": "Mods: {mods} · Workshop items: {workshop}", + "import.maps": "Maps: {maps}", + "import.counts": "Server settings: {settings} · sandbox: {sandbox}", + "import.spawnYes": "Spawn regions: included", + "import.spawnNo": "Spawn regions: none", + "import.noINI": "The preset has no server config — only the sandbox settings will be imported.", + "import.target": "Where to import", + "import.targetNew": "Into a new profile", + "import.targetExisting": "Over an existing profile", + "import.newID": "Identifier of the new profile", + "import.profile": "Profile", + "import.overwriteHint": "The profile configs will be replaced, the previous ones stay next to them with a .bak extension. The world is left alone.", + "import.keep": "Keep your own values instead of the ones from the preset:", + "import.keepPorts": "Ports and RCON", + "import.keepAccess": "Server password and announced IP", + "import.keepIdentity": "Server name and description", + "import.apply": "Import", + "import.noFile": "Choose a collection ZIP package", + "import.confirm": "Import the preset \"{name}\" into the profile {profile}?\n\nThe profile configs will be replaced, the previous ones stay next to them with a .bak extension.", + "import.done": "Preset imported into the profile {profile}. Files written: {files}.", + "import.kept": "Your own values were taken from the profile {profile}: {keys}", + "settings.versionTitle": "Panel version", "settings.title": "Manager settings", "settings.save": "Save settings", diff --git a/web/static/index.html b/web/static/index.html index bc3c938..1b2bc42 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -255,6 +255,53 @@
+

+
+

+ + + +
+

diff --git a/web/static/style.css b/web/static/style.css index f0a54e1..0e9cb40 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -570,3 +570,25 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; } margin-top: 18px; width: 100%; } + +/* Импорт пресета: карточка с разбором архива и параметрами импорта. */ +.import-card { margin-bottom: 20px; } +.import-card input[type=file] { + width: auto; + max-width: 100%; + margin: 4px 0 12px; + padding: 8px; + color: var(--text); + background: var(--bg-soft); + border: 1px dashed var(--border); + border-radius: var(--radius); +} +.preset-summary { + margin: 10px 0 14px; + padding: 10px 12px; + background: var(--bg-soft); + border-radius: var(--radius); + font-size: 13px; + line-height: 1.7; +} +.preset-keep { display: flex; flex-wrap: wrap; gap: 14px; margin: 6px 0 14px; }