Critical fixes: - Fix Dockerfile: reorder stages so frontend assets embed into Go binary - Fix Go version 1.25 (nonexistent) to 1.24 across Dockerfile, go.mod, CI - Add graceful game server shutdown on SIGTERM/SIGINT - Order startup tasks: updates complete before auto-start - Fix TOCTOU race in UpdateSettings with atomic Update() method Security: - Add optional AUTH_TOKEN bearer auth middleware on API/WS routes - Fix path traversal in DeleteMod using filepath.Rel instead of HasPrefix - Add input validation for IPPort, ServerParameters, ScheduledUpdate Memory safety: - Cap RPT buffer allocation to 64KB to prevent OOM on large logs - Cap GetLog file read to 10MB - Fix context cancel leak in SteamCmdManager.run() - Remove data-raced cancel field in steamcmd.go - Atomic file writes (write-temp-then-rename) across all managers Reliability: - Log save errors in ProcessManager.Stop() - Atomic file writes prevent corruption on crash Tests: - Add mod_manager_test.go (12 tests: ListWorkshopMods, ListLocalMods, BuildUsageMap, RemoveMod, dirSize) - Add scheduler_test.go (6 tests: Start/Stop, Refresh with empty, invalid, valid, and replaced cron expressions) - Add TestRestart to server_process_test.go CI/Docs: - Add -race flag to go test in CI and Makefile - Add npm lint step to CI - Add Go/npm module caching to CI - Update README: prerequisites, AUTH_TOKEN/GIN_MODE/SERVERS_DIR docs, fix manual quickstart to use make build
183 lines
3.5 KiB
Go
183 lines
3.5 KiB
Go
package services
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"arma3-web-server/internal/models"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type ModlistManager struct {
|
|
dir string
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
func NewModlistManager(dataDir string) *ModlistManager {
|
|
return &ModlistManager{
|
|
dir: filepath.Join(dataDir, "modlists"),
|
|
}
|
|
}
|
|
|
|
func (mm *ModlistManager) path(id string) string {
|
|
return filepath.Join(mm.dir, id+".json")
|
|
}
|
|
|
|
func (mm *ModlistManager) List() ([]models.ModlistListItem, error) {
|
|
mm.mu.RLock()
|
|
defer mm.mu.RUnlock()
|
|
|
|
entries, err := os.ReadDir(mm.dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return []models.ModlistListItem{}, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
var items []models.ModlistListItem
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(mm.dir, e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var m models.Modlist
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
continue
|
|
}
|
|
items = append(items, models.ModlistListItem{
|
|
ID: m.ID,
|
|
Name: m.Name,
|
|
ModCount: len(m.Mods),
|
|
CreatedAt: m.CreatedAt,
|
|
UpdatedAt: m.UpdatedAt,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (mm *ModlistManager) Get(id string) (*models.Modlist, error) {
|
|
mm.mu.RLock()
|
|
defer mm.mu.RUnlock()
|
|
|
|
data, err := os.ReadFile(mm.path(id))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("modlist not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
var m models.Modlist
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
func (mm *ModlistManager) Create(name string) (*models.Modlist, error) {
|
|
mm.mu.Lock()
|
|
defer mm.mu.Unlock()
|
|
|
|
now := time.Now().UTC()
|
|
m := &models.Modlist{
|
|
ID: uuid.New().String(),
|
|
Name: name,
|
|
Mods: []models.ModEntry{},
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.MkdirAll(mm.dir, 0755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeFileAtomic(mm.path(m.ID), data); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (mm *ModlistManager) Update(id, name string, mods []models.ModEntry) (*models.Modlist, error) {
|
|
mm.mu.Lock()
|
|
defer mm.mu.Unlock()
|
|
|
|
m, err := mm.getLocked(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
m.Name = name
|
|
m.Mods = mods
|
|
m.UpdatedAt = time.Now().UTC()
|
|
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeFileAtomic(mm.path(id), data); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (mm *ModlistManager) Delete(id string) error {
|
|
mm.mu.Lock()
|
|
defer mm.mu.Unlock()
|
|
return os.Remove(mm.path(id))
|
|
}
|
|
|
|
func (mm *ModlistManager) Duplicate(id, newName string) (*models.Modlist, error) {
|
|
mm.mu.Lock()
|
|
defer mm.mu.Unlock()
|
|
|
|
orig, err := mm.getLocked(id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
dup := &models.Modlist{
|
|
ID: uuid.New().String(),
|
|
Name: newName,
|
|
Mods: orig.Mods,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
data, err := json.MarshalIndent(dup, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := writeFileAtomic(mm.path(dup.ID), data); err != nil {
|
|
return nil, err
|
|
}
|
|
return dup, nil
|
|
}
|
|
|
|
func (mm *ModlistManager) getLocked(id string) (*models.Modlist, error) {
|
|
data, err := os.ReadFile(mm.path(id))
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("modlist not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
var m models.Modlist
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
return &m, nil
|
|
}
|