// 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 }