Files
mrleo1nidandClaude Opus 5 ae8c511a9a
CI / test (push) Successful in 24s
Release / release (push) Successful in 33s
Импорт пресета не терял моды: полный набор и активный профиль
В пакете сборки рядом с комплектом сервера лежит пресет песочницы одиночной
игры. Наборы сортировались по имени, и пресет песочницы часто оказывался
первым — панель предлагала его по умолчанию, а модов и карт в нём нет.
Теперь первым идёт набор с конфигом сервера, а в списке выбора видно, что
внутри каждого.

Вкладки показывают активный профиль, поэтому импорт в новый выглядел так,
будто ничего не подтянулось. Появилась галочка «Сделать этот профиль
активным»; если её снять, панель говорит, где искать импортированное.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 20:21:41 +03:00

285 lines
10 KiB
Go

// 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")
// ErrNoSandboxPreset — загруженный .cfg не похож на пресет песочницы.
var ErrNoSandboxPreset = errors.New("файл не похож на пресет песочницы")
// Files — серверная часть пакета: то, что PZ читает при запуске.
type Files struct {
// Name — имя, под которым файлы лежали в архиве (sophie-server-42-2).
Name string
INI []byte
Sandbox []byte
// SandboxPreset — пресет песочницы одиночной игры (.cfg). Готового файла
// сервера в пакете может не быть: сборки нередко раздают только его.
SandboxPreset []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"`
HasSandboxPreset bool `json:"has_sandbox_preset"`
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
// FromUpload разбирает то, что загрузили в панель: ZIP-пакет сборки или
// одиночный пресет песочницы.
func FromUpload(filename string, data []byte) ([]Files, error) {
if strings.HasSuffix(strings.ToLower(filename), ".cfg") {
if !looksLikeSandboxPreset(data) {
return nil, ErrNoSandboxPreset
}
name := strings.TrimSuffix(path.Base(filename), path.Ext(filename))
return []Files{{Name: name, SandboxPreset: data}}, nil
}
return FromZip(data)
}
// 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
}
// .cfg в архиве хватает и помимо песочницы: списки модов, настройки
// папок. Отличаем по знакомым параметрам.
if kind == kindSandboxPreset && !looksLikeSandboxPreset(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 kindSandboxPreset:
set.SandboxPreset = content
case kindSpawnRegions:
set.SpawnRegions = content
}
}
out := make([]Files, 0, len(sets))
for _, set := range sets {
// Одни точки появления без конфига и песочницы импортировать некуда.
if set.INI == nil && set.Sandbox == nil && set.SandboxPreset == nil {
continue
}
out = append(out, *set)
}
if len(out) == 0 {
return nil, ErrNoServerFiles
}
// Первым идёт самый полный набор: панель предлагает его по умолчанию, а
// одинокий пресет песочницы переносит только значения песочницы — ни модов,
// ни карт в нём нет, и импорт выглядит как «ничего не подтянулось».
sort.Slice(out, func(i, j int) bool {
wi, wj := weight(out[i]), weight(out[j])
if wi != wj {
return wi > wj
}
return out[i].Name < out[j].Name
})
return out, nil
}
// weight — насколько набор полон. Конфиг сервера перевешивает всё остальное:
// только в нём есть список модов, карты и настройки самого сервера.
func weight(f Files) int {
total := 0
if f.INI != nil {
total += 4
}
if f.Sandbox != nil {
total += 2
}
if f.SpawnRegions != nil {
total++
}
return total
}
type kind int
const (
kindOther kind = iota
kindINI
kindSandbox
kindSandboxPreset
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
case strings.HasSuffix(lower, ".cfg"):
return name[:len(name)-len(".cfg")], kindSandboxPreset
}
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,
HasSandboxPreset: f.SandboxPreset != 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())
}
switch {
case f.Sandbox != nil:
s.SandboxSettings = len(pzconfig.ParseSandbox(f.Sandbox).Entries())
case f.SandboxPreset != nil:
s.SandboxSettings = len(ParseSandboxPreset(f.SandboxPreset).Values)
}
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
}