Initial commit

This commit is contained in:
MrFastwind
2026-07-06 01:29:23 +02:00
commit b853aead8c
48 changed files with 5822 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
type createConfigInput struct {
Name string `json:"name" binding:"required"`
Content string `json:"content"`
}
type updateConfigInput struct {
Content string `json:"content" binding:"required"`
}
type duplicateConfigInput struct {
NewName string `json:"new_name" binding:"required"`
}
func (h *Handler) ListConfigs(c *gin.Context) {
configs, err := h.configs.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, configs)
}
func validateConfigName(name string) bool {
return name != "" && !strings.ContainsAny(name, "/\\")
}
func (h *Handler) CreateConfig(c *gin.Context) {
var input createConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if !validateConfigName(input.Name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"})
return
}
if err := h.configs.Create(input.Name, input.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"name": input.Name})
}
func (h *Handler) GetConfig(c *gin.Context) {
name := c.Param("name")
if !validateConfigName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"})
return
}
content, err := h.configs.Get(name)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, content)
}
func (h *Handler) UpdateConfig(c *gin.Context) {
var input updateConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name := c.Param("name")
if !validateConfigName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"})
return
}
if err := h.configs.Update(name, input.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (h *Handler) DeleteConfig(c *gin.Context) {
name := c.Param("name")
if !validateConfigName(name) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"})
return
}
if err := h.configs.Delete(name); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) DuplicateConfig(c *gin.Context) {
var input duplicateConfigInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name := c.Param("name")
if !validateConfigName(name) || !validateConfigName(input.NewName) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config name"})
return
}
if err := h.configs.Duplicate(name, input.NewName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"name": input.NewName})
}

View File

