Импорт пресетов сборок из ZIP-пакета
Крупные сборки раздают готовый комплект настроек архивом. Панель разбирает его сама: находит серверный .ini, песочницу и точки появления, показывает, что нашла (сколько модов, карт, параметров), и только по подтверждению раскладывает файлы в новый или существующий профиль. Порты, пароли и название сервера при этом можно оставить свои — они относятся к машине, а не к сборке; у нового профиля они берутся из активного. Прежние конфиги остаются рядом как .bak, мир не трогается, а импорт в работающий профиль отклоняется: PZ перезаписал бы файлы своими при остановке. Посторонние .ini из архива (настройки модов) за конфиг сервера не принимаются: он опознаётся по знакомым параметрам. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@
|
|||||||
самого конфига, поиск идёт и по названию, и по описанию.
|
самого конфига, поиск идёт и по названию, и по описанию.
|
||||||
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
- **Мод-менеджер** — моды списком с галочками и перетаскиванием порядка
|
||||||
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
загрузки, добавление по ссылке и разворачивание коллекций Steam целиком.
|
||||||
|
- **Импорт пресетов сборок** — ZIP-пакет сборки загружается прямо в панель:
|
||||||
|
конфиг, песочница и точки появления попадают в новый или существующий
|
||||||
|
профиль, свои порты и пароли при этом сохраняются.
|
||||||
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
- **Бэкапы** — ручные и по расписанию, отдельно по каждому профилю, с
|
||||||
ротацией, скачиванием и восстановлением (перед восстановлением
|
ротацией, скачиванием и восстановлением (перед восстановлением
|
||||||
автоматически снимается страховочная копия).
|
автоматически снимается страховочная копия).
|
||||||
@@ -178,6 +181,40 @@ curl -fsSL https://gitea.hsrv.site/mrleo1nid/pzmanager/raw/branch/main/deploy/ge
|
|||||||
менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он
|
менеджера в консоли. Вывод самого игрового сервера остаётся как есть — он
|
||||||
приходит от Project Zomboid.
|
приходит от 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` — что
|
Project Zomboid хранит моды в двух строках конфига: `WorkshopItems` — что
|
||||||
|
|||||||
@@ -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,
|
"PUT /api/profiles/{id}": s.handleUpdateProfile,
|
||||||
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
|
"DELETE /api/profiles/{id}": s.handleDeleteProfile,
|
||||||
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
|
"POST /api/profiles/{id}/activate": s.handleActivateProfile,
|
||||||
|
"POST /api/presets/preview": s.handlePreviewPreset,
|
||||||
|
"POST /api/presets/import": s.handleImportPreset,
|
||||||
"GET /api/mods": s.handleGetMods,
|
"GET /api/mods": s.handleGetMods,
|
||||||
"PUT /api/mods": s.handlePutMods,
|
"PUT /api/mods": s.handlePutMods,
|
||||||
"POST /api/mods/lookup": s.handleLookupMods,
|
"POST /api/mods/lookup": s.handleLookupMods,
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ func ValidateProfileID(id string) error {
|
|||||||
return nil
|
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.
|
// Config — всё, что менеджеру нужно знать о хосте и о серверах PZ.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
|
// Listen — адрес веб-панели. Для доступа только через SSH-туннель
|
||||||
|
|||||||
@@ -71,13 +71,18 @@ var messages = map[string]string{
|
|||||||
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
|
"Сервер не ответил на quit — отправляю SIGTERM": "The server did not answer quit — sending SIGTERM",
|
||||||
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
|
"Сервер завис — отправляю SIGKILL": "The server hung — sending SIGKILL",
|
||||||
"Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running",
|
"Сервер не сообщил о готовности — считаю его запущенным": "The server did not report that it is ready — treating it as running",
|
||||||
"Автоперезапуск отменён": "Automatic restart cancelled",
|
"в архиве нет серверных файлов Project Zomboid": "the archive has no Project Zomboid server files",
|
||||||
"Посмотрите ошибку выше и запустите сервер вручную.": "Check the error above and start the server manually.",
|
"распакованный архив слишком велик": "the unpacked archive is too large",
|
||||||
"Запускаю обновление — оно идёт отдельной службой и переживёт перезапуск панели": "Starting the update — it runs as a separate service and survives the panel restart",
|
"файл не выбран": "no file selected",
|
||||||
"Подробности: journalctl -u pzmanager-update -f": "Details: journalctl -u pzmanager-update -f",
|
"ожидается ZIP-архив пакета настроек": "a ZIP package of settings is expected",
|
||||||
"Перезапускаю панель": "Restarting the panel",
|
"в архиве несколько наборов настроек — выберите один": "the archive holds several sets of settings — pick one",
|
||||||
"обновлением": "the update",
|
"Автоперезапуск отменён": "Automatic restart cancelled",
|
||||||
"перезапуском": "the restart",
|
"Посмотрите ошибку выше и запустите сервер вручную.": "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{
|
var formats = [][2]string{
|
||||||
@@ -174,6 +179,16 @@ var formats = [][2]string{
|
|||||||
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
|
{"Останавливаю игровой сервер перед %s", "Stopping the game server before %s"},
|
||||||
{"Плановый бэкап не запущен: %s", "The scheduled backup did not start: %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)", "Creating the archive %s (profile %s)"},
|
||||||
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
|
{"Архив готов: %s (%s, файлов: %s)", "Archive ready: %s (%s, files: %s)"},
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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("мусор принят за архив")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,6 +172,16 @@ func SaveINI(path string, ini *INI) error {
|
|||||||
return writeAtomic(path, ini.Render(), 0o644)
|
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 {
|
func backupFile(path string) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -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) => `<option value="${escapeHTML(set.name)}">${escapeHTML(set.name)}</option>`)
|
||||||
|
.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) => `<option value="${escapeHTML(p.id)}">${escapeHTML(p.title || p.id)}</option>`)
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Бэкапы
|
// Бэкапы
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -299,6 +299,33 @@ const STRINGS = {
|
|||||||
"profiles.copyNone": "не копировать",
|
"profiles.copyNone": "не копировать",
|
||||||
"profiles.create": "Создать профиль",
|
"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.versionTitle": "Версия панели",
|
||||||
"settings.title": "Настройки менеджера",
|
"settings.title": "Настройки менеджера",
|
||||||
"settings.save": "Сохранить настройки",
|
"settings.save": "Сохранить настройки",
|
||||||
@@ -596,6 +623,33 @@ const STRINGS = {
|
|||||||
"profiles.copyNone": "do not copy",
|
"profiles.copyNone": "do not copy",
|
||||||
"profiles.create": "Create profile",
|
"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.versionTitle": "Panel version",
|
||||||
"settings.title": "Manager settings",
|
"settings.title": "Manager settings",
|
||||||
"settings.save": "Save settings",
|
"settings.save": "Save settings",
|
||||||
|
|||||||
@@ -255,6 +255,53 @@
|
|||||||
|
|
||||||
<div id="profile-cards" class="cards"></div>
|
<div id="profile-cards" class="cards"></div>
|
||||||
|
|
||||||
|
<h3 data-i18n="import.title"></h3>
|
||||||
|
<div class="card import-card">
|
||||||
|
<p class="muted small" data-i18n="import.fileHint"></p>
|
||||||
|
<input type="file" id="preset-file" accept=".zip">
|
||||||
|
|
||||||
|
<div id="preset-details" hidden>
|
||||||
|
<div class="cfg-field" id="preset-pick" hidden>
|
||||||
|
<label for="preset-set" data-i18n="import.set"></label>
|
||||||
|
<select id="preset-set"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preset-summary" id="preset-summary"></div>
|
||||||
|
|
||||||
|
<div class="fields">
|
||||||
|
<div class="cfg-field">
|
||||||
|
<label for="preset-target" data-i18n="import.target"></label>
|
||||||
|
<select id="preset-target">
|
||||||
|
<option value="new" data-i18n="import.targetNew"></option>
|
||||||
|
<option value="existing" data-i18n="import.targetExisting"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-field" id="preset-new-fields">
|
||||||
|
<label for="preset-id" data-i18n="import.newID"></label>
|
||||||
|
<input type="text" id="preset-id" autocomplete="off">
|
||||||
|
<div class="hint" data-i18n="profiles.idHint"></div>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-field" id="preset-existing-fields" hidden>
|
||||||
|
<label for="preset-profile" data-i18n="import.profile"></label>
|
||||||
|
<select id="preset-profile"></select>
|
||||||
|
<div class="hint" data-i18n="import.overwriteHint"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted small" data-i18n="import.keep"></p>
|
||||||
|
<div class="preset-keep">
|
||||||
|
<label class="checkbox"><input type="checkbox" data-keep="ports" checked>
|
||||||
|
<span data-i18n="import.keepPorts"></span></label>
|
||||||
|
<label class="checkbox"><input type="checkbox" data-keep="access" checked>
|
||||||
|
<span data-i18n="import.keepAccess"></span></label>
|
||||||
|
<label class="checkbox"><input type="checkbox" data-keep="identity" checked>
|
||||||
|
<span data-i18n="import.keepIdentity"></span></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="btn-preset-import" data-i18n="import.apply"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 data-i18n="profiles.newTitle"></h3>
|
<h3 data-i18n="profiles.newTitle"></h3>
|
||||||
<form id="profile-add-form" class="fields">
|
<form id="profile-add-form" class="fields">
|
||||||
<div class="cfg-field">
|
<div class="cfg-field">
|
||||||
|
|||||||
@@ -570,3 +570,25 @@ input.input-sm, .input-sm { width: auto; padding: 6px 10px; font-size: 13px; }
|
|||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
width: 100%;
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user