Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
// Package pzconfig читает и правит конфиги Project Zomboid: servertest.ini и
|
||||
// SandboxVars.lua.
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// iniLine — одна строка файла. Пустой Key означает комментарий или пустую
|
||||
// строку, которую нужно сохранить как есть.
|
||||
type iniLine struct {
|
||||
Key string
|
||||
Value string
|
||||
Raw string
|
||||
}
|
||||
|
||||
// INI — конфиг PZ в виде плоских key=value. Разбор сохраняет исходный порядок
|
||||
// строк и комментарии, чтобы правка одного параметра не переписывала файл.
|
||||
type INI struct {
|
||||
lines []iniLine
|
||||
index map[string]int
|
||||
}
|
||||
|
||||
// ParseINI разбирает содержимое конфига.
|
||||
func ParseINI(data []byte) *INI {
|
||||
ini := &INI{index: make(map[string]int)}
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
key, value, found := strings.Cut(raw, "=")
|
||||
if !found || trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
ini.lines = append(ini.lines, iniLine{Raw: raw})
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
ini.index[key] = len(ini.lines)
|
||||
ini.lines = append(ini.lines, iniLine{Key: key, Value: strings.TrimSpace(value)})
|
||||
}
|
||||
return ini
|
||||
}
|
||||
|
||||
// LoadINI читает конфиг с диска.
|
||||
func LoadINI(path string) (*INI, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseINI(data), nil
|
||||
}
|
||||
|
||||
// Get возвращает значение параметра.
|
||||
func (i *INI) Get(key string) string {
|
||||
idx, ok := i.index[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return i.lines[idx].Value
|
||||
}
|
||||
|
||||
// Has сообщает, есть ли такой параметр в файле.
|
||||
func (i *INI) Has(key string) bool {
|
||||
_, ok := i.index[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Set меняет значение, добавляя параметр в конец, если его ещё нет.
|
||||
func (i *INI) Set(key, value string) {
|
||||
// Перевод строки в значении сломал бы формат файла.
|
||||
value = strings.NewReplacer("\n", " ", "\r", " ").Replace(value)
|
||||
if idx, ok := i.index[key]; ok {
|
||||
i.lines[idx].Value = value
|
||||
return
|
||||
}
|
||||
i.index[key] = len(i.lines)
|
||||
i.lines = append(i.lines, iniLine{Key: key, Value: value})
|
||||
}
|
||||
|
||||
// Keys возвращает имена параметров в порядке их появления в файле.
|
||||
func (i *INI) Keys() []string {
|
||||
out := make([]string, 0, len(i.index))
|
||||
for _, l := range i.lines {
|
||||
if l.Key != "" {
|
||||
out = append(out, l.Key)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Map отдаёт все параметры в виде словаря.
|
||||
func (i *INI) Map() map[string]string {
|
||||
out := make(map[string]string, len(i.index))
|
||||
for _, l := range i.lines {
|
||||
if l.Key != "" {
|
||||
out[l.Key] = l.Value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Render собирает файл обратно в текст.
|
||||
func (i *INI) Render() []byte {
|
||||
var buf bytes.Buffer
|
||||
for n, l := range i.lines {
|
||||
if l.Key == "" {
|
||||
buf.WriteString(l.Raw)
|
||||
} else {
|
||||
buf.WriteString(l.Key)
|
||||
buf.WriteByte('=')
|
||||
buf.WriteString(l.Value)
|
||||
}
|
||||
if n < len(i.lines)-1 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// SaveINI записывает конфиг атомарно, предварительно сняв копию прежней
|
||||
// версии рядом (.bak): испорченный конфиг остановит сервер.
|
||||
func SaveINI(path string, ini *INI) error {
|
||||
if err := backupFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(path, ini.Render(), 0o644)
|
||||
}
|
||||
|
||||
func backupFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path+".bak", data, 0o644)
|
||||
}
|
||||
|
||||
func writeAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return fmt.Errorf("записать %s: %w", tmp, err)
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// javaLauncherFile — файл, из которого сервер берёт аргументы JVM.
|
||||
const javaLauncherFile = "ProjectZomboid64.json"
|
||||
|
||||
var xmxRe = regexp.MustCompile(`^-Xm[sx]`)
|
||||
|
||||
// SetJavaMemory прописывает лимит памяти JVM в ProjectZomboid64.json. Именно
|
||||
// оттуда start-server.sh берёт -Xmx, поэтому менять размер памяти в аргументах
|
||||
// запуска бесполезно.
|
||||
func SetJavaMemory(serverDir, memory string) error {
|
||||
memory = strings.TrimSpace(memory)
|
||||
if memory == "" {
|
||||
return nil
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9]+[kKmMgG]$`).MatchString(memory) {
|
||||
return fmt.Errorf("объём памяти должен выглядеть как 4g или 8192m, получено %q", memory)
|
||||
}
|
||||
|
||||
path := filepath.Join(serverDir, javaLauncherFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("прочитать %s: %w", path, err)
|
||||
}
|
||||
|
||||
// Разбираем в map, чтобы не потерять поля, о которых мы не знаем.
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return fmt.Errorf("разобрать %s: %w", path, err)
|
||||
}
|
||||
|
||||
args, _ := doc["vmArgs"].([]any)
|
||||
updated := make([]any, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
if str, ok := a.(string); ok && xmxRe.MatchString(str) {
|
||||
continue
|
||||
}
|
||||
updated = append(updated, a)
|
||||
}
|
||||
// -Xms равным -Xmx: JVM сразу забирает память и не тратит время на рост
|
||||
// кучи в момент, когда на сервере много игроков.
|
||||
updated = append(updated, "-Xms"+memory, "-Xmx"+memory)
|
||||
doc["vmArgs"] = updated
|
||||
|
||||
out, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := backupFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(path, out, 0o644)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package pzconfig
|
||||
|
||||
import "strings"
|
||||
|
||||
// Ключи servertest.ini, в которых живут моды.
|
||||
const (
|
||||
KeyMods = "Mods"
|
||||
KeyWorkshopItems = "WorkshopItems"
|
||||
KeyMap = "Map"
|
||||
)
|
||||
|
||||
// SplitList разбирает список PZ вида "a;b;c" в срез без пустых элементов.
|
||||
func SplitList(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return []string{}
|
||||
}
|
||||
parts := strings.Split(value, ";")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// JoinList собирает список обратно в формат PZ, убирая дубликаты и сохраняя
|
||||
// порядок: он важен, моды грузятся именно в нём.
|
||||
func JoinList(items []string) string {
|
||||
seen := make(map[string]bool, len(items))
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return strings.Join(out, ";")
|
||||
}
|
||||
|
||||
// Mods возвращает текущие списки модов сервера.
|
||||
func (i *INI) Mods() (modIDs, workshopIDs []string) {
|
||||
return SplitList(i.Get(KeyMods)), SplitList(i.Get(KeyWorkshopItems))
|
||||
}
|
||||
|
||||
// SetMods записывает списки модов обратно в конфиг.
|
||||
func (i *INI) SetMods(modIDs, workshopIDs []string) {
|
||||
i.Set(KeyMods, JoinList(modIDs))
|
||||
i.Set(KeyWorkshopItems, JoinList(workshopIDs))
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const sampleINI = `# Comment line
|
||||
PublicName=My Server
|
||||
Public=true
|
||||
|
||||
MaxPlayers=32
|
||||
Mods=modA;modB
|
||||
WorkshopItems=111;222
|
||||
`
|
||||
|
||||
func TestINIPreservesLayout(t *testing.T) {
|
||||
ini := ParseINI([]byte(sampleINI))
|
||||
|
||||
if got := ini.Get("PublicName"); got != "My Server" {
|
||||
t.Fatalf("PublicName = %q, ожидалось %q", got, "My Server")
|
||||
}
|
||||
// Правка одного ключа не должна трогать комментарии и пустые строки.
|
||||
ini.Set("MaxPlayers", "64")
|
||||
|
||||
got := string(ini.Render())
|
||||
if !strings.HasPrefix(got, "# Comment line\n") {
|
||||
t.Errorf("комментарий потерян:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "\n\nMaxPlayers=64\n") {
|
||||
t.Errorf("пустая строка или новое значение потеряны:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "MaxPlayers=32") {
|
||||
t.Errorf("старое значение осталось:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestINIAddsMissingKey(t *testing.T) {
|
||||
ini := ParseINI([]byte("A=1"))
|
||||
ini.Set("B", "2")
|
||||
if got := ini.Get("B"); got != "2" {
|
||||
t.Fatalf("B = %q", got)
|
||||
}
|
||||
if got := string(ini.Render()); got != "A=1\nB=2" {
|
||||
t.Fatalf("render = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestINIRejectsNewlineInValue(t *testing.T) {
|
||||
ini := ParseINI([]byte("A=1"))
|
||||
ini.Set("A", "нет\nпереносам")
|
||||
if strings.Contains(string(ini.Render()), "\nп") {
|
||||
t.Fatalf("перенос строки попал в значение: %q", ini.Get("A"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModsRoundTrip(t *testing.T) {
|
||||
ini := ParseINI([]byte(sampleINI))
|
||||
mods, workshop := ini.Mods()
|
||||
if len(mods) != 2 || mods[0] != "modA" {
|
||||
t.Fatalf("mods = %v", mods)
|
||||
}
|
||||
if len(workshop) != 2 || workshop[1] != "222" {
|
||||
t.Fatalf("workshop = %v", workshop)
|
||||
}
|
||||
|
||||
// Дубликаты отбрасываются, порядок сохраняется.
|
||||
ini.SetMods([]string{"modB", "modA", "modB"}, []string{"222"})
|
||||
if got := ini.Get(KeyMods); got != "modB;modA" {
|
||||
t.Fatalf("Mods = %q", got)
|
||||
}
|
||||
if got := ini.Get(KeyWorkshopItems); got != "222" {
|
||||
t.Fatalf("WorkshopItems = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitListIgnoresBlanks(t *testing.T) {
|
||||
if got := SplitList(" a ; ; b;"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||
t.Fatalf("SplitList = %v", got)
|
||||
}
|
||||
if got := SplitList(" "); len(got) != 0 {
|
||||
t.Fatalf("пустая строка дала %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
const sampleSandbox = `SandboxVars = {
|
||||
VERSION = 5,
|
||||
Zombies = 3,
|
||||
Speed = 2,
|
||||
ZombieLore = {
|
||||
Speed = 2,
|
||||
Strength = 2,
|
||||
},
|
||||
WaterShutModifier = 14,
|
||||
}
|
||||
`
|
||||
|
||||
func TestSandboxParsesNestedPaths(t *testing.T) {
|
||||
sb := ParseSandbox([]byte(sampleSandbox))
|
||||
|
||||
if v, ok := sb.Get("Zombies"); !ok || v != "3" {
|
||||
t.Fatalf("Zombies = %q (найдено: %v)", v, ok)
|
||||
}
|
||||
// Одноимённые ключи на разных уровнях не должны перетирать друг друга.
|
||||
if v, ok := sb.Get("ZombieLore.Speed"); !ok || v != "2" {
|
||||
t.Fatalf("ZombieLore.Speed = %q (найдено: %v)", v, ok)
|
||||
}
|
||||
if v, ok := sb.Get("Speed"); !ok || v != "2" {
|
||||
t.Fatalf("Speed верхнего уровня = %q (найдено: %v)", v, ok)
|
||||
}
|
||||
if v, ok := sb.Get("WaterShutModifier"); !ok || v != "14" {
|
||||
t.Fatalf("WaterShutModifier после закрытия таблицы = %q (найдено: %v)", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxSetKeepsFormatting(t *testing.T) {
|
||||
sb := ParseSandbox([]byte(sampleSandbox))
|
||||
if err := sb.Set("ZombieLore.Speed", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(sb.Render())
|
||||
|
||||
if !strings.Contains(got, " Speed = 1,") {
|
||||
t.Errorf("отступ или запятая потеряны:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, " Speed = 2,") {
|
||||
t.Errorf("параметр верхнего уровня изменён по ошибке:\n%s", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "}\n") {
|
||||
t.Errorf("хвост файла изменён:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxRejectsUnknownAndBrokenValues(t *testing.T) {
|
||||
sb := ParseSandbox([]byte(sampleSandbox))
|
||||
if err := sb.Set("НетТакого", "1"); err == nil {
|
||||
t.Error("ожидалась ошибка для неизвестного параметра")
|
||||
}
|
||||
if err := sb.Set("Zombies", "{ 1, 2 }"); err == nil {
|
||||
t.Error("ожидалась ошибка для значения с фигурными скобками")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSandboxEntriesFollowFileOrder(t *testing.T) {
|
||||
entries := ParseSandbox([]byte(sampleSandbox)).Entries()
|
||||
want := []string{"VERSION", "Zombies", "Speed", "ZombieLore.Speed", "ZombieLore.Strength", "WaterShutModifier"}
|
||||
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("получено %d параметров, ожидалось %d: %v", len(entries), len(want), entries)
|
||||
}
|
||||
for i, path := range want {
|
||||
if entries[i].Path != path {
|
||||
t.Errorf("entries[%d] = %q, ожидалось %q", i, entries[i].Path, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SandboxEntry — один параметр песочницы. Path включает вложенные таблицы
|
||||
// (например ZombieLore.Speed), Value хранится как в файле.
|
||||
type SandboxEntry struct {
|
||||
Path string `json:"path"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Sandbox — разбор <server>_SandboxVars.lua. Файл генерируется игрой и имеет
|
||||
// предсказуемый вид (одна пара ключ-значение на строку), поэтому вместо
|
||||
// полноценного интерпретатора Lua достаточно построчного разбора: он
|
||||
// гарантирует, что при сохранении файл останется байт-в-байт прежним, кроме
|
||||
// изменённых значений.
|
||||
type Sandbox struct {
|
||||
lines []string
|
||||
// index сопоставляет путь параметра с номером строки.
|
||||
index map[string]int
|
||||
}
|
||||
|
||||
var (
|
||||
sandboxAssignRe = regexp.MustCompile(`^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)(,?)\s*$`)
|
||||
sandboxCloseRe = regexp.MustCompile(`^\s*\}`)
|
||||
)
|
||||
|
||||
// ParseSandbox разбирает содержимое SandboxVars.lua.
|
||||
func ParseSandbox(data []byte) *Sandbox {
|
||||
s := &Sandbox{index: make(map[string]int)}
|
||||
text := strings.ReplaceAll(string(data), "\r\n", "\n")
|
||||
s.lines = strings.Split(text, "\n")
|
||||
|
||||
var stack []string
|
||||
for n, line := range s.lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "--") {
|
||||
continue
|
||||
}
|
||||
if sandboxCloseRe.MatchString(line) {
|
||||
if len(stack) > 0 {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
m := sandboxAssignRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
name, value := m[2], strings.TrimSpace(m[3])
|
||||
if strings.HasPrefix(value, "{") {
|
||||
// Начало вложенной таблицы: сам SandboxVars = { тоже сюда попадёт,
|
||||
// но его имя в путях не нужно.
|
||||
if name != "SandboxVars" {
|
||||
stack = append(stack, name)
|
||||
} else {
|
||||
stack = stack[:0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
path := name
|
||||
if len(stack) > 0 {
|
||||
path = strings.Join(stack, ".") + "." + name
|
||||
}
|
||||
s.index[path] = n
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// LoadSandbox читает файл песочницы с диска.
|
||||
func LoadSandbox(path string) (*Sandbox, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseSandbox(data), nil
|
||||
}
|
||||
|
||||
// Entries возвращает все параметры в порядке следования в файле.
|
||||
func (s *Sandbox) Entries() []SandboxEntry {
|
||||
out := make([]SandboxEntry, 0, len(s.index))
|
||||
// Обходим строки, а не карту: порядок в файле осмысленный.
|
||||
byLine := make(map[int]string, len(s.index))
|
||||
for path, line := range s.index {
|
||||
byLine[line] = path
|
||||
}
|
||||
for n := range s.lines {
|
||||
path, ok := byLine[n]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, SandboxEntry{Path: path, Value: s.valueAt(n)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Sandbox) valueAt(line int) string {
|
||||
m := sandboxAssignRe.FindStringSubmatch(s.lines[line])
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(m[3])
|
||||
}
|
||||
|
||||
// Get возвращает значение параметра по пути.
|
||||
func (s *Sandbox) Get(path string) (string, bool) {
|
||||
n, ok := s.index[path]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return s.valueAt(n), true
|
||||
}
|
||||
|
||||
// Set меняет значение существующего параметра. Новые параметры не добавляются:
|
||||
// игра всё равно игнорирует незнакомые ключи.
|
||||
func (s *Sandbox) Set(path, value string) error {
|
||||
n, ok := s.index[path]
|
||||
if !ok {
|
||||
return fmt.Errorf("параметр %q не найден в SandboxVars", path)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.ContainsAny(value, "\n\r{}") {
|
||||
return fmt.Errorf("недопустимое значение для %q", path)
|
||||
}
|
||||
m := sandboxAssignRe.FindStringSubmatch(s.lines[n])
|
||||
if m == nil {
|
||||
return fmt.Errorf("не удалось разобрать строку параметра %q", path)
|
||||
}
|
||||
indent, name, comma := m[1], m[2], m[4]
|
||||
s.lines[n] = fmt.Sprintf("%s%s = %s%s", indent, name, value, comma)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render собирает файл обратно.
|
||||
func (s *Sandbox) Render() []byte {
|
||||
var buf bytes.Buffer
|
||||
for n, line := range s.lines {
|
||||
buf.WriteString(line)
|
||||
if n < len(s.lines)-1 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// SaveSandbox пишет файл песочницы, сохранив предыдущую версию в .bak.
|
||||
func SaveSandbox(path string, s *Sandbox) error {
|
||||
if err := backupFile(path); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(path, s.Render(), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
|
||||
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
|
||||
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
||||
type WorkshopItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Preview string `json:"preview"`
|
||||
Description string `json:"description"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
|
||||
// модов на странице обновляется часто, а названия не меняются.
|
||||
type WorkshopClient struct {
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedItem
|
||||
}
|
||||
|
||||
type cachedItem struct {
|
||||
item WorkshopItem
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// cacheTTL — как долго доверять закешированному названию мода.
|
||||
const cacheTTL = 12 * time.Hour
|
||||
|
||||
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
|
||||
func NewWorkshopClient() *WorkshopClient {
|
||||
return &WorkshopClient{
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
cache: make(map[string]cachedItem),
|
||||
}
|
||||
}
|
||||
|
||||
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
|
||||
// возвращает записи с заполненным полем Error — панель всё равно покажет
|
||||
// список, просто без названий.
|
||||
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
|
||||
out := make([]WorkshopItem, 0, len(ids))
|
||||
missing := make([]string, 0, len(ids))
|
||||
|
||||
c.mu.Lock()
|
||||
for _, id := range ids {
|
||||
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
|
||||
out = append(out, entry.item)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if len(missing) > 0 {
|
||||
fetched, err := c.fetch(ctx, missing)
|
||||
if err != nil {
|
||||
for _, id := range missing {
|
||||
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
|
||||
}
|
||||
} else {
|
||||
c.mu.Lock()
|
||||
for _, item := range fetched {
|
||||
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
out = append(out, fetched...)
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем в том же порядке, в каком просили.
|
||||
byID := make(map[string]WorkshopItem, len(out))
|
||||
for _, item := range out {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
ordered := make([]WorkshopItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if item, ok := byID[id]; ok {
|
||||
ordered = append(ordered, item)
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
|
||||
form := url.Values{}
|
||||
form.Set("itemcount", strconv.Itoa(len(ids)))
|
||||
for n, id := range ids {
|
||||
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Steam недоступен: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Response struct {
|
||||
PublishedFileDetails []struct {
|
||||
PublishedFileID string `json:"publishedfileid"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
Result int `json:"result"`
|
||||
} `json:"publishedfiledetails"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
|
||||
}
|
||||
|
||||
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
|
||||
for _, d := range payload.Response.PublishedFileDetails {
|
||||
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
|
||||
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
|
||||
if d.Result != 1 {
|
||||
item.Error = "мод не найден в мастерской"
|
||||
}
|
||||
if len(d.Description) > 400 {
|
||||
d.Description = d.Description[:400] + "…"
|
||||
}
|
||||
item.Description = d.Description
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
Reference in New Issue
Block a user