@@ -0,0 +1,196 @@
package api
import (
"io"
"log"
"net/http"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func (h *Handler) StreamLogs(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("websocket upgrade error: %v", err)
return
}
defer conn.Close()
clientID := conn.RemoteAddr().String()
ch := h.streamer.Subscribe("server", clientID)
defer h.streamer.Unsubscribe("server", clientID)
for line := range ch {
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
break
}
}
}
func (h *Handler) ListLogs(c *gin.Context) {
profilesDir := h.process.ProfilesDir()
files := h.collectLogFiles(profilesDir, "")
logsDir := filepath.Join(profilesDir, "logs")
if fi, err := os.Stat(logsDir); err == nil && fi.IsDir() {
files = append(files, h.collectLogFiles(logsDir, "logs")...)
}
slices.Sort(files)
if files == nil {
files = []string{}
}
c.JSON(http.StatusOK, files)
}
func (h *Handler) collectLogFiles(dir, prefix string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var files []string
for _, e := range entries {
if !e.IsDir() {
ext := filepath.Ext(e.Name())
if ext == ".log" || ext == ".rpt" || e.Name() == "script.log" {
if prefix != "" {
files = append(files, prefix+"/"+e.Name())
} else {
files = append(files, e.Name())
}
}
}
}
return files
}
func (h *Handler) StreamSteamCMDLogs(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("websocket upgrade error: %v", err)
return
}
defer conn.Close()
clientID := conn.RemoteAddr().String()
ch := h.streamer.Subscribe("steamcmd", clientID)
defer h.streamer.Unsubscribe("steamcmd", clientID)
for line := range ch {
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
break
}
}
}
func (h *Handler) StreamRPTLogs(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("websocket upgrade error: %v", err)
return
}
defer conn.Close()
profilesDir := h.process.ProfilesDir()
var currentPath string
var currentOffset int64
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
path := findLatestRPT(profilesDir)
if path != currentPath {
currentPath = path
currentOffset = 0
if currentPath == "" {
continue
}
conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---"))
}
if currentPath == "" {
continue
}
fi, err := os.Stat(currentPath)
if err != nil {
currentPath = ""
continue
}
if fi.Size() <= currentOffset {
continue
}
f, err := os.Open(currentPath)
if err != nil {
currentPath = ""
continue
}
f.Seek(currentOffset, io.SeekStart)
buf := make([]byte, fi.Size()-currentOffset)
n, _ := io.ReadFull(f, buf)
f.Close()
currentOffset += int64(n)
for _, line := range strings.Split(string(buf[:n]), "\n") {
if line == "" {
continue
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
return
}
}
}
}
func findLatestRPT(dir string) string {
pattern := filepath.Join(dir, "arma3server_x64_*.rpt")
matches, err := filepath.Glob(pattern)
if err != nil || len(matches) == 0 {
return ""
}
sort.Slice(matches, func(i, j int) bool {
fi, _ := os.Stat(matches[i])
fj, _ := os.Stat(matches[j])
return fi.ModTime().After(fj.ModTime())
})
return matches[0]
}
func (h *Handler) GetLog(c *gin.Context) {
filename := filepath.Base(c.Param("file"))
if filename == "" || strings.ContainsRune(c.Param("file"), os.PathSeparator) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
profilesDir := h.process.ProfilesDir()
path := filepath.Join(profilesDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
logsPath := filepath.Join(profilesDir, "logs", filename)
if _, err2 := os.Stat(logsPath); err2 == nil {
path = logsPath
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
}
data, err := os.ReadFile(path)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.String(http.StatusOK, string(data))
}

View File

@@ -0,0 +1,202 @@
package api
import (
"net/http"
"arma3-web-server/internal/models"
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
)
type createModlistInput struct {
Name string `json:"name" binding:"required"`
}
type updateModlistInput struct {
Name string `json:"name" binding:"required"`
Mods []models.ModEntry `json:"mods"`
}
type duplicateModlistInput struct {
Name string `json:"name" binding:"required"`
}
func (h *Handler) ListModlists(c *gin.Context) {
items, err := h.modlists.List()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, items)
}
func (h *Handler) CreateModlist(c *gin.Context) {
var input createModlistInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ml, err := h.modlists.Create(input.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, ml)
}
func (h *Handler) GetModlist(c *gin.Context) {
ml, err := h.modlists.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ml)
}
func (h *Handler) UpdateModlist(c *gin.Context) {
var input updateModlistInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ml, err := h.modlists.Update(c.Param("id"), input.Name, input.Mods)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, ml)
}
func (h *Handler) DeleteModlist(c *gin.Context) {
if err := h.modlists.Delete(c.Param("id")); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusNoContent, nil)
}
func (h *Handler) DuplicateModlist(c *gin.Context) {
var input duplicateModlistInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ml, err := h.modlists.Duplicate(c.Param("id"), input.Name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, ml)
}
func (h *Handler) ImportModlist(c *gin.Context) {
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "file field required"})
return
}
defer file.Close()
_, mods, err := services.ParseModlistHTML(file)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "parse html: " + err.Error()})
return
}
listName := services.FileNameToModlistName(header.Filename)
ml, err := h.modlists.Create(listName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ml, err = h.modlists.Update(ml.ID, ml.Name, mods)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, ml)
}
type modStatus struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Downloaded bool `json:"downloaded"`
}
func (h *Handler) CheckModlistMods(c *gin.Context) {
ml, err := h.modlists.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
dir := h.process.ServerfileDir()
var result []modStatus
for _, m := range ml.Mods {
result = append(result, modStatus{
ID: m.ID,
Name: m.Name,
Enabled: m.Enabled,
Downloaded: services.CheckWorkshopMod(dir, m.ID),
})
}
c.JSON(http.StatusOK, result)
}
func (h *Handler) DownloadMissingMods(c *gin.Context) {
ml, err := h.modlists.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
dir := h.process.ServerfileDir()
var missing []string
for _, m := range ml.Mods {
if m.ID != "" && !services.CheckWorkshopMod(dir, m.ID) {
missing = append(missing, m.ID)
}
}
if len(missing) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "all mods present"})
return
}
if err := h.steamcmd.DownloadMods(missing); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "downloading", "count": len(missing)})
}
func (h *Handler) UpdateAllMods(c *gin.Context) {
ml, err := h.modlists.Get(c.Param("id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
var ids []string
for _, m := range ml.Mods {
if m.ID != "" {
ids = append(ids, m.ID)
}
}
if len(ids) == 0 {
c.JSON(http.StatusOK, gin.H{"status": "no mods with workshop ids"})
return
}
if err := h.steamcmd.DownloadMods(ids); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updating", "count": len(ids)})
}

View File

@@ -0,0 +1,85 @@
package api
import (
"arma3-web-server/internal/services"
"github.com/gin-gonic/gin"
)
type Handler struct {
settings *services.SettingsManager
configs *services.ConfigManager
modlists *services.ModlistManager
process *services.ProcessManager
steamcmd *services.SteamCmdManager
streamer *services.LogStreamer
serverfileDir string
modsDir string
cfgDir string
profilesDir string
}
func New(
settings *services.SettingsManager,
configs *services.ConfigManager,
modlists *services.ModlistManager,
process *services.ProcessManager,
steamcmd *services.SteamCmdManager,
streamer *services.LogStreamer,
serverfileDir, modsDir, cfgDir, profilesDir string,
) *Handler {
return &Handler{
settings: settings,
configs: configs,
modlists: modlists,
process: process,
steamcmd: steamcmd,
streamer: streamer,
serverfileDir: serverfileDir,
modsDir: modsDir,
cfgDir: cfgDir,
profilesDir: profilesDir,
}
}
func (h *Handler) SetupRoutes(r *gin.Engine) {
api := r.Group("/api")
{
api.GET("/server/settings", h.GetSettings)
api.PUT("/server/settings", h.UpdateSettings)
api.POST("/server/start", h.StartServer)
api.POST("/server/stop", h.StopServer)
api.POST("/server/restart", h.RestartServer)
api.GET("/server/status", h.ServerStatus)
api.GET("/server/steamcmd", h.SteamCMDSettings)
api.POST("/server/steamcmd/update-game", h.SteamCMDUpdateGame)
api.POST("/server/steamcmd/download-mod", h.SteamCMDDownloadMod)
api.GET("/server/steamcmd/status", h.SteamCMDStatus)
api.GET("/server/paths", h.ServerPaths)
api.GET("/server/logs", h.ListLogs)
api.GET("/server/logs/:file", h.GetLog)
api.GET("/configs", h.ListConfigs)
api.POST("/configs", h.CreateConfig)
api.GET("/configs/:name", h.GetConfig)
api.PUT("/configs/:name", h.UpdateConfig)
api.DELETE("/configs/:name", h.DeleteConfig)
api.POST("/configs/:name/duplicate", h.DuplicateConfig)
api.GET("/modlists", h.ListModlists)
api.POST("/modlists", h.CreateModlist)
api.GET("/modlists/:id", h.GetModlist)
api.PUT("/modlists/:id", h.UpdateModlist)
api.DELETE("/modlists/:id", h.DeleteModlist)
api.POST("/modlists/:id/duplicate", h.DuplicateModlist)
api.POST("/modlists/import", h.ImportModlist)
api.GET("/modlists/:id/check", h.CheckModlistMods)
api.POST("/modlists/:id/download-missing", h.DownloadMissingMods)
api.POST("/modlists/:id/update-all", h.UpdateAllMods)
}
r.GET("/ws/server/logs", h.StreamLogs)
r.GET("/ws/steamcmd/logs", h.StreamSteamCMDLogs)
r.GET("/ws/server/rpt", h.StreamRPTLogs)
}

View File

@@ -0,0 +1,173 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
type updateSettingsInput struct {
IPPort *string `json:"ip_port"`
ServerParameters *string `json:"server_parameters"`
SteamBranch *string `json:"steam_branch"`
SteamUser *string `json:"steam_user"`
Platform *string `json:"platform"`
CBASettings *string `json:"cba_settings"`
AILevelPresets *string `json:"ai_level_presets"`
DifficultyPresets *string `json:"difficulty_presets"`
ActiveConfig *string `json:"active_config"`
ActiveModlist *string `json:"active_modlist"`
}
func (h *Handler) GetSettings(c *gin.Context) {
s, err := h.settings.Load()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func (h *Handler) UpdateSettings(c *gin.Context) {
var input updateSettingsInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
s, err := h.settings.Load()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if input.IPPort != nil { s.IPPort = *input.IPPort }
if input.ServerParameters != nil { s.ServerParameters = *input.ServerParameters }
if input.SteamBranch != nil { s.SteamBranch = *input.SteamBranch }
if input.SteamUser != nil { s.SteamUser = *input.SteamUser }
if input.Platform != nil { s.Platform = *input.Platform }
if input.CBASettings != nil { s.CBASettings = *input.CBASettings }
if input.AILevelPresets != nil { s.AILevelPresets = *input.AILevelPresets }
if input.DifficultyPresets != nil { s.DifficultyPresets = *input.DifficultyPresets }
if input.ActiveConfig != nil { s.ActiveConfig = *input.ActiveConfig }
if input.ActiveModlist != nil { s.ActiveModlist = *input.ActiveModlist }
if err := h.process.WriteUserconfigFiles(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "write userconfig: " + err.Error()})
return
}
if err := h.settings.Save(s); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, s)
}
func (h *Handler) StartServer(c *gin.Context) {
if err := h.process.Start(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "starting"})
}
func (h *Handler) StopServer(c *gin.Context) {
if err := h.process.Stop(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "stopping"})
}
func (h *Handler) RestartServer(c *gin.Context) {
if err := h.process.Restart(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "restarting"})
}
func (h *Handler) ServerStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"running": h.process.IsRunning(),
})
}
func (h *Handler) SteamCMDSettings(c *gin.Context) {
s, err := h.settings.Load()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"branch": s.SteamBranch,
"user": s.SteamUser,
"platform": s.Platform,
})
}
type steamcmdUpdateGameInput struct {
Branch string `json:"branch"`
User string `json:"user"`
}
func (h *Handler) SteamCMDUpdateGame(c *gin.Context) {
var input steamcmdUpdateGameInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user := input.User
if user == "" {
user = "anonymous"
}
branch := input.Branch
if branch == "" {
branch = "stable"
}
if err := h.steamcmd.UpdateGame(branch, user); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "started"})
}
type steamcmdDownloadModInput struct {
ModID string `json:"mod_id"`
}
func (h *Handler) SteamCMDDownloadMod(c *gin.Context) {
var input steamcmdDownloadModInput
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if input.ModID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "mod_id is required"})
return
}
if err := h.steamcmd.DownloadMod(input.ModID); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "started"})
}
func (h *Handler) SteamCMDStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"running": h.steamcmd.IsRunning(),
})
}
func (h *Handler) ServerPaths(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"serverfile_dir": h.serverfileDir,
"mods_dir": h.modsDir,
"cfg_dir": h.cfgDir,
"profiles_dir": h.profilesDir,
})
}