Files
ArmA-3-web-server/backend/internal/api/logs.go
T
MrFastwind 8bf163a931
CI / build (push) Successful in 1m18s
fix(backend): fix memory leaks and resource issues in logs system
- Fix Subscribe overwriting channel without closing old one (goroutine leak)
- Add context.Context to Stream() for cancellation support
- Add WebSocket read pump to detect dead clients (StreamLogs, StreamSteamCMDLogs, StreamRPTLogs)
- Use defer f.Close() pattern in StreamRPTLogs file handling
- Fix findLatestRPT nil dereference on failed os.Stat
- Optimize findLatestRPT to collect stat results before sorting
- Apply gofmt formatting to modlists.go, settings.go
2026-07-24 16:25:09 +02:00

270 lines
5.6 KiB
Go

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 },
}
const wsReadTimeout = 60 * time.Second
func readPump(conn *websocket.Conn, done chan struct{}) {
defer close(done)
conn.SetReadLimit(4096)
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
return nil
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
return
}
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
}
}
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()
done := make(chan struct{})
go readPump(conn, done)
clientID := conn.RemoteAddr().String()
ch := h.streamer.Subscribe("server", clientID)
defer h.streamer.Unsubscribe("server", clientID)
for {
select {
case line, ok := <-ch:
if !ok {
return
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
return
}
case <-done:
return
}
}
}
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()
done := make(chan struct{})
go readPump(conn, done)
clientID := conn.RemoteAddr().String()
ch := h.streamer.Subscribe("steamcmd", clientID)
defer h.streamer.Unsubscribe("steamcmd", clientID)
for {
select {
case line, ok := <-ch:
if !ok {
return
}
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
return
}
case <-done:
return
}
}
}
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()
done := make(chan struct{})
go readPump(conn, done)
profilesDir := h.process.ProfilesDir()
var currentPath string
var currentOffset int64
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
}
path := findLatestRPT(profilesDir)
if path != currentPath {
currentPath = path
currentOffset = 0
if currentPath == "" {
continue
}
if err := conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---")); err != nil {
return
}
}
if currentPath == "" {
continue
}
fi, err := os.Stat(currentPath)
if err != nil {
currentPath = ""
continue
}
if fi.Size() <= currentOffset {
continue
}
if err := func() error {
f, err := os.Open(currentPath)
if err != nil {
currentPath = ""
return nil
}
defer f.Close()
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
return nil
}
buf := make([]byte, fi.Size()-currentOffset)
n, _ := io.ReadFull(f, buf)
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 err
}
}
return nil
}(); 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 ""
}
type rptInfo struct {
path string
time time.Time
}
var infos []rptInfo
for _, m := range matches {
fi, err := os.Stat(m)
if err != nil {
continue
}
infos = append(infos, rptInfo{path: m, time: fi.ModTime()})
}
if len(infos) == 0 {
return ""
}
sort.Slice(infos, func(i, j int) bool {
return infos[i].time.After(infos[j].time)
})
return infos[0].path
}
func (h *Handler) GetLog(c *gin.Context) {
filename := filepath.Base(c.Param("file"))
if filename == "" || filename == "." || strings.ContainsAny(filename, "/\\") {
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))
}