Compare commits
36 Commits
v0.10.9
...
feature/co
| Author | SHA1 | Date | |
|---|---|---|---|
| f2580b6de0 | |||
| f23f276ee5 | |||
| 180555c903 | |||
| efc6b72838 | |||
| 0e04da031b | |||
| 908adf4468 | |||
| 6c1e9b236e | |||
| f563d52396 | |||
| a490a73392 | |||
| 0ef47ef241 | |||
| ea2e871c9b | |||
| c2c6543068 | |||
| 14aa8a84e5 | |||
| e96b8bca12 | |||
| a6f67dd652 | |||
| e637b5e649 | |||
| 85c5874bc7 | |||
| 4441524447 | |||
| c03234d339 | |||
| d4b16d80f3 | |||
| 9172f0256f | |||
| beb9b479df | |||
| 4aa67db3fd | |||
| 1100072245 | |||
| 49dfa38475 | |||
| 747adc77a5 | |||
| c9a32e8889 | |||
| 7d470f3638 | |||
| cf7473b373 | |||
| 35c53cc54c | |||
| 016824e846 | |||
| d62c8cef34 | |||
| 190e1a3a16 | |||
|
|
4004d83411 | ||
|
|
901dbe697e | ||
|
|
760412d7db |
29
.env.example
Normal file → Executable file
29
.env.example
Normal file → Executable file
@@ -50,6 +50,35 @@ PASSWORD=change-this-default-admin-password
|
||||
|
||||
# 4. NEVER commit actual secrets to version control!
|
||||
|
||||
# =============================================================================
|
||||
# PLATFORM SELECTION
|
||||
# =============================================================================
|
||||
|
||||
# Platform mode: "windows" (default) or "docker"
|
||||
# PLATFORM=windows
|
||||
|
||||
# =============================================================================
|
||||
# DOCKER MODE SETTINGS (required when PLATFORM=docker)
|
||||
# =============================================================================
|
||||
|
||||
# Docker image name for ACC server containers (built from docker/acc-server/Dockerfile)
|
||||
# ACC_IMAGE=acc-wine:latest
|
||||
|
||||
# Base path where per-server game installations are stored (inside the manager container).
|
||||
# Each server gets its own subdirectory: ACC_SERVERS_PATH/<service-name>/
|
||||
# ACC_SERVERS_PATH=/data/servers
|
||||
|
||||
# Host-side path for the same directory — used when creating sibling ACC server containers.
|
||||
# Must match the host path bound to ACC_SERVERS_PATH in docker-compose.yml.
|
||||
# ACC_SERVERS_HOST_PATH=/opt/acc-servers
|
||||
|
||||
# Port pool for Docker-mode ACC server containers (one port per server).
|
||||
# PORT_POOL_MIN=9600
|
||||
# PORT_POOL_MAX=9700
|
||||
|
||||
# Linux SteamCMD binary path (pre-installed in the manager Docker image).
|
||||
# STEAMCMD_LINUX_PATH=/usr/games/steamcmd
|
||||
|
||||
# =============================================================================
|
||||
# OPTIONAL SETTINGS (These are handled by system config in database)
|
||||
# =============================================================================
|
||||
|
||||
0
.gitea/workflows/release.yml
Normal file → Executable file
0
.gitea/workflows/release.yml
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
0
.gitignore
vendored
Normal file → Executable file
63
Dockerfile
Normal file
63
Dockerfile
Normal file
@@ -0,0 +1,63 @@
|
||||
# Multi-stage build for the acc-server-manager Go backend.
|
||||
# CGO_ENABLED=1 is required for mattn/go-sqlite3 (uses cgo).
|
||||
# The binary is compiled for Linux (Docker mode default).
|
||||
|
||||
# ─── Builder ──────────────────────────────────────────────────────────────────
|
||||
FROM golang:1.25-bookworm AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build dependencies for CGO + sqlite3.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libc6-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Download Go modules first (layer cache).
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source and build.
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||
go build -ldflags="-s -w" -o /acc-server-manager ./cmd/api
|
||||
|
||||
# ─── Runtime ──────────────────────────────────────────────────────────────────
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
# Install runtime dependencies and SteamCMD.
|
||||
# steamcmd is not in the standard Debian repo — download directly from Valve.
|
||||
RUN dpkg --add-architecture i386 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
lib32gcc-s1 \
|
||||
&& \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /usr/games && \
|
||||
curl -sSL --retry 3 --retry-delay 5 \
|
||||
-o /tmp/steamcmd.tar.gz \
|
||||
"https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" && \
|
||||
tar -xzf /tmp/steamcmd.tar.gz -C /usr/games && \
|
||||
rm /tmp/steamcmd.tar.gz && \
|
||||
chmod +x /usr/games/steamcmd.sh && \
|
||||
ln -sf /usr/games/steamcmd.sh /usr/games/steamcmd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /acc-server-manager /app/acc-server-manager
|
||||
|
||||
# Persistent data directories.
|
||||
RUN mkdir -p /data/servers /data/db
|
||||
|
||||
ENV PLATFORM=docker
|
||||
ENV PORT=3000
|
||||
ENV DB_NAME=/data/db/acc.db
|
||||
ENV STEAMCMD_LINUX_PATH=/usr/games/steamcmd
|
||||
ENV ACC_SERVERS_PATH=/data/servers
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT ["/app/acc-server-manager"]
|
||||
0
cmd/api/main.go
Normal file → Executable file
0
cmd/api/main.go
Normal file → Executable file
0
cmd/api/swagger.go
Normal file → Executable file
0
cmd/api/swagger.go
Normal file → Executable file
0
cmd/migrate/main.go
Normal file → Executable file
0
cmd/migrate/main.go
Normal file → Executable file
0
cmd/steam-crypt/main.go
Normal file → Executable file
0
cmd/steam-crypt/main.go
Normal file → Executable file
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
manager:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: acc-server-manager:latest
|
||||
container_name: acc-server-manager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PLATFORM: docker
|
||||
DB_NAME: /data/db/acc.db
|
||||
STEAMCMD_LINUX_PATH: /usr/games/steamcmd
|
||||
PORT_POOL_MIN: "9600"
|
||||
PORT_POOL_MAX: "9699"
|
||||
ACC_IMAGE: acc-wine:latest
|
||||
ACC_SERVERS_PATH: /data/servers
|
||||
ACC_SERVERS_HOST_PATH: /opt/acc-servers
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /opt/acc-servers:/data/servers
|
||||
- acc-db:/data/db
|
||||
networks:
|
||||
- acc-net
|
||||
|
||||
volumes:
|
||||
acc-db:
|
||||
|
||||
networks:
|
||||
acc-net:
|
||||
driver: bridge
|
||||
35
docker/acc-server/Dockerfile
Normal file
35
docker/acc-server/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# ACC Server container: Wine + accServer.exe
|
||||
# Game files are bind-mounted from the host at /acc/game.
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install Wine, Xvfb (headless display for Wine), and minimal runtime deps.
|
||||
RUN dpkg --add-architecture i386 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wine \
|
||||
wine32 \
|
||||
wine64 \
|
||||
xvfb \
|
||||
ca-certificates \
|
||||
tcpdump \
|
||||
&& \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix
|
||||
|
||||
WORKDIR /root
|
||||
|
||||
ENV WINEARCH=win64
|
||||
ENV WINEPREFIX=/root/.wine
|
||||
ENV WINEDEBUG=-all
|
||||
|
||||
# Copy entrypoint script.
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Bind-mounted by DockerRuntime.Create: per-server game install (rw)
|
||||
VOLUME ["/acc/game"]
|
||||
|
||||
ENV SERVER_PORT=9600
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
26
docker/acc-server/entrypoint.sh
Normal file
26
docker/acc-server/entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Use a display number derived from the server port to avoid conflicts
|
||||
# between containers (each server gets its own unique display).
|
||||
DISPLAY_NUM=$((SERVER_PORT % 1000 + 100))
|
||||
export DISPLAY=:${DISPLAY_NUM}
|
||||
|
||||
# Clean up any stale X lock from a previous container run.
|
||||
rm -f /tmp/.X${DISPLAY_NUM}-lock /tmp/.X11-unix/X${DISPLAY_NUM} 2>/dev/null || true
|
||||
|
||||
Xvfb ${DISPLAY} -screen 0 1024x768x16 -nolisten tcp &
|
||||
XVFB_PID=$!
|
||||
|
||||
cleanup() {
|
||||
echo "Stopping ACC server..."
|
||||
kill $XVFB_PID 2>/dev/null || true
|
||||
}
|
||||
trap cleanup SIGTERM SIGINT
|
||||
|
||||
mkdir -p /acc/game/log
|
||||
|
||||
echo "Starting ACC server on port ${SERVER_PORT}..."
|
||||
|
||||
cd /acc/game
|
||||
exec wine /acc/game/accServer.exe
|
||||
0
docs/API.md
Normal file → Executable file
0
docs/API.md
Normal file → Executable file
0
docs/CONFIG.md
Normal file → Executable file
0
docs/CONFIG.md
Normal file → Executable file
0
docs/DEPLOYMENT.md
Normal file → Executable file
0
docs/DEPLOYMENT.md
Normal file → Executable file
0
docs/MIGRATION.md
Normal file → Executable file
0
docs/MIGRATION.md
Normal file → Executable file
0
docs/SETUP.md
Normal file → Executable file
0
docs/SETUP.md
Normal file → Executable file
0
docs/STEAM_2FA_IMPLEMENTATION.md
Normal file → Executable file
0
docs/STEAM_2FA_IMPLEMENTATION.md
Normal file → Executable file
0
docs/SWAGGER.md
Normal file → Executable file
0
docs/SWAGGER.md
Normal file → Executable file
0
docs/TROUBLESHOOTING.md
Normal file → Executable file
0
docs/TROUBLESHOOTING.md
Normal file → Executable file
25
go.mod
Normal file → Executable file
25
go.mod
Normal file → Executable file
@@ -1,15 +1,19 @@
|
||||
module acc-server-manager
|
||||
|
||||
go 1.23.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/docker/docker v24.0.9+incompatible
|
||||
github.com/docker/go-connections v0.6.0
|
||||
github.com/gofiber/fiber/v2 v2.52.8
|
||||
github.com/gofiber/swagger v1.1.0
|
||||
github.com/gofiber/websocket/v2 v2.2.1
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c
|
||||
github.com/swaggo/swag v1.16.3
|
||||
github.com/valyala/fasthttp v1.51.0
|
||||
go.uber.org/dig v1.17.1
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/sync v0.15.0
|
||||
@@ -20,11 +24,17 @@ require (
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/Microsoft/go-winio v0.4.21 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/docker/distribution v2.7.1+incompatible // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/fasthttp/websocket v1.5.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
@@ -34,12 +44,21 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/morikuni/aec v1.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.2.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/swaggo/files/v2 v2.0.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.51.0 // indirect
|
||||
github.com/valyala/tcplisten v1.0.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/time v0.9.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gotest.tools/v3 v3.5.2 // indirect
|
||||
)
|
||||
|
||||
82
go.sum
Normal file → Executable file
82
go.sum
Normal file → Executable file
@@ -1,9 +1,23 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Microsoft/go-winio v0.4.21 h1:+6mVbXh4wPzUrl1COX9A+ZCvEpYsOBZ6/+kwDnvLyro=
|
||||
github.com/Microsoft/go-winio v0.4.21/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
|
||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
|
||||
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
|
||||
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
@@ -16,8 +30,14 @@ github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0
|
||||
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
|
||||
github.com/gofiber/swagger v1.1.0 h1:ff3rg1fB+Rp5JN/N8jfxTiZtMKe/9tB9QDc79fPiJKQ=
|
||||
github.com/gofiber/swagger v1.1.0/go.mod h1:pRZL0Np35sd+lTODTE5The0G+TMHfNY+oC4hM2/i5m8=
|
||||
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
|
||||
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -28,6 +48,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -45,16 +67,31 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
|
||||
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c h1:kmzxiX+OB0knCo1V0dkEkdPelzCdAzCURCfmFArn2/A=
|
||||
github.com/qjebbs/go-jsons v0.0.0-20221222033332-a534c5fc1c4c/go.mod h1:wNJrtinHyC3YSf6giEh4FJN8+yZV7nXBjvmfjhBIcw4=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
|
||||
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
|
||||
github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw=
|
||||
github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM=
|
||||
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
|
||||
@@ -65,22 +102,53 @@ github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1S
|
||||
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
|
||||
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
|
||||
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
go.uber.org/dig v1.17.1 h1:Tga8Lz8PcYNsWsyHMZ1Vm0OQOUaJNDyvPImgbAu9YSc=
|
||||
go.uber.org/dig v1.17.1/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -90,3 +158,5 @@ gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||
|
||||
8
local/api/api.go
Normal file → Executable file
8
local/api/api.go
Normal file → Executable file
@@ -31,7 +31,13 @@ func Init(di *dig.Container, app *fiber.App) {
|
||||
StateHistory: serverIdGroup.Group("/state-history"),
|
||||
Membership: groups.Group("/membership"),
|
||||
System: groups.Group("/system"),
|
||||
Steam2FA: groups.Group("/steam2fa"),
|
||||
WebSocket: groups.Group("/ws"),
|
||||
Leaderboard: serverIdGroup.Group("/leaderboard"),
|
||||
Steam: groups.Group("/steam"),
|
||||
ActivityLog: serverIdGroup.Group("/activity-log"),
|
||||
Groups: groups,
|
||||
Log: serverIdGroup.Group("/log"),
|
||||
Presets: groups.Group("/presets"),
|
||||
}
|
||||
|
||||
accessKeyMiddleware := middleware.NewAccessKeyMiddleware()
|
||||
|
||||
48
local/controller/activity_log.go
Normal file
48
local/controller/activity_log.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ActivityLogController struct {
|
||||
service *service.ActivityLogService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewActivityLogController(as *service.ActivityLogService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ActivityLogController {
|
||||
ac := &ActivityLogController{
|
||||
service: as,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
// Global activity log: GET /activity-log (admin use)
|
||||
routeGroups.Groups.Group("/activity-log").Use(auth.Authenticate).Get("/", ac.GetAllLogs)
|
||||
|
||||
return ac
|
||||
}
|
||||
|
||||
// GetAllLogs returns activity logs across all servers (admin).
|
||||
//
|
||||
// @Summary Return all activity logs
|
||||
// @Tags ActivityLog
|
||||
// @Success 200 {array} model.ActivityLog
|
||||
// @Router /activity-log [get]
|
||||
func (ac *ActivityLogController) GetAllLogs(c *fiber.Ctx) error {
|
||||
var filter model.ActivityLogFilter
|
||||
if err := common.ParseQueryFilter(c, &filter); err != nil {
|
||||
return ac.errorHandler.HandleValidationError(c, err, "query_filter")
|
||||
}
|
||||
|
||||
result, err := ac.service.GetAll(c.UserContext(), &filter)
|
||||
if err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(result)
|
||||
}
|
||||
50
local/controller/config.go
Normal file → Executable file
50
local/controller/config.go
Normal file → Executable file
@@ -2,6 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
@@ -34,6 +35,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
|
||||
|
||||
configGroup := routeGroups.Config
|
||||
configGroup.Use(auth.Authenticate)
|
||||
configGroup.Patch("/", ac.UpdateAllConfigs)
|
||||
configGroup.Put("/:file", ac.UpdateConfig)
|
||||
configGroup.Get("/:file", ac.GetConfig)
|
||||
configGroup.Get("/", ac.GetConfigs)
|
||||
@@ -41,6 +43,54 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
|
||||
return ac
|
||||
}
|
||||
|
||||
// updateAllConfigs updates all configuration files
|
||||
//
|
||||
// @Summary Bulk update all server configuration files
|
||||
// @Description Update multiple ACC server configuration files in one request. Null or omitted sections are skipped.
|
||||
// @Tags Server Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID (UUID format)"
|
||||
// @Param restart query bool false "Restart the server after update"
|
||||
// @Param override query bool false "Override instead of merging with existing config"
|
||||
// @Param content body model.ConfigurationsUpdateRequest true "Configuration sections to update (null sections are skipped)"
|
||||
// @Success 200 {array} model.Config "List of updated config audit records"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or JSON format"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /server/{id}/config [patch]
|
||||
func (ac *ConfigController) UpdateAllConfigs(c *fiber.Ctx) error {
|
||||
restart := c.QueryBool("restart")
|
||||
serverID := c.Params("id")
|
||||
|
||||
if _, err := uuid.Parse(serverID); err != nil {
|
||||
return ac.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
c.Locals("serverId", serverID)
|
||||
|
||||
var req model.ConfigurationsUpdateRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return ac.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
|
||||
configs, err := ac.service.UpdateAllConfigs(c, &req)
|
||||
if err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
if restart {
|
||||
if _, err := ac.apiService.ServiceControlRestartServer(c); err != nil {
|
||||
logging.ErrorWithContext("CONFIG_RESTART", "Failed to restart server after bulk config update: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(configs)
|
||||
}
|
||||
|
||||
// updateConfig returns Config
|
||||
//
|
||||
// @Summary Update server configuration file
|
||||
|
||||
246
local/controller/config_preset.go
Normal file
246
local/controller/config_preset.go
Normal file
@@ -0,0 +1,246 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ConfigPresetController struct {
|
||||
service *service.ConfigPresetService
|
||||
apiService *service.ServiceControlService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
// NewConfigPresetController initializes ConfigPresetController.
|
||||
func NewConfigPresetController(s *service.ConfigPresetService, apiService *service.ServiceControlService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *ConfigPresetController {
|
||||
c := &ConfigPresetController{
|
||||
service: s,
|
||||
apiService: apiService,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
presetRoutes := routeGroups.Presets
|
||||
presetRoutes.Use(auth.Authenticate)
|
||||
presetRoutes.Get("/", auth.HasPermission(model.PresetView), c.ListPresets)
|
||||
presetRoutes.Post("/", auth.HasPermission(model.PresetCreate), c.CreatePreset)
|
||||
presetRoutes.Get("/:presetId", auth.HasPermission(model.PresetView), c.GetPreset)
|
||||
presetRoutes.Put("/:presetId", auth.HasPermission(model.PresetUpdate), c.UpdatePreset)
|
||||
presetRoutes.Delete("/:presetId", auth.HasPermission(model.PresetDelete), c.DeletePreset)
|
||||
|
||||
configRoutes := routeGroups.Config
|
||||
configRoutes.Post("/presets/:presetId/apply", auth.HasPermission(model.ConfigUpdate), c.ApplyPreset)
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// ListPresets returns all config presets.
|
||||
//
|
||||
// @Summary List config presets
|
||||
// @Description Get a list of all saved configuration presets
|
||||
// @Tags Config Presets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param name query string false "Filter by preset name (partial match)"
|
||||
// @Success 200 {array} model.ConfigPreset "List of presets"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /presets [get]
|
||||
func (c *ConfigPresetController) ListPresets(ctx *fiber.Ctx) error {
|
||||
var filter model.ConfigPresetFilter
|
||||
if err := common.ParseQueryFilter(ctx, &filter); err != nil {
|
||||
return c.errorHandler.HandleParsingError(ctx, err)
|
||||
}
|
||||
|
||||
presets, err := c.service.ListPresets(ctx.UserContext(), &filter)
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
return ctx.JSON(presets)
|
||||
}
|
||||
|
||||
// GetPreset returns a single config preset by ID.
|
||||
//
|
||||
// @Summary Get config preset
|
||||
// @Description Retrieve a single configuration preset by ID
|
||||
// @Tags Config Presets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param presetId path string true "Preset ID (UUID format)"
|
||||
// @Success 200 {object} model.ConfigPreset "Preset"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Preset not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /presets/{presetId} [get]
|
||||
func (c *ConfigPresetController) GetPreset(ctx *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(ctx.Params("presetId"))
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
|
||||
}
|
||||
|
||||
preset, err := c.service.GetPreset(ctx.UserContext(), id)
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
return ctx.JSON(preset)
|
||||
}
|
||||
|
||||
// CreatePreset creates a new config preset.
|
||||
//
|
||||
// @Summary Create config preset
|
||||
// @Description Save a new configuration preset with one or more ACC config sections
|
||||
// @Tags Config Presets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param content body model.ConfigPresetCreateRequest true "Preset data"
|
||||
// @Success 201 {object} model.ConfigPreset "Created preset"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /presets [post]
|
||||
func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error {
|
||||
var req model.ConfigPresetCreateRequest
|
||||
if err := ctx.BodyParser(&req); err != nil {
|
||||
return c.errorHandler.HandleParsingError(ctx, err)
|
||||
}
|
||||
|
||||
actorID, _ := ctx.Locals("userID").(string)
|
||||
actorUsername, _ := ctx.Locals("actorUsername").(string)
|
||||
if actorUsername == "" {
|
||||
actorUsername = "system"
|
||||
}
|
||||
|
||||
preset, err := c.service.CreatePreset(ctx.UserContext(), &req, actorID, actorUsername)
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
return ctx.Status(fiber.StatusCreated).JSON(preset)
|
||||
}
|
||||
|
||||
// UpdatePreset updates an existing config preset.
|
||||
//
|
||||
// @Summary Update config preset
|
||||
// @Description Update name, description, or any config sections of an existing preset
|
||||
// @Tags Config Presets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param presetId path string true "Preset ID (UUID format)"
|
||||
// @Param content body model.ConfigPresetUpdateRequest true "Fields to update (omit to keep existing)"
|
||||
// @Success 200 {object} model.ConfigPreset "Updated preset"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid request or preset ID"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Preset not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /presets/{presetId} [put]
|
||||
func (c *ConfigPresetController) UpdatePreset(ctx *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(ctx.Params("presetId"))
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
|
||||
}
|
||||
|
||||
var req model.ConfigPresetUpdateRequest
|
||||
if err := ctx.BodyParser(&req); err != nil {
|
||||
return c.errorHandler.HandleParsingError(ctx, err)
|
||||
}
|
||||
|
||||
preset, err := c.service.UpdatePreset(ctx.UserContext(), id, &req)
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
return ctx.JSON(preset)
|
||||
}
|
||||
|
||||
// DeletePreset deletes a config preset.
|
||||
//
|
||||
// @Summary Delete config preset
|
||||
// @Description Permanently delete a configuration preset
|
||||
// @Tags Config Presets
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param presetId path string true "Preset ID (UUID format)"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid preset ID"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /presets/{presetId} [delete]
|
||||
func (c *ConfigPresetController) DeletePreset(ctx *fiber.Ctx) error {
|
||||
id, err := uuid.Parse(ctx.Params("presetId"))
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
|
||||
}
|
||||
|
||||
if err := c.service.DeletePreset(ctx.UserContext(), id); err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
return ctx.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
// ApplyPreset applies a saved preset to a server's config files.
|
||||
//
|
||||
// @Summary Apply config preset to server
|
||||
// @Description Apply a saved configuration preset to an ACC server, writing only the sections stored in the preset
|
||||
// @Tags Server Configuration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID (UUID format)"
|
||||
// @Param presetId path string true "Preset ID (UUID format)"
|
||||
// @Param restart query bool false "Restart the server after applying the preset"
|
||||
// @Success 200 {array} model.Config "List of updated config audit records"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid server or preset ID"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server or preset not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /server/{id}/config/presets/{presetId}/apply [post]
|
||||
func (c *ConfigPresetController) ApplyPreset(ctx *fiber.Ctx) error {
|
||||
restart := ctx.QueryBool("restart")
|
||||
serverID := ctx.Params("id")
|
||||
|
||||
if _, err := uuid.Parse(serverID); err != nil {
|
||||
return c.errorHandler.HandleUUIDError(ctx, "server ID")
|
||||
}
|
||||
|
||||
ctx.Locals("serverId", serverID)
|
||||
|
||||
presetID, err := uuid.Parse(ctx.Params("presetId"))
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleUUIDError(ctx, "preset ID")
|
||||
}
|
||||
|
||||
actorID, _ := ctx.Locals("userID").(string)
|
||||
actorUsername, _ := ctx.Locals("actorUsername").(string)
|
||||
if actorUsername == "" {
|
||||
actorUsername = "system"
|
||||
}
|
||||
|
||||
configs, err := c.service.ApplyPreset(ctx.UserContext(), serverID, presetID, actorID, actorUsername)
|
||||
if err != nil {
|
||||
return c.errorHandler.HandleServiceError(ctx, err)
|
||||
}
|
||||
|
||||
if restart {
|
||||
if _, err := c.apiService.ServiceControlRestartServer(ctx); err != nil {
|
||||
logging.ErrorWithContext("PRESET_APPLY_RESTART", "Failed to restart server after preset apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.JSON(configs)
|
||||
}
|
||||
29
local/controller/controller.go
Normal file → Executable file
29
local/controller/controller.go
Normal file → Executable file
@@ -35,6 +35,11 @@ func InitializeControllers(c *dig.Container) {
|
||||
logging.Panic("unable to initialize config controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewConfigPresetController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize config preset controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewServerController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize server controller")
|
||||
@@ -55,8 +60,28 @@ func InitializeControllers(c *dig.Container) {
|
||||
logging.Panic("unable to initialize membership controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewSteam2FAController)
|
||||
err = c.Invoke(NewWebSocketController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize steam 2fa controller")
|
||||
logging.Panic("unable to initialize websocket controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewLeaderboardController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize leaderboard controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewSteamController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize steam controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewActivityLogController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize activity log controller")
|
||||
}
|
||||
|
||||
err = c.Invoke(NewLogController)
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize log controller")
|
||||
}
|
||||
}
|
||||
|
||||
77
local/controller/leaderboard.go
Executable file
77
local/controller/leaderboard.go
Executable file
@@ -0,0 +1,77 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
|
||||
type LeaderboardController struct {
|
||||
service *service.LeaderboardService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewLeaderboardController(ls *service.LeaderboardService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LeaderboardController {
|
||||
lc := &LeaderboardController{
|
||||
service: ls,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
apiServerRoutes := routeGroups.Api.Group("/server/:id")
|
||||
apiServerRoutes.Get("/leaderboard", lc.Get)
|
||||
|
||||
routeGroups.Leaderboard.Get("/", lc.Get)
|
||||
routeGroups.Leaderboard.Put("/", auth.Authenticate, lc.Update)
|
||||
|
||||
return lc
|
||||
}
|
||||
|
||||
// Get returns the leaderboard for a server (public, no auth required).
|
||||
func (lc *LeaderboardController) Get(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
data, err := lc.service.Get(c.UserContext(), serverID)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(data)
|
||||
}
|
||||
|
||||
// Update replaces the leaderboard for a server (requires JWT auth).
|
||||
func (lc *LeaderboardController) Update(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
var input model.Leaderboard
|
||||
if err := c.BodyParser(&input); err != nil {
|
||||
return lc.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
|
||||
userInfo, _ := c.Locals("userInfo").(*middleware.CachedUserInfo)
|
||||
actorID, actorUsername := "", "system"
|
||||
if userInfo != nil {
|
||||
actorID = userInfo.UserID
|
||||
actorUsername = userInfo.Username
|
||||
}
|
||||
|
||||
data, err := lc.service.Update(c.UserContext(), serverID, &input, actorID, actorUsername)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(data)
|
||||
}
|
||||
62
local/controller/log.go
Normal file
62
local/controller/log.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LogController struct {
|
||||
service *service.ServerService
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewLogController(ss *service.ServerService, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware) *LogController {
|
||||
lc := &LogController{
|
||||
service: ss,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
logRoutes := routeGroups.Log
|
||||
logRoutes.Use(auth.Authenticate)
|
||||
logRoutes.Get("/", auth.HasPermission(model.ServerView), lc.GetLogLines)
|
||||
|
||||
return lc
|
||||
}
|
||||
|
||||
// GetLogLines godoc
|
||||
// @Summary Get last N lines of server log
|
||||
// @Tags Server
|
||||
// @Param id path string true "Server ID"
|
||||
// @Param lines query int false "Number of lines to return (default 100)"
|
||||
// @Success 200 {object} object{lines=[]string}
|
||||
// @Security BearerAuth
|
||||
// @Router /server/{id}/log [get]
|
||||
func (lc *LogController) GetLogLines(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
n, _ := strconv.Atoi(c.Query("lines", "100"))
|
||||
if n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
if n > 10000 {
|
||||
n = 10000
|
||||
}
|
||||
|
||||
lines, err := lc.service.GetLastLogLines(c, serverID, n)
|
||||
if err != nil {
|
||||
return lc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"lines": lines})
|
||||
}
|
||||
0
local/controller/lookup.go
Normal file → Executable file
0
local/controller/lookup.go
Normal file → Executable file
0
local/controller/membership.go
Normal file → Executable file
0
local/controller/membership.go
Normal file → Executable file
44
local/controller/server.go
Normal file → Executable file
44
local/controller/server.go
Normal file → Executable file
@@ -29,7 +29,6 @@ func NewServerController(ss *service.ServerService, routeGroups *common.RouteGro
|
||||
serverRoutes.Get("/", auth.HasPermission(model.ServerView), ac.GetAll)
|
||||
serverRoutes.Get("/:id", auth.HasPermission(model.ServerView), ac.GetById)
|
||||
serverRoutes.Post("/", auth.HasPermission(model.ServerCreate), ac.CreateServer)
|
||||
serverRoutes.Put("/:id", auth.HasPermission(model.ServerUpdate), ac.UpdateServer)
|
||||
serverRoutes.Delete("/:id", auth.HasPermission(model.ServerDelete), ac.DeleteServer)
|
||||
|
||||
apiServerRoutes := routeGroups.Api.Group("/server")
|
||||
@@ -139,45 +138,16 @@ func (ac *ServerController) CreateServer(c *fiber.Ctx) error {
|
||||
if err := c.BodyParser(server); err != nil {
|
||||
return ac.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
ac.service.GenerateServerPath(server)
|
||||
if err := ac.service.CreateServer(c, server); err != nil {
|
||||
|
||||
server.GenerateUUID()
|
||||
|
||||
// Use async server creation to avoid blocking other requests
|
||||
if err := ac.service.CreateServerAsync(c, server); err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
return c.JSON(server)
|
||||
}
|
||||
|
||||
// UpdateServer updates an existing server
|
||||
// @Summary Update an ACC server
|
||||
// @Description Update configuration for an existing ACC server
|
||||
// @Tags Server
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Server ID (UUID format)"
|
||||
// @Param server body model.Server true "Updated server configuration"
|
||||
// @Success 200 {object} object "Updated server details"
|
||||
// @Failure 400 {object} error_handler.ErrorResponse "Invalid server data or ID"
|
||||
// @Failure 401 {object} error_handler.ErrorResponse "Unauthorized"
|
||||
// @Failure 403 {object} error_handler.ErrorResponse "Insufficient permissions"
|
||||
// @Failure 404 {object} error_handler.ErrorResponse "Server not found"
|
||||
// @Failure 500 {object} error_handler.ErrorResponse "Internal server error"
|
||||
// @Security BearerAuth
|
||||
// @Router /server/{id} [put]
|
||||
func (ac *ServerController) UpdateServer(c *fiber.Ctx) error {
|
||||
serverIDStr := c.Params("id")
|
||||
serverID, err := uuid.Parse(serverIDStr)
|
||||
if err != nil {
|
||||
return ac.errorHandler.HandleUUIDError(c, "server ID")
|
||||
}
|
||||
|
||||
server := new(model.Server)
|
||||
if err := c.BodyParser(server); err != nil {
|
||||
return ac.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
server.ID = serverID
|
||||
|
||||
if err := ac.service.UpdateServer(c, server); err != nil {
|
||||
return ac.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
// Return immediately with server details
|
||||
// The actual creation will happen in the background with WebSocket updates
|
||||
return c.JSON(server)
|
||||
}
|
||||
|
||||
|
||||
1
local/controller/service_control.go
Normal file → Executable file
1
local/controller/service_control.go
Normal file → Executable file
@@ -29,6 +29,7 @@ func NewServiceControlController(as *service.ServiceControlService, routeGroups
|
||||
}
|
||||
|
||||
serviceRoutes := routeGroups.Server.Group("/:id/service")
|
||||
serviceRoutes.Use(auth.Authenticate)
|
||||
serviceRoutes.Get("/:service", ac.getStatus)
|
||||
serviceRoutes.Post("/start", ac.startServer)
|
||||
serviceRoutes.Post("/stop", ac.stopServer)
|
||||
|
||||
0
local/controller/state_history.go
Normal file → Executable file
0
local/controller/state_history.go
Normal file → Executable file
57
local/controller/steam.go
Normal file
57
local/controller/steam.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type SteamController struct {
|
||||
steamService *service.SteamService
|
||||
auth *middleware.AuthMiddleware
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
}
|
||||
|
||||
func NewSteamController(steamService *service.SteamService, auth *middleware.AuthMiddleware, routeGroups *common.RouteGroups) *SteamController {
|
||||
sc := &SteamController{
|
||||
steamService: steamService,
|
||||
auth: auth,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
}
|
||||
|
||||
routeGroups.Steam.Use(auth.Authenticate, auth.RequireSuperAdmin())
|
||||
routeGroups.Steam.Post("/credentials", sc.SetCredentials)
|
||||
|
||||
return sc
|
||||
}
|
||||
|
||||
func (sc *SteamController) SetCredentials(c *fiber.Ctx) error {
|
||||
type request struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
var req request
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return sc.errorHandler.HandleParsingError(c, err)
|
||||
}
|
||||
|
||||
creds := &model.SteamCredentials{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
}
|
||||
|
||||
if err := creds.Validate(); err != nil {
|
||||
return sc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
if err := sc.steamService.SaveCredentials(c.UserContext(), creds); err != nil {
|
||||
return sc.errorHandler.HandleServiceError(c, err)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": creds.ID})
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/error_handler"
|
||||
"acc-server-manager/local/utl/jwt"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type Steam2FAController struct {
|
||||
tfaManager *model.Steam2FAManager
|
||||
errorHandler *error_handler.ControllerErrorHandler
|
||||
jwtHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
func NewSteam2FAController(tfaManager *model.Steam2FAManager, routeGroups *common.RouteGroups, auth *middleware.AuthMiddleware, jwtHandler *jwt.OpenJWTHandler) *Steam2FAController {
|
||||
controller := &Steam2FAController{
|
||||
tfaManager: tfaManager,
|
||||
errorHandler: error_handler.NewControllerErrorHandler(),
|
||||
jwtHandler: jwtHandler,
|
||||
}
|
||||
|
||||
steam2faRoutes := routeGroups.Steam2FA
|
||||
steam2faRoutes.Use(auth.AuthenticateOpen)
|
||||
|
||||
// Define routes
|
||||
steam2faRoutes.Get("/pending", auth.HasPermission(model.ServerView), controller.GetPendingRequests)
|
||||
steam2faRoutes.Get("/:id", auth.HasPermission(model.ServerView), controller.GetRequest)
|
||||
steam2faRoutes.Post("/:id/complete", auth.HasPermission(model.ServerUpdate), controller.CompleteRequest)
|
||||
steam2faRoutes.Post("/:id/cancel", auth.HasPermission(model.ServerUpdate), controller.CancelRequest)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
// GetPendingRequests gets all pending 2FA requests
|
||||
//
|
||||
// @Summary Get pending 2FA requests
|
||||
// @Description Get all pending Steam 2FA authentication requests
|
||||
// @Tags Steam 2FA
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.Steam2FARequest
|
||||
// @Failure 500 {object} error_handler.ErrorResponse
|
||||
// @Router /steam2fa/pending [get]
|
||||
func (c *Steam2FAController) GetPendingRequests(ctx *fiber.Ctx) error {
|
||||
requests := c.tfaManager.GetPendingRequests()
|
||||
return ctx.JSON(requests)
|
||||
}
|
||||
|
||||
// GetRequest gets a specific 2FA request by ID
|
||||
//
|
||||
// @Summary Get 2FA request
|
||||
// @Description Get a specific Steam 2FA authentication request by ID
|
||||
// @Tags Steam 2FA
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "2FA Request ID"
|
||||
// @Success 200 {object} model.Steam2FARequest
|
||||
// @Failure 404 {object} error_handler.ErrorResponse
|
||||
// @Failure 500 {object} error_handler.ErrorResponse
|
||||
// @Router /steam2fa/{id} [get]
|
||||
func (c *Steam2FAController) GetRequest(ctx *fiber.Ctx) error {
|
||||
id := ctx.Params("id")
|
||||
if id == "" {
|
||||
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
request, exists := c.tfaManager.GetRequest(id)
|
||||
if !exists {
|
||||
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||
}
|
||||
|
||||
return ctx.JSON(request)
|
||||
}
|
||||
|
||||
// CompleteRequest marks a 2FA request as completed
|
||||
//
|
||||
// @Summary Complete 2FA request
|
||||
// @Description Mark a Steam 2FA authentication request as completed
|
||||
// @Tags Steam 2FA
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "2FA Request ID"
|
||||
// @Success 200 {object} model.Steam2FARequest
|
||||
// @Failure 400 {object} error_handler.ErrorResponse
|
||||
// @Failure 404 {object} error_handler.ErrorResponse
|
||||
// @Failure 500 {object} error_handler.ErrorResponse
|
||||
// @Router /steam2fa/{id}/complete [post]
|
||||
func (c *Steam2FAController) CompleteRequest(ctx *fiber.Ctx) error {
|
||||
id := ctx.Params("id")
|
||||
if id == "" {
|
||||
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := c.tfaManager.CompleteRequest(id); err != nil {
|
||||
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
request, exists := c.tfaManager.GetRequest(id)
|
||||
if !exists {
|
||||
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||
}
|
||||
|
||||
return ctx.JSON(request)
|
||||
}
|
||||
|
||||
// CancelRequest cancels a 2FA request
|
||||
//
|
||||
// @Summary Cancel 2FA request
|
||||
// @Description Cancel a Steam 2FA authentication request
|
||||
// @Tags Steam 2FA
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "2FA Request ID"
|
||||
// @Success 200 {object} model.Steam2FARequest
|
||||
// @Failure 400 {object} error_handler.ErrorResponse
|
||||
// @Failure 404 {object} error_handler.ErrorResponse
|
||||
// @Failure 500 {object} error_handler.ErrorResponse
|
||||
// @Router /steam2fa/{id}/cancel [post]
|
||||
func (c *Steam2FAController) CancelRequest(ctx *fiber.Ctx) error {
|
||||
id := ctx.Params("id")
|
||||
if id == "" {
|
||||
return c.errorHandler.HandleError(ctx, fiber.ErrBadRequest, fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
if err := c.tfaManager.ErrorRequest(id, "cancelled by user"); err != nil {
|
||||
return c.errorHandler.HandleError(ctx, err, fiber.StatusBadRequest)
|
||||
}
|
||||
|
||||
request, exists := c.tfaManager.GetRequest(id)
|
||||
if !exists {
|
||||
return c.errorHandler.HandleNotFoundError(ctx, "2FA request")
|
||||
}
|
||||
|
||||
return ctx.JSON(request)
|
||||
}
|
||||
0
local/controller/system.go
Normal file → Executable file
0
local/controller/system.go
Normal file → Executable file
138
local/controller/websocket.go
Executable file
138
local/controller/websocket.go
Executable file
@@ -0,0 +1,138 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/middleware"
|
||||
"acc-server-manager/local/service"
|
||||
"acc-server-manager/local/utl/common"
|
||||
"acc-server-manager/local/utl/jwt"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WebSocketController struct {
|
||||
webSocketService *service.WebSocketService
|
||||
jwtHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
func NewWebSocketController(
|
||||
wsService *service.WebSocketService,
|
||||
jwtHandler *jwt.OpenJWTHandler,
|
||||
routeGroups *common.RouteGroups,
|
||||
auth *middleware.AuthMiddleware,
|
||||
) *WebSocketController {
|
||||
wsc := &WebSocketController{
|
||||
webSocketService: wsService,
|
||||
jwtHandler: jwtHandler,
|
||||
}
|
||||
|
||||
wsRoutes := routeGroups.WebSocket
|
||||
wsRoutes.Use("/", wsc.upgradeWebSocket)
|
||||
wsRoutes.Get("/", websocket.New(wsc.handleWebSocket))
|
||||
|
||||
return wsc
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) upgradeWebSocket(c *fiber.Ctx) error {
|
||||
if websocket.IsWebSocketUpgrade(c) {
|
||||
token := c.Query("token")
|
||||
if token == "" {
|
||||
token = c.Get("Authorization")
|
||||
if token != "" && len(token) > 7 && token[:7] == "Bearer " {
|
||||
token = token[7:]
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Missing authentication token")
|
||||
}
|
||||
|
||||
claims, err := wsc.jwtHandler.ValidateToken(token)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid authentication token")
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(claims.UserID)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Invalid user ID in token")
|
||||
}
|
||||
|
||||
c.Locals("userID", userID)
|
||||
c.Locals("username", claims.UserID)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
return fiber.NewError(fiber.StatusUpgradeRequired, "WebSocket upgrade required")
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) handleWebSocket(c *websocket.Conn) {
|
||||
connID := uuid.New().String()
|
||||
|
||||
userID, ok := c.Locals("userID").(uuid.UUID)
|
||||
if !ok {
|
||||
logging.Error("Failed to get user ID from WebSocket connection")
|
||||
c.Close()
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := c.Locals("username").(string)
|
||||
logging.Info("WebSocket connection established for user: %s (ID: %s)", username, userID.String())
|
||||
|
||||
wsc.webSocketService.AddConnection(connID, c, &userID)
|
||||
|
||||
defer func() {
|
||||
wsc.webSocketService.RemoveConnection(connID)
|
||||
logging.Info("WebSocket connection closed for user: %s", username)
|
||||
}()
|
||||
|
||||
for {
|
||||
messageType, message, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||
logging.Error("WebSocket error for user %s: %v", username, err)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
switch messageType {
|
||||
case websocket.TextMessage:
|
||||
wsc.handleTextMessage(connID, userID, message)
|
||||
case websocket.BinaryMessage:
|
||||
logging.Debug("Received binary message from user %s (not supported)", username)
|
||||
case websocket.PingMessage:
|
||||
if err := c.WriteMessage(websocket.PongMessage, nil); err != nil {
|
||||
logging.Error("Failed to send pong to user %s: %v", username, err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) handleTextMessage(connID string, userID uuid.UUID, message []byte) {
|
||||
logging.Debug("Received WebSocket message from user %s: %s", userID.String(), string(message))
|
||||
|
||||
messageStr := string(message)
|
||||
if len(messageStr) > 10 && messageStr[:9] == "server_id" {
|
||||
if serverIDStr := messageStr[10:]; len(serverIDStr) > 0 {
|
||||
if serverID, err := uuid.Parse(serverIDStr); err == nil {
|
||||
wsc.webSocketService.SetServerID(connID, serverID)
|
||||
logging.Info("Associated WebSocket connection %s with server %s", connID, serverID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) GetWebSocketUpgrade() fiber.Handler {
|
||||
return wsc.upgradeWebSocket
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) GetWebSocketHandler() func(*websocket.Conn) {
|
||||
return wsc.handleWebSocket
|
||||
}
|
||||
|
||||
func (wsc *WebSocketController) BroadcastServerCreationProgress(serverID uuid.UUID, step string, status string, message string) {
|
||||
logging.Info("Broadcasting server creation progress: %s - %s: %s", serverID.String(), step, status)
|
||||
}
|
||||
4
local/middleware/access_key.go
Normal file → Executable file
4
local/middleware/access_key.go
Normal file → Executable file
@@ -10,12 +10,10 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// AccessKeyMiddleware provides authentication and permission middleware.
|
||||
type AccessKeyMiddleware struct {
|
||||
userInfo CachedUserInfo
|
||||
}
|
||||
|
||||
// NewAccessKeyMiddleware creates a new AccessKeyMiddleware.
|
||||
func NewAccessKeyMiddleware() *AccessKeyMiddleware {
|
||||
auth := &AccessKeyMiddleware{
|
||||
userInfo: CachedUserInfo{UserID: uuid.New().String(), Username: "access_key", RoleName: "Admin", Permissions: map[string]bool{
|
||||
@@ -25,9 +23,7 @@ func NewAccessKeyMiddleware() *AccessKeyMiddleware {
|
||||
return auth
|
||||
}
|
||||
|
||||
// Authenticate is a middleware for JWT authentication with enhanced security.
|
||||
func (m *AccessKeyMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
// Log authentication attempt
|
||||
ip := ctx.IP()
|
||||
userAgent := ctx.Get("User-Agent")
|
||||
|
||||
|
||||
48
local/middleware/auth.go
Normal file → Executable file
48
local/middleware/auth.go
Normal file → Executable file
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CachedUserInfo holds cached user authentication and permission data
|
||||
type CachedUserInfo struct {
|
||||
UserID string
|
||||
Username string
|
||||
@@ -25,7 +24,6 @@ type CachedUserInfo struct {
|
||||
CachedAt time.Time
|
||||
}
|
||||
|
||||
// AuthMiddleware provides authentication and permission middleware.
|
||||
type AuthMiddleware struct {
|
||||
membershipService *service.MembershipService
|
||||
cache *cache.InMemoryCache
|
||||
@@ -34,7 +32,6 @@ type AuthMiddleware struct {
|
||||
openJWTHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
// NewAuthMiddleware creates a new AuthMiddleware.
|
||||
func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache, jwtHandler *jwt.JWTHandler, openJWTHandler *jwt.OpenJWTHandler) *AuthMiddleware {
|
||||
auth := &AuthMiddleware{
|
||||
membershipService: ms,
|
||||
@@ -44,24 +41,20 @@ func NewAuthMiddleware(ms *service.MembershipService, cache *cache.InMemoryCache
|
||||
openJWTHandler: openJWTHandler,
|
||||
}
|
||||
|
||||
// Set up bidirectional relationship for cache invalidation
|
||||
ms.SetCacheInvalidator(auth)
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
// Authenticate is a middleware for JWT authentication with enhanced security.
|
||||
func (m *AuthMiddleware) AuthenticateOpen(ctx *fiber.Ctx) error {
|
||||
return m.AuthenticateWithHandler(m.openJWTHandler.JWTHandler, true, ctx)
|
||||
}
|
||||
|
||||
// Authenticate is a middleware for JWT authentication with enhanced security.
|
||||
func (m *AuthMiddleware) Authenticate(ctx *fiber.Ctx) error {
|
||||
return m.AuthenticateWithHandler(m.jwtHandler, false, ctx)
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isOpenToken bool, ctx *fiber.Ctx) error {
|
||||
// Log authentication attempt
|
||||
ip := ctx.IP()
|
||||
userAgent := ctx.Get("User-Agent")
|
||||
|
||||
@@ -89,7 +82,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
|
||||
})
|
||||
}
|
||||
|
||||
// Validate token length to prevent potential attacks
|
||||
token := parts[1]
|
||||
if len(token) < 10 || len(token) > 2048 {
|
||||
logging.Error("Authentication failed: invalid token length from IP %s", ip)
|
||||
@@ -113,7 +105,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
|
||||
})
|
||||
}
|
||||
|
||||
// Additional security: validate user ID format
|
||||
if claims.UserID == "" || len(claims.UserID) < 10 {
|
||||
logging.Error("Authentication failed: invalid user ID in token from IP %s", ip)
|
||||
return ctx.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
||||
@@ -124,10 +115,10 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
|
||||
if os.Getenv("TESTING_ENV") == "true" {
|
||||
userInfo := CachedUserInfo{UserID: uuid.New().String(), Username: "test@example.com", RoleName: "Admin", Permissions: make(map[string]bool), CachedAt: time.Now()}
|
||||
ctx.Locals("userID", userInfo.UserID)
|
||||
ctx.Locals("actorUsername", userInfo.Username)
|
||||
ctx.Locals("userInfo", userInfo)
|
||||
ctx.Locals("authTime", time.Now())
|
||||
} else {
|
||||
// Preload and cache user info to avoid database queries on permission checks
|
||||
userInfo, err := m.getCachedUserInfo(ctx.UserContext(), claims.UserID)
|
||||
if err != nil {
|
||||
logging.Error("Authentication failed: unable to load user info for %s from IP %s: %v", claims.UserID, ip, err)
|
||||
@@ -137,6 +128,7 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
|
||||
}
|
||||
|
||||
ctx.Locals("userID", claims.UserID)
|
||||
ctx.Locals("actorUsername", userInfo.Username)
|
||||
ctx.Locals("userInfo", userInfo)
|
||||
ctx.Locals("authTime", time.Now())
|
||||
}
|
||||
@@ -145,7 +137,6 @@ func (m *AuthMiddleware) AuthenticateWithHandler(jwtHandler *jwt.JWTHandler, isO
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
// HasPermission is a middleware for checking user permissions with enhanced logging.
|
||||
func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
userID, ok := ctx.Locals("userID").(string)
|
||||
@@ -160,7 +151,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
// Validate permission parameter
|
||||
if requiredPermission == "" {
|
||||
logging.Error("Permission check failed: empty permission requirement")
|
||||
return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
@@ -168,7 +158,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
|
||||
})
|
||||
}
|
||||
|
||||
// Use cached user info from authentication step - no database queries needed
|
||||
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
|
||||
if !ok {
|
||||
logging.Error("Permission check failed: no cached user info in context from IP %s", ctx.IP())
|
||||
@@ -177,7 +166,6 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
|
||||
})
|
||||
}
|
||||
|
||||
// Check if user has permission using cached data
|
||||
has := m.hasPermissionFromCache(userInfo, requiredPermission)
|
||||
|
||||
if !has {
|
||||
@@ -192,16 +180,30 @@ func (m *AuthMiddleware) HasPermission(requiredPermission string) fiber.Handler
|
||||
}
|
||||
}
|
||||
|
||||
// AuthRateLimit applies rate limiting specifically for authentication endpoints
|
||||
func (m *AuthMiddleware) RequireSuperAdmin() fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
if os.Getenv("TESTING_ENV") == "true" {
|
||||
return ctx.Next()
|
||||
}
|
||||
|
||||
userInfo, ok := ctx.Locals("userInfo").(*CachedUserInfo)
|
||||
if !ok || userInfo.RoleName != "Super Admin" {
|
||||
return ctx.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
"error": "Forbidden",
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) AuthRateLimit() fiber.Handler {
|
||||
return m.securityMW.AuthRateLimit()
|
||||
}
|
||||
|
||||
// RequireHTTPS redirects HTTP requests to HTTPS in production
|
||||
func (m *AuthMiddleware) RequireHTTPS() fiber.Handler {
|
||||
return func(ctx *fiber.Ctx) error {
|
||||
if ctx.Protocol() != "https" && ctx.Get("X-Forwarded-Proto") != "https" {
|
||||
// Allow HTTP in development/testing
|
||||
if ctx.Hostname() != "localhost" && ctx.Hostname() != "127.0.0.1" {
|
||||
httpsURL := "https://" + ctx.Hostname() + ctx.OriginalURL()
|
||||
return ctx.Redirect(httpsURL, fiber.StatusMovedPermanently)
|
||||
@@ -211,11 +213,9 @@ func (m *AuthMiddleware) RequireHTTPS() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// getCachedUserInfo retrieves and caches complete user information including permissions
|
||||
func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (*CachedUserInfo, error) {
|
||||
cacheKey := fmt.Sprintf("userinfo:%s", userID)
|
||||
|
||||
// Try cache first
|
||||
if cached, found := m.cache.Get(cacheKey); found {
|
||||
if userInfo, ok := cached.(*CachedUserInfo); ok {
|
||||
logging.DebugWithContext("AUTH_CACHE", "User info for %s found in cache", userID)
|
||||
@@ -223,13 +223,11 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
|
||||
}
|
||||
}
|
||||
|
||||
// Cache miss - load from database
|
||||
user, err := m.membershipService.GetUserWithPermissions(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build permission map for fast lookups
|
||||
permissions := make(map[string]bool)
|
||||
for _, p := range user.Role.Permissions {
|
||||
permissions[p.Name] = true
|
||||
@@ -243,34 +241,26 @@ func (m *AuthMiddleware) getCachedUserInfo(ctx context.Context, userID string) (
|
||||
CachedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Cache for 15 minutes
|
||||
m.cache.Set(cacheKey, userInfo, 15*time.Minute)
|
||||
logging.DebugWithContext("AUTH_CACHE", "User info for %s cached with %d permissions", userID, len(permissions))
|
||||
|
||||
return userInfo, nil
|
||||
}
|
||||
|
||||
// hasPermissionFromCache checks permissions using cached user info (no database queries)
|
||||
func (m *AuthMiddleware) hasPermissionFromCache(userInfo *CachedUserInfo, permission string) bool {
|
||||
// Super Admin and Admin have all permissions
|
||||
if userInfo.RoleName == "Super Admin" || userInfo.RoleName == "Admin" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check specific permission in cached map
|
||||
return userInfo.Permissions[permission]
|
||||
}
|
||||
|
||||
// InvalidateUserPermissions removes cached user info for a user
|
||||
func (m *AuthMiddleware) InvalidateUserPermissions(userID string) {
|
||||
cacheKey := fmt.Sprintf("userinfo:%s", userID)
|
||||
m.cache.Delete(cacheKey)
|
||||
logging.InfoWithContext("AUTH_CACHE", "User info cache invalidated for user %s", userID)
|
||||
}
|
||||
|
||||
// InvalidateAllUserPermissions clears all cached user info (useful for role/permission changes)
|
||||
func (m *AuthMiddleware) InvalidateAllUserPermissions() {
|
||||
// This would need to be implemented based on your cache interface
|
||||
// For now, just log that invalidation was requested
|
||||
logging.InfoWithContext("AUTH_CACHE", "All user info caches invalidation requested")
|
||||
}
|
||||
|
||||
12
local/middleware/logging/request_logging.go
Normal file → Executable file
12
local/middleware/logging/request_logging.go
Normal file → Executable file
@@ -7,25 +7,20 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RequestLoggingMiddleware logs HTTP requests and responses
|
||||
type RequestLoggingMiddleware struct {
|
||||
infoLogger *logging.InfoLogger
|
||||
}
|
||||
|
||||
// NewRequestLoggingMiddleware creates a new request logging middleware
|
||||
func NewRequestLoggingMiddleware() *RequestLoggingMiddleware {
|
||||
return &RequestLoggingMiddleware{
|
||||
infoLogger: logging.GetInfoLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the middleware handler function
|
||||
func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Record start time
|
||||
start := time.Now()
|
||||
|
||||
// Log incoming request
|
||||
userAgent := c.Get("User-Agent")
|
||||
if userAgent == "" {
|
||||
userAgent = "Unknown"
|
||||
@@ -33,17 +28,13 @@ func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
|
||||
|
||||
rlm.infoLogger.LogRequest(c.Method(), c.OriginalURL(), userAgent)
|
||||
|
||||
// Continue to next handler
|
||||
err := c.Next()
|
||||
|
||||
// Calculate duration
|
||||
duration := time.Since(start)
|
||||
|
||||
// Log response
|
||||
statusCode := c.Response().StatusCode()
|
||||
rlm.infoLogger.LogResponse(c.Method(), c.OriginalURL(), statusCode, duration.String())
|
||||
|
||||
// Log error if present
|
||||
if err != nil {
|
||||
logging.ErrorWithContext("REQUEST_MIDDLEWARE", "Request failed: %v", err)
|
||||
}
|
||||
@@ -52,10 +43,8 @@ func (rlm *RequestLoggingMiddleware) Handler() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Global request logging middleware instance
|
||||
var globalRequestLoggingMiddleware *RequestLoggingMiddleware
|
||||
|
||||
// GetRequestLoggingMiddleware returns the global request logging middleware
|
||||
func GetRequestLoggingMiddleware() *RequestLoggingMiddleware {
|
||||
if globalRequestLoggingMiddleware == nil {
|
||||
globalRequestLoggingMiddleware = NewRequestLoggingMiddleware()
|
||||
@@ -63,7 +52,6 @@ func GetRequestLoggingMiddleware() *RequestLoggingMiddleware {
|
||||
return globalRequestLoggingMiddleware
|
||||
}
|
||||
|
||||
// Handler returns the global request logging middleware handler
|
||||
func Handler() fiber.Handler {
|
||||
return GetRequestLoggingMiddleware().Handler()
|
||||
}
|
||||
|
||||
48
local/middleware/security/security.go
Normal file → Executable file
48
local/middleware/security/security.go
Normal file → Executable file
@@ -11,19 +11,16 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// RateLimiter stores rate limiting information
|
||||
type RateLimiter struct {
|
||||
requests map[string][]time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewRateLimiter creates a new rate limiter
|
||||
func NewRateLimiter() *RateLimiter {
|
||||
rl := &RateLimiter{
|
||||
requests: make(map[string][]time.Time),
|
||||
}
|
||||
|
||||
// Use graceful shutdown for cleanup goroutine
|
||||
shutdownManager := graceful.GetManager()
|
||||
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||
rl.cleanupWithContext(ctx)
|
||||
@@ -32,7 +29,6 @@ func NewRateLimiter() *RateLimiter {
|
||||
return rl
|
||||
}
|
||||
|
||||
// cleanup removes old entries from the rate limiter
|
||||
func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
@@ -62,44 +58,29 @@ func (rl *RateLimiter) cleanupWithContext(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityMiddleware provides comprehensive security middleware
|
||||
type SecurityMiddleware struct {
|
||||
rateLimiter *RateLimiter
|
||||
}
|
||||
|
||||
// NewSecurityMiddleware creates a new security middleware
|
||||
func NewSecurityMiddleware() *SecurityMiddleware {
|
||||
return &SecurityMiddleware{
|
||||
rateLimiter: NewRateLimiter(),
|
||||
}
|
||||
}
|
||||
|
||||
// SecurityHeaders adds security headers to responses
|
||||
func (sm *SecurityMiddleware) SecurityHeaders() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Prevent MIME type sniffing
|
||||
c.Set("X-Content-Type-Options", "nosniff")
|
||||
|
||||
// Prevent clickjacking
|
||||
c.Set("X-Frame-Options", "DENY")
|
||||
|
||||
// Enable XSS protection
|
||||
c.Set("X-XSS-Protection", "1; mode=block")
|
||||
|
||||
// Prevent referrer leakage
|
||||
c.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
|
||||
// Content Security Policy
|
||||
c.Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'")
|
||||
|
||||
// Permissions Policy
|
||||
c.Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()")
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimit implements rate limiting for API endpoints
|
||||
func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
@@ -111,7 +92,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
|
||||
now := time.Now()
|
||||
requests := sm.rateLimiter.requests[key]
|
||||
|
||||
// Remove requests older than duration
|
||||
filtered := make([]time.Time, 0, len(requests))
|
||||
for _, t := range requests {
|
||||
if now.Sub(t) < duration {
|
||||
@@ -119,7 +99,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if limit is exceeded
|
||||
if len(filtered) >= maxRequests {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "Rate limit exceeded",
|
||||
@@ -127,7 +106,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
|
||||
})
|
||||
}
|
||||
|
||||
// Add current request
|
||||
filtered = append(filtered, now)
|
||||
sm.rateLimiter.requests[key] = filtered
|
||||
|
||||
@@ -135,7 +113,6 @@ func (sm *SecurityMiddleware) RateLimit(maxRequests int, duration time.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
// AuthRateLimit implements stricter rate limiting for authentication endpoints
|
||||
func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ip := c.IP()
|
||||
@@ -148,7 +125,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
now := time.Now()
|
||||
requests := sm.rateLimiter.requests[key]
|
||||
|
||||
// Remove requests older than 15 minutes
|
||||
filtered := make([]time.Time, 0, len(requests))
|
||||
for _, t := range requests {
|
||||
if now.Sub(t) < 15*time.Minute {
|
||||
@@ -156,7 +132,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if limit is exceeded (5 requests per 15 minutes for auth)
|
||||
if len(filtered) >= 5 {
|
||||
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
||||
"error": "Too many authentication attempts",
|
||||
@@ -164,7 +139,6 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// Add current request
|
||||
filtered = append(filtered, now)
|
||||
sm.rateLimiter.requests[key] = filtered
|
||||
|
||||
@@ -172,20 +146,16 @@ func (sm *SecurityMiddleware) AuthRateLimit() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// InputSanitization sanitizes user input to prevent XSS and injection attacks
|
||||
func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Sanitize query parameters
|
||||
c.Request().URI().QueryArgs().VisitAll(func(key, value []byte) {
|
||||
sanitized := sanitizeInput(string(value))
|
||||
c.Request().URI().QueryArgs().Set(string(key), sanitized)
|
||||
})
|
||||
|
||||
// Store original body for processing
|
||||
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
|
||||
body := c.Body()
|
||||
if len(body) > 0 {
|
||||
// Basic sanitization - remove potentially dangerous patterns
|
||||
sanitized := sanitizeInput(string(body))
|
||||
c.Request().SetBodyString(sanitized)
|
||||
}
|
||||
@@ -195,7 +165,6 @@ func (sm *SecurityMiddleware) InputSanitization() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeInput removes potentially dangerous patterns from input
|
||||
func sanitizeInput(input string) string {
|
||||
dangerous := []string{
|
||||
"<script",
|
||||
@@ -236,7 +205,7 @@ func sanitizeInput(input string) string {
|
||||
|
||||
result := input
|
||||
lowerInput := strings.ToLower(input)
|
||||
|
||||
|
||||
for _, pattern := range dangerous {
|
||||
if strings.Contains(lowerInput, pattern) {
|
||||
return ""
|
||||
@@ -254,7 +223,6 @@ func sanitizeInput(input string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateContentType ensures only expected content types are accepted
|
||||
func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
|
||||
@@ -265,7 +233,6 @@ func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.
|
||||
})
|
||||
}
|
||||
|
||||
// Check if content type is allowed
|
||||
allowed := false
|
||||
for _, allowedType := range allowedTypes {
|
||||
if strings.Contains(contentType, allowedType) {
|
||||
@@ -285,7 +252,6 @@ func (sm *SecurityMiddleware) ValidateContentType(allowedTypes ...string) fiber.
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUserAgent blocks requests with suspicious or missing user agents
|
||||
func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
|
||||
suspiciousAgents := []string{
|
||||
"sqlmap",
|
||||
@@ -296,21 +262,19 @@ func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
|
||||
"dirb",
|
||||
"dirbuster",
|
||||
"wpscan",
|
||||
"curl/7.0", // Very old curl versions
|
||||
"wget/1.0", // Very old wget versions
|
||||
"curl/7.0",
|
||||
"wget/1.0",
|
||||
}
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
userAgent := strings.ToLower(c.Get("User-Agent"))
|
||||
|
||||
// Block empty user agents
|
||||
if userAgent == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "User-Agent header is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Block suspicious user agents
|
||||
for _, suspicious := range suspiciousAgents {
|
||||
if strings.Contains(userAgent, suspicious) {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
|
||||
@@ -323,7 +287,6 @@ func (sm *SecurityMiddleware) ValidateUserAgent() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// RequestSizeLimit limits the size of incoming requests
|
||||
func (sm *SecurityMiddleware) RequestSizeLimit(maxSize int) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" {
|
||||
@@ -340,19 +303,15 @@ func (sm *SecurityMiddleware) RequestSizeLimit(maxSize int) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// LogSecurityEvents logs security-related events
|
||||
func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
|
||||
// Process request
|
||||
err := c.Next()
|
||||
|
||||
// Log suspicious activity
|
||||
status := c.Response().StatusCode()
|
||||
if status == 401 || status == 403 || status == 429 {
|
||||
duration := time.Since(start)
|
||||
// In a real implementation, you would send this to your logging system
|
||||
fmt.Printf("[SECURITY] %s %s %s %d %v %s\n",
|
||||
time.Now().Format(time.RFC3339),
|
||||
c.IP(),
|
||||
@@ -367,7 +326,6 @@ func (sm *SecurityMiddleware) LogSecurityEvents() fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// TimeoutMiddleware adds request timeout
|
||||
func (sm *SecurityMiddleware) TimeoutMiddleware(timeout time.Duration) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(c.UserContext(), timeout)
|
||||
|
||||
41
local/migrations/001_upgrade_password_security.go
Normal file → Executable file
41
local/migrations/001_upgrade_password_security.go
Normal file → Executable file
@@ -9,21 +9,17 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Migration001UpgradePasswordSecurity migrates existing user passwords from encrypted to hashed format
|
||||
type Migration001UpgradePasswordSecurity struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// NewMigration001UpgradePasswordSecurity creates a new password security migration
|
||||
func NewMigration001UpgradePasswordSecurity(db *gorm.DB) *Migration001UpgradePasswordSecurity {
|
||||
return &Migration001UpgradePasswordSecurity{DB: db}
|
||||
}
|
||||
|
||||
// Up executes the migration
|
||||
func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
logging.Info("Starting password security upgrade migration...")
|
||||
|
||||
// Check if migration has already been applied
|
||||
var migrationRecord MigrationRecord
|
||||
err := m.DB.Where("migration_name = ?", "001_upgrade_password_security").First(&migrationRecord).Error
|
||||
if err == nil {
|
||||
@@ -31,12 +27,10 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create migration tracking table if it doesn't exist
|
||||
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := m.DB.Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to start transaction: %v", tx.Error)
|
||||
@@ -47,16 +41,13 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Add a backup column for old passwords (temporary)
|
||||
if err := tx.Exec("ALTER TABLE users ADD COLUMN password_backup TEXT").Error; err != nil {
|
||||
// Column might already exist, ignore if it's a duplicate column error
|
||||
if !isDuplicateColumnError(err) {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to add backup column: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all users with encrypted passwords
|
||||
var users []UserForMigration
|
||||
if err := tx.Find(&users).Error; err != nil {
|
||||
tx.Rollback()
|
||||
@@ -72,19 +63,15 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
if err := m.migrateUserPassword(tx, &user); err != nil {
|
||||
logging.Error("Failed to migrate user %s (ID: %s): %v", user.Username, user.ID, err)
|
||||
failedCount++
|
||||
// Continue with other users rather than failing completely
|
||||
continue
|
||||
}
|
||||
migratedCount++
|
||||
}
|
||||
|
||||
// Remove backup column after successful migration
|
||||
if err := tx.Exec("ALTER TABLE users DROP COLUMN password_backup").Error; err != nil {
|
||||
logging.Error("Failed to remove backup column (non-critical): %v", err)
|
||||
// Don't fail the migration for this
|
||||
}
|
||||
|
||||
// Record successful migration
|
||||
migrationRecord = MigrationRecord{
|
||||
MigrationName: "001_upgrade_password_security",
|
||||
AppliedAt: "datetime('now')",
|
||||
@@ -97,7 +84,6 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
return fmt.Errorf("failed to record migration: %v", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("failed to commit migration: %v", err)
|
||||
}
|
||||
@@ -111,32 +97,24 @@ func (m *Migration001UpgradePasswordSecurity) Up() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateUserPassword migrates a single user's password
|
||||
func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, user *UserForMigration) error {
|
||||
// Skip if password is already hashed (bcrypt hashes start with $2a$, $2b$, or $2y$)
|
||||
if isAlreadyHashed(user.Password) {
|
||||
logging.Debug("User %s already has hashed password, skipping", user.Username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Backup original password
|
||||
if err := tx.Model(user).Update("password_backup", user.Password).Error; err != nil {
|
||||
return fmt.Errorf("failed to backup password: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt the old password
|
||||
var plainPassword string
|
||||
|
||||
// First, try to decrypt using the old encryption method
|
||||
decrypted, err := decryptOldPassword(user.Password)
|
||||
if err != nil {
|
||||
// If decryption fails, the password might already be plain text or corrupted
|
||||
logging.Error("Failed to decrypt password for user %s, treating as plain text: %v", user.Username, err)
|
||||
|
||||
// Use original password as-is (might be plain text from development)
|
||||
plainPassword = user.Password
|
||||
|
||||
// Validate it's not obviously encrypted data
|
||||
if len(plainPassword) > 100 || containsBinaryData(plainPassword) {
|
||||
return fmt.Errorf("password appears to be corrupted encrypted data")
|
||||
}
|
||||
@@ -144,7 +122,6 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
|
||||
plainPassword = decrypted
|
||||
}
|
||||
|
||||
// Validate plain password
|
||||
if plainPassword == "" {
|
||||
return errors.New("decrypted password is empty")
|
||||
}
|
||||
@@ -153,13 +130,11 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
|
||||
return errors.New("password too short after decryption")
|
||||
}
|
||||
|
||||
// Hash the plain password using bcrypt
|
||||
hashedPassword, err := password.HashPassword(plainPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %v", err)
|
||||
}
|
||||
|
||||
// Update with hashed password
|
||||
if err := tx.Model(user).Update("password", hashedPassword).Error; err != nil {
|
||||
return fmt.Errorf("failed to update password: %v", err)
|
||||
}
|
||||
@@ -168,19 +143,16 @@ func (m *Migration001UpgradePasswordSecurity) migrateUserPassword(tx *gorm.DB, u
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserForMigration represents a user record for migration purposes
|
||||
type UserForMigration struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Username string `gorm:"column:username"`
|
||||
Password string `gorm:"column:password"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (UserForMigration) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// MigrationRecord tracks applied migrations
|
||||
type MigrationRecord struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
MigrationName string `gorm:"unique;not null"`
|
||||
@@ -189,49 +161,38 @@ type MigrationRecord struct {
|
||||
Notes string
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (MigrationRecord) TableName() string {
|
||||
return "migration_records"
|
||||
}
|
||||
|
||||
// isAlreadyHashed checks if a password is already bcrypt hashed
|
||||
func isAlreadyHashed(password string) bool {
|
||||
return len(password) >= 60 && (password[:4] == "$2a$" || password[:4] == "$2b$" || password[:4] == "$2y$")
|
||||
}
|
||||
|
||||
// containsBinaryData checks if a string contains binary data
|
||||
func containsBinaryData(s string) bool {
|
||||
for _, b := range []byte(s) {
|
||||
if b < 32 && b != 9 && b != 10 && b != 13 { // Allow tab, newline, carriage return
|
||||
if b < 32 && b != 9 && b != 10 && b != 13 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isDuplicateColumnError checks if an error is due to duplicate column
|
||||
func isDuplicateColumnError(err error) bool {
|
||||
errStr := err.Error()
|
||||
return fmt.Sprintf("%v", errStr) == "duplicate column name: password_backup" ||
|
||||
fmt.Sprintf("%v", errStr) == "SQLITE_ERROR: duplicate column name: password_backup"
|
||||
}
|
||||
|
||||
// decryptOldPassword attempts to decrypt using the old encryption method
|
||||
// This is a simplified version of the old DecryptPassword function
|
||||
func decryptOldPassword(encryptedPassword string) (string, error) {
|
||||
// This would use the old decryption logic
|
||||
// For now, we'll return an error to force treating as plain text
|
||||
// In a real scenario, you'd implement the old decryption here
|
||||
return "", errors.New("old decryption not implemented - treating as plain text")
|
||||
}
|
||||
|
||||
// Down reverses the migration (if needed)
|
||||
func (m *Migration001UpgradePasswordSecurity) Down() error {
|
||||
logging.Error("Password security migration rollback is not supported for security reasons")
|
||||
return errors.New("password security migration rollback is not supported")
|
||||
}
|
||||
|
||||
// RunMigration is a convenience function to run the migration
|
||||
func RunPasswordSecurityMigration(db *gorm.DB) error {
|
||||
migration := NewMigration001UpgradePasswordSecurity(db)
|
||||
return migration.Up()
|
||||
|
||||
21
local/migrations/002_migrate_to_uuid.go
Normal file → Executable file
21
local/migrations/002_migrate_to_uuid.go
Normal file → Executable file
@@ -9,21 +9,17 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Migration002MigrateToUUID migrates tables from integer IDs to UUIDs
|
||||
type Migration002MigrateToUUID struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// NewMigration002MigrateToUUID creates a new UUID migration
|
||||
func NewMigration002MigrateToUUID(db *gorm.DB) *Migration002MigrateToUUID {
|
||||
return &Migration002MigrateToUUID{DB: db}
|
||||
}
|
||||
|
||||
// Up executes the migration
|
||||
func (m *Migration002MigrateToUUID) Up() error {
|
||||
logging.Info("Checking UUID migration...")
|
||||
|
||||
// Check if migration is needed by looking at the servers table structure
|
||||
if !m.needsMigration() {
|
||||
logging.Info("UUID migration not needed - tables already use UUID primary keys")
|
||||
return nil
|
||||
@@ -31,7 +27,6 @@ func (m *Migration002MigrateToUUID) Up() error {
|
||||
|
||||
logging.Info("Starting UUID migration...")
|
||||
|
||||
// Check if migration has already been applied
|
||||
var migrationRecord MigrationRecord
|
||||
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
|
||||
if err == nil {
|
||||
@@ -39,12 +34,10 @@ func (m *Migration002MigrateToUUID) Up() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create migration tracking table if it doesn't exist
|
||||
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||
}
|
||||
|
||||
// Execute the UUID migration using the existing migration function
|
||||
logging.Info("Executing UUID migration...")
|
||||
if err := runUUIDMigrationSQL(m.DB); err != nil {
|
||||
return fmt.Errorf("failed to execute UUID migration: %v", err)
|
||||
@@ -54,9 +47,7 @@ func (m *Migration002MigrateToUUID) Up() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// needsMigration checks if the UUID migration is needed by examining table structure
|
||||
func (m *Migration002MigrateToUUID) needsMigration() bool {
|
||||
// Check if servers table exists and has integer primary key
|
||||
var result struct {
|
||||
Type string `gorm:"column:type"`
|
||||
}
|
||||
@@ -67,29 +58,22 @@ func (m *Migration002MigrateToUUID) needsMigration() bool {
|
||||
`).Scan(&result).Error
|
||||
|
||||
if err != nil || result.Type == "" {
|
||||
// Table doesn't exist or no primary key found - assume no migration needed
|
||||
return false
|
||||
}
|
||||
|
||||
// If the primary key is INTEGER, we need migration
|
||||
// If it's TEXT (UUID), migration already done
|
||||
return result.Type == "INTEGER" || result.Type == "integer"
|
||||
}
|
||||
|
||||
// Down reverses the migration (not implemented for safety)
|
||||
func (m *Migration002MigrateToUUID) Down() error {
|
||||
logging.Error("UUID migration rollback is not supported for data safety reasons")
|
||||
return fmt.Errorf("UUID migration rollback is not supported")
|
||||
}
|
||||
|
||||
// runUUIDMigrationSQL executes the UUID migration using the SQL file
|
||||
func runUUIDMigrationSQL(db *gorm.DB) error {
|
||||
// Disable foreign key constraints during migration
|
||||
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
|
||||
return fmt.Errorf("failed to disable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
// Start transaction
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to start transaction: %v", tx.Error)
|
||||
@@ -101,25 +85,21 @@ func runUUIDMigrationSQL(db *gorm.DB) error {
|
||||
}
|
||||
}()
|
||||
|
||||
// Read the migration SQL from file
|
||||
sqlPath := filepath.Join("scripts", "migrations", "002_migrate_servers_to_uuid.sql")
|
||||
migrationSQL, err := ioutil.ReadFile(sqlPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read migration SQL file: %v", err)
|
||||
}
|
||||
|
||||
// Execute the migration
|
||||
if err := tx.Exec(string(migrationSQL)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to execute migration: %v", err)
|
||||
}
|
||||
|
||||
// Commit transaction
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("failed to commit migration: %v", err)
|
||||
}
|
||||
|
||||
// Re-enable foreign key constraints
|
||||
if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
|
||||
return fmt.Errorf("failed to re-enable foreign keys: %v", err)
|
||||
}
|
||||
@@ -127,7 +107,6 @@ func runUUIDMigrationSQL(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunUUIDMigration is a convenience function to run the migration
|
||||
func RunUUIDMigration(db *gorm.DB) error {
|
||||
migration := NewMigration002MigrateToUUID(db)
|
||||
return migration.Up()
|
||||
|
||||
106
local/migrations/003_update_state_history_sessions.go
Executable file
106
local/migrations/003_update_state_history_sessions.go
Executable file
@@ -0,0 +1,106 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UpdateStateHistorySessions struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewUpdateStateHistorySessions(db *gorm.DB) *UpdateStateHistorySessions {
|
||||
return &UpdateStateHistorySessions{DB: db}
|
||||
}
|
||||
|
||||
func (m *UpdateStateHistorySessions) Up() error {
|
||||
logging.Info("Checking UUID migration...")
|
||||
|
||||
if !m.needsMigration() {
|
||||
logging.Info("UUID migration not needed - tables already use UUID primary keys")
|
||||
return nil
|
||||
}
|
||||
|
||||
logging.Info("Starting UUID migration...")
|
||||
|
||||
var migrationRecord MigrationRecord
|
||||
err := m.DB.Where("migration_name = ?", "002_migrate_to_uuid").First(&migrationRecord).Error
|
||||
if err == nil {
|
||||
logging.Info("UUID migration already applied, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||
}
|
||||
|
||||
logging.Info("Executing UUID migration...")
|
||||
if err := runUUIDMigrationSQL(m.DB); err != nil {
|
||||
return fmt.Errorf("failed to execute UUID migration: %v", err)
|
||||
}
|
||||
|
||||
logging.Info("UUID migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *UpdateStateHistorySessions) needsMigration() bool {
|
||||
var result struct {
|
||||
Exists bool `gorm:"column:exists"`
|
||||
}
|
||||
|
||||
err := m.DB.Raw(`
|
||||
SELECT count(*) > 0 as exists FROM state_history
|
||||
WHERE length(session) > 1 LIMIT 1;
|
||||
`).Scan(&result).Error
|
||||
|
||||
if err != nil || !result.Exists {
|
||||
return false
|
||||
}
|
||||
return result.Exists
|
||||
}
|
||||
|
||||
func (m *UpdateStateHistorySessions) Down() error {
|
||||
logging.Error("UUID migration rollback is not supported for data safety reasons")
|
||||
return fmt.Errorf("UUID migration rollback is not supported")
|
||||
}
|
||||
|
||||
func runUpdateStateHistorySessionsMigration(db *gorm.DB) error {
|
||||
if err := db.Exec("PRAGMA foreign_keys=OFF").Error; err != nil {
|
||||
return fmt.Errorf("failed to disable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to start transaction: %v", tx.Error)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
migrationSQL := "UPDATE state_history SET session = upper(substr(session, 1, 1));"
|
||||
|
||||
if err := tx.Exec(string(migrationSQL)).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("failed to execute migration: %v", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("failed to commit migration: %v", err)
|
||||
}
|
||||
|
||||
if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil {
|
||||
return fmt.Errorf("failed to re-enable foreign keys: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func RunUpdateStateHistorySessionsMigration(db *gorm.DB) error {
|
||||
migration := NewUpdateStateHistorySessions(db)
|
||||
return migration.Up()
|
||||
}
|
||||
106
local/migrations/004_fix_ddl_comments.go
Executable file
106
local/migrations/004_fix_ddl_comments.go
Executable file
@@ -0,0 +1,106 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var sqlLineCommentRegexp = regexp.MustCompile(`--[^\n]*`)
|
||||
|
||||
type Migration004FixDDLComments struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewMigration004FixDDLComments(db *gorm.DB) *Migration004FixDDLComments {
|
||||
return &Migration004FixDDLComments{DB: db}
|
||||
}
|
||||
|
||||
func (m *Migration004FixDDLComments) Up() error {
|
||||
if err := m.DB.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to create migration tracking table: %v", err)
|
||||
}
|
||||
|
||||
var record MigrationRecord
|
||||
if err := m.DB.Where("migration_name = ?", "004_fix_ddl_comments").First(&record).Error; err == nil {
|
||||
logging.Info("DDL comment fix migration already applied, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
var tables []string
|
||||
if err := m.DB.Raw(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE '%--%' ORDER BY name",
|
||||
).Scan(&tables).Error; err != nil {
|
||||
return fmt.Errorf("failed to query tables with DDL comments: %v", err)
|
||||
}
|
||||
|
||||
if len(tables) == 0 {
|
||||
logging.Info("No tables with DDL comments found, skipping")
|
||||
}
|
||||
|
||||
for _, table := range tables {
|
||||
if err := m.rebuildTable(table); err != nil {
|
||||
return fmt.Errorf("failed to rebuild table %q: %v", table, err)
|
||||
}
|
||||
}
|
||||
|
||||
record = MigrationRecord{
|
||||
MigrationName: "004_fix_ddl_comments",
|
||||
AppliedAt: "datetime('now')",
|
||||
Success: true,
|
||||
Notes: fmt.Sprintf("Rebuilt %d table(s): %s", len(tables), strings.Join(tables, ", ")),
|
||||
}
|
||||
if err := m.DB.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("failed to record migration: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migration004FixDDLComments) rebuildTable(table string) error {
|
||||
var originalDDL string
|
||||
if err := m.DB.Raw(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", table,
|
||||
).Row().Scan(&originalDDL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cleanDDL := sqlLineCommentRegexp.ReplaceAllString(originalDDL, "")
|
||||
|
||||
tmpTable := table + "__fixed"
|
||||
createTmp := strings.Replace(cleanDDL, `"`+table+`"`, "`"+tmpTable+"`", 1)
|
||||
if createTmp == cleanDDL {
|
||||
createTmp = strings.Replace(cleanDDL, table, "`"+tmpTable+"`", 1)
|
||||
}
|
||||
|
||||
logging.Info("Rebuilding table %q to remove DDL comments...", table)
|
||||
|
||||
return m.DB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, sql := range []string{
|
||||
"PRAGMA foreign_keys = OFF",
|
||||
fmt.Sprintf("DROP TABLE IF EXISTS `%s`", tmpTable),
|
||||
createTmp,
|
||||
fmt.Sprintf("INSERT INTO `%s` SELECT * FROM `%s`", tmpTable, table),
|
||||
fmt.Sprintf("DROP TABLE `%s`", table),
|
||||
fmt.Sprintf("ALTER TABLE `%s` RENAME TO `%s`", tmpTable, table),
|
||||
"PRAGMA foreign_keys = ON",
|
||||
} {
|
||||
if err := tx.Exec(sql).Error; err != nil {
|
||||
return fmt.Errorf("exec %q: %w", sql, err)
|
||||
}
|
||||
}
|
||||
logging.Info("Table %q rebuilt successfully", table)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Migration004FixDDLComments) Down() error {
|
||||
return fmt.Errorf("DDL comment fix migration rollback is not supported")
|
||||
}
|
||||
|
||||
func RunFixDDLCommentsMigration(db *gorm.DB) error {
|
||||
return NewMigration004FixDDLComments(db).Up()
|
||||
}
|
||||
33
local/migrations/005_add_platform_columns.go
Normal file
33
local/migrations/005_add_platform_columns.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RunAddPlatformColumnsMigration(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to ensure migration_records table: %v", err)
|
||||
}
|
||||
|
||||
var record MigrationRecord
|
||||
if err := db.Where("migration_name = ?", "005_add_platform_columns").First(&record).Error; err == nil {
|
||||
logging.Info("Platform columns migration already applied, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
record = MigrationRecord{
|
||||
MigrationName: "005_add_platform_columns",
|
||||
AppliedAt: "datetime('now')",
|
||||
Success: true,
|
||||
Notes: "Added Platform and ContainerID columns to servers table via GORM AutoMigrate",
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("failed to record migration 005: %v", err)
|
||||
}
|
||||
|
||||
logging.Info("Platform columns migration recorded")
|
||||
return nil
|
||||
}
|
||||
33
local/migrations/006_add_activity_log.go
Normal file
33
local/migrations/006_add_activity_log.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func RunAddActivityLogMigration(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to ensure migration_records table: %v", err)
|
||||
}
|
||||
|
||||
var record MigrationRecord
|
||||
if err := db.Where("migration_name = ?", "006_add_activity_log").First(&record).Error; err == nil {
|
||||
logging.Info("Activity log migration already applied, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
record = MigrationRecord{
|
||||
MigrationName: "006_add_activity_log",
|
||||
AppliedAt: "datetime('now')",
|
||||
Success: true,
|
||||
Notes: "Added ActivityLog table via GORM AutoMigrate",
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("failed to record migration 006: %v", err)
|
||||
}
|
||||
|
||||
logging.Info("Activity log migration recorded")
|
||||
return nil
|
||||
}
|
||||
51
local/migrations/007_add_config_presets.go
Normal file
51
local/migrations/007_add_config_presets.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigPresetMigration struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"not null"`
|
||||
Description string
|
||||
Configuration string `gorm:"type:text"`
|
||||
AssistRules string `gorm:"type:text"`
|
||||
Event string `gorm:"type:text"`
|
||||
EventRules string `gorm:"type:text"`
|
||||
Settings string `gorm:"type:text"`
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (ConfigPresetMigration) TableName() string {
|
||||
return "config_presets"
|
||||
}
|
||||
|
||||
func RunAddConfigPresetsMigration(db *gorm.DB) error {
|
||||
if err := db.AutoMigrate(&MigrationRecord{}); err != nil {
|
||||
return fmt.Errorf("failed to ensure migration_records table: %v", err)
|
||||
}
|
||||
|
||||
var record MigrationRecord
|
||||
if err := db.Where("migration_name = ?", "007_add_config_presets").First(&record).Error; err == nil {
|
||||
logging.Info("Config presets migration already applied, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
record = MigrationRecord{
|
||||
MigrationName: "007_add_config_presets",
|
||||
AppliedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Success: true,
|
||||
Notes: "Added ConfigPreset table via GORM AutoMigrate",
|
||||
}
|
||||
if err := db.Create(&record).Error; err != nil {
|
||||
return fmt.Errorf("failed to record migration 007: %v", err)
|
||||
}
|
||||
|
||||
logging.Info("Config presets migration recorded")
|
||||
return nil
|
||||
}
|
||||
84
local/model/activity_log.go
Normal file
84
local/model/activity_log.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActionType string
|
||||
|
||||
const (
|
||||
ActionConfigUpdate ActionType = "config_update"
|
||||
ActionServerStart ActionType = "server_start"
|
||||
ActionServerStop ActionType = "server_stop"
|
||||
ActionServerRestart ActionType = "server_restart"
|
||||
ActionLeaderboardUpdate ActionType = "leaderboard_update"
|
||||
ActionPresetCreate ActionType = "preset_create"
|
||||
ActionPresetApply ActionType = "preset_apply"
|
||||
)
|
||||
|
||||
type ActivityLog struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
ServerID *uuid.UUID `json:"serverId" gorm:"type:uuid;index"`
|
||||
Server *Server `json:"server,omitempty" gorm:"foreignKey:ServerID"`
|
||||
UserID string `json:"userId" gorm:"not null"`
|
||||
Username string `json:"username" gorm:"not null"`
|
||||
Action ActionType `json:"action" gorm:"not null"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func (a *ActivityLog) BeforeCreate(tx *gorm.DB) error {
|
||||
if a.ID == uuid.Nil {
|
||||
a.ID = uuid.New()
|
||||
}
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ActivityLogFilter struct {
|
||||
BaseFilter
|
||||
DateRangeFilter
|
||||
UserID string `query:"user_id"`
|
||||
Action ActionType `query:"action"`
|
||||
ServerID string `query:"server_id"`
|
||||
}
|
||||
|
||||
func (f *ActivityLogFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
if f.ServerID != "" {
|
||||
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
|
||||
query = query.Where("server_id = ?", serverUUID)
|
||||
}
|
||||
}
|
||||
if f.UserID != "" {
|
||||
query = query.Where("user_id = ?", f.UserID)
|
||||
}
|
||||
if f.Action != "" {
|
||||
query = query.Where("action = ?", f.Action)
|
||||
}
|
||||
timeZero := time.Time{}
|
||||
if f.StartDate != timeZero {
|
||||
query = query.Where("created_at >= ?", f.StartDate)
|
||||
}
|
||||
if f.EndDate != timeZero {
|
||||
query = query.Where("created_at <= ?", f.EndDate)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func (f *ActivityLogFilter) GetSorting() (field string, desc bool) {
|
||||
if f.SortBy == "" {
|
||||
return "created_at", true
|
||||
}
|
||||
return f.SortBy, f.SortDesc
|
||||
}
|
||||
|
||||
type LastActivityInfo struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
Username string `json:"username"`
|
||||
Action ActionType `json:"action"`
|
||||
}
|
||||
38
local/model/cache.go
Normal file → Executable file
38
local/model/cache.go
Normal file → Executable file
@@ -6,20 +6,17 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// StatusCache represents a cached server status with expiration
|
||||
type StatusCache struct {
|
||||
Status ServiceStatus
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// CacheConfig holds configuration for cache behavior
|
||||
type CacheConfig struct {
|
||||
ExpirationTime time.Duration // How long before a cache entry expires
|
||||
ThrottleTime time.Duration // Minimum time between status checks
|
||||
DefaultStatus ServiceStatus // Default status to return when throttled
|
||||
ExpirationTime time.Duration
|
||||
ThrottleTime time.Duration
|
||||
DefaultStatus ServiceStatus
|
||||
}
|
||||
|
||||
// ServerStatusCache manages cached server statuses
|
||||
type ServerStatusCache struct {
|
||||
sync.RWMutex
|
||||
cache map[string]*StatusCache
|
||||
@@ -27,7 +24,6 @@ type ServerStatusCache struct {
|
||||
lastChecked map[string]time.Time
|
||||
}
|
||||
|
||||
// NewServerStatusCache creates a new server status cache
|
||||
func NewServerStatusCache(config CacheConfig) *ServerStatusCache {
|
||||
return &ServerStatusCache{
|
||||
cache: make(map[string]*StatusCache),
|
||||
@@ -36,12 +32,10 @@ func NewServerStatusCache(config CacheConfig) *ServerStatusCache {
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus retrieves the cached status or indicates if a fresh check is needed
|
||||
func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
|
||||
// Check if we're being throttled
|
||||
if lastCheck, exists := c.lastChecked[serviceName]; exists {
|
||||
if time.Since(lastCheck) < c.config.ThrottleTime {
|
||||
if cached, ok := c.cache[serviceName]; ok {
|
||||
@@ -51,7 +45,6 @@ func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a valid cached entry
|
||||
if cached, ok := c.cache[serviceName]; ok {
|
||||
if time.Since(cached.UpdatedAt) < c.config.ExpirationTime {
|
||||
return cached.Status, false
|
||||
@@ -61,7 +54,6 @@ func (c *ServerStatusCache) GetStatus(serviceName string) (ServiceStatus, bool)
|
||||
return StatusUnknown, true
|
||||
}
|
||||
|
||||
// UpdateStatus updates the cache with a new status
|
||||
func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatus) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -73,7 +65,6 @@ func (c *ServerStatusCache) UpdateStatus(serviceName string, status ServiceStatu
|
||||
c.lastChecked[serviceName] = time.Now()
|
||||
}
|
||||
|
||||
// InvalidateStatus removes a specific service from the cache
|
||||
func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -82,7 +73,6 @@ func (c *ServerStatusCache) InvalidateStatus(serviceName string) {
|
||||
delete(c.lastChecked, serviceName)
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (c *ServerStatusCache) Clear() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -91,13 +81,11 @@ func (c *ServerStatusCache) Clear() {
|
||||
c.lastChecked = make(map[string]time.Time)
|
||||
}
|
||||
|
||||
// LookupCache provides a generic cache for lookup data
|
||||
type LookupCache struct {
|
||||
sync.RWMutex
|
||||
data map[string]interface{}
|
||||
}
|
||||
|
||||
// NewLookupCache creates a new lookup cache
|
||||
func NewLookupCache() *LookupCache {
|
||||
logging.Debug("Initializing new LookupCache")
|
||||
return &LookupCache{
|
||||
@@ -105,7 +93,6 @@ func NewLookupCache() *LookupCache {
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a cached value by key
|
||||
func (c *LookupCache) Get(key string) (interface{}, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -119,7 +106,6 @@ func (c *LookupCache) Get(key string) (interface{}, bool) {
|
||||
return value, exists
|
||||
}
|
||||
|
||||
// Set stores a value in the cache
|
||||
func (c *LookupCache) Set(key string, value interface{}) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -128,7 +114,6 @@ func (c *LookupCache) Set(key string, value interface{}) {
|
||||
logging.Debug("Cache SET for key: %s", key)
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (c *LookupCache) Clear() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -137,13 +122,11 @@ func (c *LookupCache) Clear() {
|
||||
logging.Debug("Cache CLEARED")
|
||||
}
|
||||
|
||||
// ConfigEntry represents a cached configuration entry with its update time
|
||||
type ConfigEntry[T any] struct {
|
||||
Data T
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// getConfigFromCache is a generic helper function to retrieve cached configs
|
||||
func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string, expirationTime time.Duration) (*T, bool) {
|
||||
if entry, ok := cache[serverID]; ok {
|
||||
if time.Since(entry.UpdatedAt) < expirationTime {
|
||||
@@ -157,7 +140,6 @@ func getConfigFromCache[T any](cache map[string]*ConfigEntry[T], serverID string
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// updateConfigInCache is a generic helper function to update cached configs
|
||||
func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID string, data T) {
|
||||
cache[serverID] = &ConfigEntry[T]{
|
||||
Data: data,
|
||||
@@ -166,7 +148,6 @@ func updateConfigInCache[T any](cache map[string]*ConfigEntry[T], serverID strin
|
||||
logging.Debug("Config cache SET for server ID: %s", serverID)
|
||||
}
|
||||
|
||||
// ServerConfigCache manages cached server configurations
|
||||
type ServerConfigCache struct {
|
||||
sync.RWMutex
|
||||
configuration map[string]*ConfigEntry[Configuration]
|
||||
@@ -177,7 +158,6 @@ type ServerConfigCache struct {
|
||||
config CacheConfig
|
||||
}
|
||||
|
||||
// NewServerConfigCache creates a new server configuration cache
|
||||
func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
|
||||
logging.Debug("Initializing new ServerConfigCache with expiration time: %v, throttle time: %v", config.ExpirationTime, config.ThrottleTime)
|
||||
return &ServerConfigCache{
|
||||
@@ -190,7 +170,6 @@ func NewServerConfigCache(config CacheConfig) *ServerConfigCache {
|
||||
}
|
||||
}
|
||||
|
||||
// GetConfiguration retrieves a cached configuration
|
||||
func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -198,7 +177,6 @@ func (c *ServerConfigCache) GetConfiguration(serverID string) (*Configuration, b
|
||||
return getConfigFromCache(c.configuration, serverID, c.config.ExpirationTime)
|
||||
}
|
||||
|
||||
// GetAssistRules retrieves cached assist rules
|
||||
func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -206,7 +184,6 @@ func (c *ServerConfigCache) GetAssistRules(serverID string) (*AssistRules, bool)
|
||||
return getConfigFromCache(c.assistRules, serverID, c.config.ExpirationTime)
|
||||
}
|
||||
|
||||
// GetEvent retrieves cached event configuration
|
||||
func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -214,7 +191,6 @@ func (c *ServerConfigCache) GetEvent(serverID string) (*EventConfig, bool) {
|
||||
return getConfigFromCache(c.event, serverID, c.config.ExpirationTime)
|
||||
}
|
||||
|
||||
// GetEventRules retrieves cached event rules
|
||||
func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -222,7 +198,6 @@ func (c *ServerConfigCache) GetEventRules(serverID string) (*EventRules, bool) {
|
||||
return getConfigFromCache(c.eventRules, serverID, c.config.ExpirationTime)
|
||||
}
|
||||
|
||||
// GetSettings retrieves cached server settings
|
||||
func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool) {
|
||||
c.RLock()
|
||||
defer c.RUnlock()
|
||||
@@ -230,7 +205,6 @@ func (c *ServerConfigCache) GetSettings(serverID string) (*ServerSettings, bool)
|
||||
return getConfigFromCache(c.settings, serverID, c.config.ExpirationTime)
|
||||
}
|
||||
|
||||
// UpdateConfiguration updates the configuration cache
|
||||
func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configuration) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -238,7 +212,6 @@ func (c *ServerConfigCache) UpdateConfiguration(serverID string, config Configur
|
||||
updateConfigInCache(c.configuration, serverID, config)
|
||||
}
|
||||
|
||||
// UpdateAssistRules updates the assist rules cache
|
||||
func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -246,7 +219,6 @@ func (c *ServerConfigCache) UpdateAssistRules(serverID string, rules AssistRules
|
||||
updateConfigInCache(c.assistRules, serverID, rules)
|
||||
}
|
||||
|
||||
// UpdateEvent updates the event configuration cache
|
||||
func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -254,7 +226,6 @@ func (c *ServerConfigCache) UpdateEvent(serverID string, event EventConfig) {
|
||||
updateConfigInCache(c.event, serverID, event)
|
||||
}
|
||||
|
||||
// UpdateEventRules updates the event rules cache
|
||||
func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -262,7 +233,6 @@ func (c *ServerConfigCache) UpdateEventRules(serverID string, rules EventRules)
|
||||
updateConfigInCache(c.eventRules, serverID, rules)
|
||||
}
|
||||
|
||||
// UpdateSettings updates the server settings cache
|
||||
func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSettings) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -270,7 +240,6 @@ func (c *ServerConfigCache) UpdateSettings(serverID string, settings ServerSetti
|
||||
updateConfigInCache(c.settings, serverID, settings)
|
||||
}
|
||||
|
||||
// InvalidateServerCache removes all cached configurations for a specific server
|
||||
func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
@@ -283,7 +252,6 @@ func (c *ServerConfigCache) InvalidateServerCache(serverID string) {
|
||||
delete(c.settings, serverID)
|
||||
}
|
||||
|
||||
// Clear removes all entries from the cache
|
||||
func (c *ServerConfigCache) Clear() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
|
||||
24
local/model/config.go
Normal file → Executable file
24
local/model/config.go
Normal file → Executable file
@@ -13,17 +13,15 @@ import (
|
||||
type IntString int
|
||||
type IntBool int
|
||||
|
||||
// Config tracks configuration modifications
|
||||
type Config struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
|
||||
ConfigFile string `json:"configFile" gorm:"not null"` // e.g. "settings.json"
|
||||
ConfigFile string `json:"configFile" gorm:"not null"`
|
||||
OldConfig string `json:"oldConfig" gorm:"type:text"`
|
||||
NewConfig string `json:"newConfig" gorm:"type:text"`
|
||||
ChangedAt time.Time `json:"changedAt" gorm:"default:CURRENT_TIMESTAMP"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new config entries
|
||||
func (c *Config) BeforeCreate(tx *gorm.DB) error {
|
||||
if c.ID == uuid.Nil {
|
||||
c.ID = uuid.New()
|
||||
@@ -42,6 +40,14 @@ type Configurations struct {
|
||||
Settings ServerSettings `json:"settings"`
|
||||
}
|
||||
|
||||
type ConfigurationsUpdateRequest struct {
|
||||
Configuration *map[string]interface{} `json:"configuration"`
|
||||
AssistRules *map[string]interface{} `json:"assistRules"`
|
||||
Event *map[string]interface{} `json:"event"`
|
||||
EventRules *map[string]interface{} `json:"eventRules"`
|
||||
Settings *map[string]interface{} `json:"settings"`
|
||||
}
|
||||
|
||||
type ServerSettings struct {
|
||||
ServerName string `json:"serverName"`
|
||||
AdminPassword string `json:"adminPassword"`
|
||||
@@ -79,11 +85,11 @@ type EventConfig struct {
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
HourOfDay IntString `json:"hourOfDay"`
|
||||
DayOfWeekend IntString `json:"dayOfWeekend"`
|
||||
TimeMultiplier IntString `json:"timeMultiplier"`
|
||||
SessionType string `json:"sessionType"`
|
||||
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
|
||||
HourOfDay IntString `json:"hourOfDay"`
|
||||
DayOfWeekend IntString `json:"dayOfWeekend"`
|
||||
TimeMultiplier IntString `json:"timeMultiplier"`
|
||||
SessionType TrackSession `json:"sessionType"`
|
||||
SessionDurationMinutes IntString `json:"sessionDurationMinutes"`
|
||||
}
|
||||
|
||||
type AssistRules struct {
|
||||
@@ -121,8 +127,6 @@ type Configuration struct {
|
||||
ConfigVersion IntString `json:"configVersion"`
|
||||
}
|
||||
|
||||
// Known configuration keys
|
||||
|
||||
func (i *IntBool) UnmarshalJSON(b []byte) error {
|
||||
var str int
|
||||
if err := json.Unmarshal(b, &str); err == nil && str <= 1 {
|
||||
|
||||
48
local/model/config_preset.go
Normal file
48
local/model/config_preset.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigPreset struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
Description string `json:"description"`
|
||||
Configuration string `json:"configuration" gorm:"type:text"`
|
||||
AssistRules string `json:"assistRules" gorm:"type:text"`
|
||||
Event string `json:"event" gorm:"type:text"`
|
||||
EventRules string `json:"eventRules" gorm:"type:text"`
|
||||
Settings string `json:"settings" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (p *ConfigPreset) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == uuid.Nil {
|
||||
p.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ConfigPresetCreateRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Configuration *map[string]interface{} `json:"configuration"`
|
||||
AssistRules *map[string]interface{} `json:"assistRules"`
|
||||
Event *map[string]interface{} `json:"event"`
|
||||
EventRules *map[string]interface{} `json:"eventRules"`
|
||||
Settings *map[string]interface{} `json:"settings"`
|
||||
}
|
||||
|
||||
type ConfigPresetUpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Configuration *map[string]interface{} `json:"configuration"`
|
||||
AssistRules *map[string]interface{} `json:"assistRules"`
|
||||
Event *map[string]interface{} `json:"event"`
|
||||
EventRules *map[string]interface{} `json:"eventRules"`
|
||||
Settings *map[string]interface{} `json:"settings"`
|
||||
}
|
||||
30
local/model/filter.go
Normal file → Executable file
30
local/model/filter.go
Normal file → Executable file
@@ -7,7 +7,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseFilter contains common filter fields that can be embedded in other filters
|
||||
type BaseFilter struct {
|
||||
Page int `query:"page"`
|
||||
PageSize int `query:"page_size"`
|
||||
@@ -15,18 +14,15 @@ type BaseFilter struct {
|
||||
SortDesc bool `query:"sort_desc"`
|
||||
}
|
||||
|
||||
// DateRangeFilter adds date range filtering capabilities
|
||||
type DateRangeFilter struct {
|
||||
StartDate time.Time `query:"start_date" time_format:"2006-01-02T15:04:05Z07:00"`
|
||||
EndDate time.Time `query:"end_date" time_format:"2006-01-02T15:04:05Z07:00"`
|
||||
}
|
||||
|
||||
// ServerBasedFilter adds server ID filtering capability
|
||||
type ServerBasedFilter struct {
|
||||
ServerID string `param:"id"`
|
||||
}
|
||||
|
||||
// ConfigFilter defines filtering options for Config queries
|
||||
type ConfigFilter struct {
|
||||
BaseFilter
|
||||
ServerBasedFilter
|
||||
@@ -34,13 +30,23 @@ type ConfigFilter struct {
|
||||
ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"`
|
||||
}
|
||||
|
||||
// ApiFilter defines filtering options for Api queries
|
||||
type ConfigPresetFilter struct {
|
||||
BaseFilter
|
||||
Name string `query:"name"`
|
||||
}
|
||||
|
||||
func (f *ConfigPresetFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
if f.Name != "" {
|
||||
query = query.Where("name LIKE ?", "%"+f.Name+"%")
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
type ServiceControlFilter struct {
|
||||
BaseFilter
|
||||
ServiceControl string `query:"serviceControl"`
|
||||
}
|
||||
|
||||
// MembershipFilter defines filtering options for User queries
|
||||
type MembershipFilter struct {
|
||||
BaseFilter
|
||||
Username string `query:"username"`
|
||||
@@ -48,36 +54,32 @@ type MembershipFilter struct {
|
||||
RoleID string `query:"role_id"`
|
||||
}
|
||||
|
||||
// Pagination returns the offset and limit for database queries
|
||||
func (f *BaseFilter) Pagination() (offset, limit int) {
|
||||
if f.Page < 1 {
|
||||
f.Page = 1
|
||||
}
|
||||
if f.PageSize < 1 {
|
||||
f.PageSize = 10 // Default page size
|
||||
f.PageSize = 10
|
||||
}
|
||||
offset = (f.Page - 1) * f.PageSize
|
||||
limit = f.PageSize
|
||||
return
|
||||
}
|
||||
|
||||
// GetSorting returns the sort field and direction for database queries
|
||||
func (f *BaseFilter) GetSorting() (field string, desc bool) {
|
||||
if f.SortBy == "" {
|
||||
return "id", false // Default sorting
|
||||
return "id", false
|
||||
}
|
||||
return f.SortBy, f.SortDesc
|
||||
}
|
||||
|
||||
// IsDateRangeValid checks if both dates are set and start date is before end date
|
||||
func (f *DateRangeFilter) IsDateRangeValid() bool {
|
||||
if f.StartDate.IsZero() || f.EndDate.IsZero() {
|
||||
return true // If either date is not set, consider it valid
|
||||
return true
|
||||
}
|
||||
return f.StartDate.Before(f.EndDate)
|
||||
}
|
||||
|
||||
// ApplyFilter applies the membership filter to a GORM query
|
||||
func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
if f.Username != "" {
|
||||
query = query.Where("username LIKE ?", "%"+f.Username+"%")
|
||||
@@ -93,12 +95,10 @@ func (f *MembershipFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
return query
|
||||
}
|
||||
|
||||
// Pagination returns the offset and limit for database queries
|
||||
func (f *MembershipFilter) Pagination() (offset, limit int) {
|
||||
return f.BaseFilter.Pagination()
|
||||
}
|
||||
|
||||
// GetSorting returns the sort field and direction for database queries
|
||||
func (f *MembershipFilter) GetSorting() (field string, desc bool) {
|
||||
return f.BaseFilter.GetSorting()
|
||||
}
|
||||
|
||||
113
local/model/leaderboard.go
Executable file
113
local/model/leaderboard.go
Executable file
@@ -0,0 +1,113 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Score is a custom type that accepts both numbers and strings in JSON.
|
||||
type Score string
|
||||
|
||||
func (s *Score) UnmarshalJSON(data []byte) error {
|
||||
var n json.Number
|
||||
if err := json.Unmarshal(data, &n); err == nil {
|
||||
*s = Score(n.String())
|
||||
return nil
|
||||
}
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
*s = Score(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Score) MarshalJSON() ([]byte, error) {
|
||||
if n, err := strconv.Atoi(string(s)); err == nil {
|
||||
return json.Marshal(n)
|
||||
}
|
||||
return json.Marshal(string(s))
|
||||
}
|
||||
|
||||
type Leaderboard struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
ServerID uuid.UUID `gorm:"uniqueIndex;not null;type:uuid" json:"serverId"`
|
||||
FLPoints int `gorm:"default:1" json:"flPoints"`
|
||||
FLColor string `gorm:"default:'#8b5cf6'" json:"flColor"`
|
||||
FLTextColor string `gorm:"default:'#000000'" json:"flTextColor"`
|
||||
Drivers []LeaderboardDriver `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"drivers"`
|
||||
Races []LeaderboardRace `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"tracks"`
|
||||
PointRows []LeaderboardPointRow `gorm:"foreignKey:LeaderboardID;constraint:OnDelete:CASCADE" json:"pointsTable"`
|
||||
}
|
||||
|
||||
type LeaderboardDriver struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Initials string `json:"initials"`
|
||||
Color string `json:"color"`
|
||||
Position int `json:"-"`
|
||||
}
|
||||
|
||||
type LeaderboardRace struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Position int `json:"-"`
|
||||
FastestLapDriverID *uuid.UUID `gorm:"type:uuid" json:"fastestLapDriverId"`
|
||||
Results []LeaderboardResult `gorm:"foreignKey:RaceID;constraint:OnDelete:CASCADE" json:"results"`
|
||||
}
|
||||
|
||||
type LeaderboardResult struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"-"`
|
||||
RaceID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||
DriverID uuid.UUID `gorm:"not null;type:uuid" json:"driverId"`
|
||||
Score Score `json:"score"`
|
||||
}
|
||||
|
||||
type LeaderboardPointRow struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
LeaderboardID uuid.UUID `gorm:"not null;type:uuid" json:"-"`
|
||||
Label string `json:"label"`
|
||||
Points int `json:"points"`
|
||||
Color string `json:"color"`
|
||||
TextColor string `json:"textColor"`
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
func (l *Leaderboard) BeforeCreate(tx *gorm.DB) error {
|
||||
if l.ID == uuid.Nil {
|
||||
l.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *LeaderboardDriver) BeforeCreate(tx *gorm.DB) error {
|
||||
if d.ID == uuid.Nil {
|
||||
d.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRace) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardResult) BeforeCreate(tx *gorm.DB) error {
|
||||
if r.ID == uuid.Nil {
|
||||
r.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LeaderboardPointRow) BeforeCreate(tx *gorm.DB) error {
|
||||
if p.ID == uuid.Nil {
|
||||
p.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
5
local/model/lookup.go
Normal file → Executable file
5
local/model/lookup.go
Normal file → Executable file
@@ -1,31 +1,26 @@
|
||||
package model
|
||||
|
||||
// Track represents a track and its capacity
|
||||
type Track struct {
|
||||
Name string `json:"track" gorm:"primaryKey;size:50"`
|
||||
UniquePitBoxes int `json:"unique_pit_boxes"`
|
||||
PrivateServerSlots int `json:"private_server_slots"`
|
||||
}
|
||||
|
||||
// CarModel represents a car model mapping
|
||||
type CarModel struct {
|
||||
Value int `json:"value" gorm:"primaryKey"`
|
||||
CarModel string `json:"car_model"`
|
||||
}
|
||||
|
||||
// DriverCategory represents driver skill categories
|
||||
type DriverCategory struct {
|
||||
Value int `json:"value" gorm:"primaryKey"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// CupCategory represents championship cup categories
|
||||
type CupCategory struct {
|
||||
Value int `json:"value" gorm:"primaryKey"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
// SessionType represents session types
|
||||
type SessionType struct {
|
||||
Value int `json:"value" gorm:"primaryKey"`
|
||||
SessionType string `json:"session_type"`
|
||||
|
||||
2
local/model/model.go
Normal file → Executable file
2
local/model/model.go
Normal file → Executable file
@@ -32,8 +32,6 @@ type BaseModel struct {
|
||||
DateUpdated time.Time `json:"dateUpdated"`
|
||||
}
|
||||
|
||||
// Init
|
||||
// Initializes base model with DateCreated, DateUpdated, and Id values.
|
||||
func (cm *BaseModel) Init() {
|
||||
date := time.Now()
|
||||
cm.Id = uuid.NewString()
|
||||
|
||||
4
local/model/permission.go
Normal file → Executable file
4
local/model/permission.go
Normal file → Executable file
@@ -5,15 +5,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Permission represents an action that can be performed in the system.
|
||||
type Permission struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
Name string `json:"name" gorm:"unique_index;not null"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new credentials
|
||||
func (s *Permission) BeforeCreate(tx *gorm.DB) error {
|
||||
s.ID = uuid.New()
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
11
local/model/permissions.go
Normal file → Executable file
11
local/model/permissions.go
Normal file → Executable file
@@ -1,6 +1,5 @@
|
||||
package model
|
||||
|
||||
// Permission constants
|
||||
const (
|
||||
ServerView = "server.view"
|
||||
ServerCreate = "server.create"
|
||||
@@ -25,9 +24,13 @@ const (
|
||||
MembershipCreate = "membership.create"
|
||||
MembershipView = "membership.view"
|
||||
MembershipEdit = "membership.edit"
|
||||
|
||||
PresetView = "preset.view"
|
||||
PresetCreate = "preset.create"
|
||||
PresetUpdate = "preset.update"
|
||||
PresetDelete = "preset.delete"
|
||||
)
|
||||
|
||||
// AllPermissions returns a slice of all permission strings.
|
||||
func AllPermissions() []string {
|
||||
return []string{
|
||||
ServerView,
|
||||
@@ -49,5 +52,9 @@ func AllPermissions() []string {
|
||||
MembershipCreate,
|
||||
MembershipView,
|
||||
MembershipEdit,
|
||||
PresetView,
|
||||
PresetCreate,
|
||||
PresetUpdate,
|
||||
PresetDelete,
|
||||
}
|
||||
}
|
||||
|
||||
4
local/model/role.go
Normal file → Executable file
4
local/model/role.go
Normal file → Executable file
@@ -5,16 +5,14 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Role represents a user role in the system.
|
||||
type Role struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
Name string `json:"name" gorm:"unique_index;not null"`
|
||||
Permissions []Permission `json:"permissions" gorm:"many2many:role_permissions;"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new credentials
|
||||
func (s *Role) BeforeCreate(tx *gorm.DB) error {
|
||||
s.ID = uuid.New()
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
71
local/model/server.go
Normal file → Executable file
71
local/model/server.go
Normal file → Executable file
@@ -16,7 +16,6 @@ const (
|
||||
ServiceNamePrefix = "ACC-Server"
|
||||
)
|
||||
|
||||
// Server represents an ACC server instance
|
||||
type ServerAPI struct {
|
||||
Name string `json:"name"`
|
||||
Status ServiceStatus `json:"status"`
|
||||
@@ -35,28 +34,30 @@ func (s *Server) ToServerAPI() *ServerAPI {
|
||||
}
|
||||
}
|
||||
|
||||
// Server represents an ACC server instance
|
||||
type Server struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Status ServiceStatus `json:"status" gorm:"-"`
|
||||
IP string `gorm:"not null" json:"-"`
|
||||
Port int `gorm:"not null" json:"-"`
|
||||
Path string `gorm:"not null" json:"path"` // e.g. "/acc/servers/server1/"
|
||||
ServiceName string `gorm:"not null" json:"serviceName"` // Windows service name
|
||||
State *ServerState `gorm:"-" json:"state"`
|
||||
DateCreated time.Time `json:"dateCreated"`
|
||||
FromSteamCMD bool `gorm:"not null; default:true" json:"-"`
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Status ServiceStatus `json:"status" gorm:"-"`
|
||||
IP string `gorm:"not null" json:"-"`
|
||||
Port int `gorm:"not null" json:"-"`
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
ServiceName string `gorm:"not null" json:"serviceName"`
|
||||
Platform string `gorm:"not null;default:'windows'" json:"platform"`
|
||||
ContainerID string `gorm:"default:''" json:"-"`
|
||||
State *ServerState `gorm:"-" json:"state"`
|
||||
LastActivity *LastActivityInfo `gorm:"-" json:"lastActivity,omitempty"`
|
||||
DateCreated time.Time `json:"dateCreated"`
|
||||
FromSteamCMD bool `gorm:"not null; default:true" json:"-"`
|
||||
}
|
||||
|
||||
type PlayerState struct {
|
||||
CarID int // Car ID in broadcast packets
|
||||
DriverName string // Optional: pulled from registration packet
|
||||
CarID int
|
||||
DriverName string
|
||||
TeamName string
|
||||
CarModel string
|
||||
CurrentLap int
|
||||
LastLapTime int // in milliseconds
|
||||
BestLapTime int // in milliseconds
|
||||
LastLapTime int
|
||||
BestLapTime int
|
||||
Position int
|
||||
ConnectedAt time.Time
|
||||
DisconnectedAt *time.Time
|
||||
@@ -67,23 +68,18 @@ type State struct {
|
||||
Session string `json:"session"`
|
||||
SessionStart time.Time `json:"sessionStart"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
// Players map[int]*PlayerState
|
||||
// etc.
|
||||
}
|
||||
|
||||
type ServerState struct {
|
||||
sync.RWMutex `swaggerignore:"-" json:"-"`
|
||||
Session string `json:"session"`
|
||||
SessionStart time.Time `json:"sessionStart"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
Track string `json:"track"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
SessionDurationMinutes int `json:"sessionDurationMinutes"`
|
||||
// Players map[int]*PlayerState
|
||||
// etc.
|
||||
Session TrackSession `json:"session"`
|
||||
SessionStart time.Time `json:"sessionStart"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
Track string `json:"track"`
|
||||
MaxConnections int `json:"maxConnections"`
|
||||
SessionDurationMinutes int `json:"sessionDurationMinutes"`
|
||||
}
|
||||
|
||||
// ServerFilter defines filtering options for Server queries
|
||||
type ServerFilter struct {
|
||||
BaseFilter
|
||||
ServerBasedFilter
|
||||
@@ -92,9 +88,7 @@ type ServerFilter struct {
|
||||
Status string `query:"status"`
|
||||
}
|
||||
|
||||
// ApplyFilter implements the Filterable interface
|
||||
func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
// Apply server filter
|
||||
if f.ServerID != "" {
|
||||
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
|
||||
query = query.Where("id = ?", serverUUID)
|
||||
@@ -104,18 +98,19 @@ func (f *ServerFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
return query
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating a new server
|
||||
func (s *Server) GenerateUUID() {
|
||||
if s.ID == uuid.Nil {
|
||||
s.ID = uuid.New()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.Name == "" {
|
||||
return errors.New("server name is required")
|
||||
}
|
||||
|
||||
// Generate UUID if not set
|
||||
if s.ID == uuid.Nil {
|
||||
s.ID = uuid.New()
|
||||
}
|
||||
s.GenerateUUID()
|
||||
|
||||
// Generate service name and config path if not set
|
||||
if s.ServiceName == "" {
|
||||
s.ServiceName = s.GenerateServiceName()
|
||||
}
|
||||
@@ -123,7 +118,6 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error {
|
||||
s.Path = s.GenerateServerPath(BaseServerPath)
|
||||
}
|
||||
|
||||
// Set creation date if not set
|
||||
if s.DateCreated.IsZero() {
|
||||
s.DateCreated = time.Now().UTC()
|
||||
}
|
||||
@@ -131,19 +125,14 @@ func (s *Server) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateServiceName creates a unique service name based on the server name
|
||||
func (s *Server) GenerateServiceName() string {
|
||||
// If ID is set, use it
|
||||
if s.ID != uuid.Nil {
|
||||
return fmt.Sprintf("%s-%s", ServiceNamePrefix, s.ID.String()[:8])
|
||||
}
|
||||
// Otherwise use a timestamp-based unique identifier
|
||||
return fmt.Sprintf("%s-%d", ServiceNamePrefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
// GenerateServerPath creates the config path based on the service name
|
||||
func (s *Server) GenerateServerPath(steamCMDPath string) string {
|
||||
// Ensure service name is set
|
||||
if s.ServiceName == "" {
|
||||
s.ServiceName = s.GenerateServiceName()
|
||||
}
|
||||
|
||||
10
local/model/service_control.go
Normal file → Executable file
10
local/model/service_control.go
Normal file → Executable file
@@ -17,7 +17,6 @@ const (
|
||||
StatusRunning
|
||||
)
|
||||
|
||||
// String converts the ServiceStatus to its string representation
|
||||
func (s ServiceStatus) String() string {
|
||||
switch s {
|
||||
case StatusRunning:
|
||||
@@ -35,7 +34,6 @@ func (s ServiceStatus) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseServiceStatus converts a string to ServiceStatus
|
||||
func ParseServiceStatus(s string) ServiceStatus {
|
||||
switch s {
|
||||
case "SERVICE_RUNNING":
|
||||
@@ -53,31 +51,24 @@ func ParseServiceStatus(s string) ServiceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler interface
|
||||
func (s ServiceStatus) MarshalJSON() ([]byte, error) {
|
||||
// Return the numeric value instead of string
|
||||
return []byte(strconv.Itoa(int(s))), nil
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements json.Unmarshaler interface
|
||||
func (s *ServiceStatus) UnmarshalJSON(data []byte) error {
|
||||
// Try to parse as number first
|
||||
if i, err := strconv.Atoi(string(data)); err == nil {
|
||||
*s = ServiceStatus(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fallback to string parsing for backward compatibility
|
||||
str := string(data)
|
||||
if len(str) >= 2 {
|
||||
// Remove quotes if present
|
||||
str = str[1 : len(str)-1]
|
||||
}
|
||||
*s = ParseServiceStatus(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scan implements the sql.Scanner interface
|
||||
func (s *ServiceStatus) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*s = StatusUnknown
|
||||
@@ -99,7 +90,6 @@ func (s *ServiceStatus) Scan(value interface{}) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface
|
||||
func (s ServiceStatus) Value() (driver.Value, error) {
|
||||
return s.String(), nil
|
||||
}
|
||||
|
||||
91
local/model/state_history.go
Normal file → Executable file
91
local/model/state_history.go
Normal file → Executable file
@@ -1,33 +1,31 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// StateHistoryFilter combines common filter capabilities
|
||||
type StateHistoryFilter struct {
|
||||
ServerBasedFilter // Adds server ID from path parameter
|
||||
DateRangeFilter // Adds date range filtering
|
||||
ServerBasedFilter
|
||||
DateRangeFilter
|
||||
|
||||
// Additional fields specific to state history
|
||||
Session string `query:"session"`
|
||||
MinPlayers *int `query:"min_players"`
|
||||
MaxPlayers *int `query:"max_players"`
|
||||
Session TrackSession `query:"session"`
|
||||
MinPlayers *int `query:"min_players"`
|
||||
MaxPlayers *int `query:"max_players"`
|
||||
}
|
||||
|
||||
// ApplyFilter implements the Filterable interface
|
||||
func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
// Apply server filter
|
||||
if f.ServerID != "" {
|
||||
if serverUUID, err := uuid.Parse(f.ServerID); err == nil {
|
||||
query = query.Where("server_id = ?", serverUUID)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply date range filter if set
|
||||
timeZero := time.Time{}
|
||||
if f.StartDate != timeZero {
|
||||
query = query.Where("date_created >= ?", f.StartDate)
|
||||
@@ -36,12 +34,10 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
query = query.Where("date_created <= ?", f.EndDate)
|
||||
}
|
||||
|
||||
// Apply session filter if set
|
||||
if f.Session != "" {
|
||||
query = query.Where("session = ?", f.Session)
|
||||
}
|
||||
|
||||
// Apply player count filters if set
|
||||
if f.MinPlayers != nil {
|
||||
query = query.Where("player_count >= ?", *f.MinPlayers)
|
||||
}
|
||||
@@ -52,19 +48,68 @@ func (f *StateHistoryFilter) ApplyFilter(query *gorm.DB) *gorm.DB {
|
||||
return query
|
||||
}
|
||||
|
||||
type StateHistory struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
|
||||
Session string `json:"session"`
|
||||
Track string `json:"track"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
DateCreated time.Time `json:"dateCreated"`
|
||||
SessionStart time.Time `json:"sessionStart"`
|
||||
SessionDurationMinutes int `json:"sessionDurationMinutes"`
|
||||
SessionID uuid.UUID `json:"sessionId" gorm:"not null;type:uuid"` // Unique identifier for each session/event
|
||||
type TrackSession string
|
||||
|
||||
const (
|
||||
SessionPractice TrackSession = "P"
|
||||
SessionQualify TrackSession = "Q"
|
||||
SessionRace TrackSession = "R"
|
||||
SessionUnknown TrackSession = "U"
|
||||
)
|
||||
|
||||
func (i *TrackSession) UnmarshalJSON(b []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(b, &str); err == nil {
|
||||
*i = ToTrackSession(str)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid TrackSession value")
|
||||
}
|
||||
|
||||
func (i TrackSession) Humanize() string {
|
||||
switch i {
|
||||
case SessionPractice:
|
||||
return "Practice"
|
||||
case SessionQualify:
|
||||
return "Qualifying"
|
||||
case SessionRace:
|
||||
return "Race"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func ToTrackSession(i string) TrackSession {
|
||||
sessionAbrv := strings.ToUpper(i[:1])
|
||||
switch sessionAbrv {
|
||||
case "P":
|
||||
return SessionPractice
|
||||
case "Q":
|
||||
return SessionQualify
|
||||
case "R":
|
||||
return SessionRace
|
||||
default:
|
||||
return SessionUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func (i TrackSession) ToString() string {
|
||||
return string(i)
|
||||
}
|
||||
|
||||
type StateHistory struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid"`
|
||||
Session TrackSession `json:"session"`
|
||||
Track string `json:"track"`
|
||||
PlayerCount int `json:"playerCount"`
|
||||
DateCreated time.Time `json:"dateCreated"`
|
||||
SessionStart time.Time `json:"sessionStart"`
|
||||
SessionDurationMinutes int `json:"sessionDurationMinutes"`
|
||||
SessionID uuid.UUID `json:"sessionId" gorm:"not null;type:uuid"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new state history entries
|
||||
func (sh *StateHistory) BeforeCreate(tx *gorm.DB) error {
|
||||
if sh.ID == uuid.Nil {
|
||||
sh.ID = uuid.New()
|
||||
|
||||
18
local/model/state_history_stats.go
Normal file → Executable file
18
local/model/state_history_stats.go
Normal file → Executable file
@@ -3,8 +3,8 @@ package model
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type SessionCount struct {
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
Name TrackSession `json:"name"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type DailyActivity struct {
|
||||
@@ -21,7 +21,7 @@ type StateHistoryStats struct {
|
||||
AveragePlayers float64 `json:"averagePlayers"`
|
||||
PeakPlayers int `json:"peakPlayers"`
|
||||
TotalSessions int `json:"totalSessions"`
|
||||
TotalPlaytime int `json:"totalPlaytime" gorm:"-"` // in minutes
|
||||
TotalPlaytime int `json:"totalPlaytime" gorm:"-"`
|
||||
PlayerCountOverTime []PlayerCountPoint `json:"playerCountOverTime" gorm:"-"`
|
||||
SessionTypes []SessionCount `json:"sessionTypes" gorm:"-"`
|
||||
DailyActivity []DailyActivity `json:"dailyActivity" gorm:"-"`
|
||||
@@ -29,10 +29,10 @@ type StateHistoryStats struct {
|
||||
}
|
||||
|
||||
type RecentSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Type string `json:"type"`
|
||||
Track string `json:"track"`
|
||||
Duration int `json:"duration"`
|
||||
Players int `json:"players"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Type TrackSession `json:"type"`
|
||||
Track string `json:"track"`
|
||||
Duration int `json:"duration"`
|
||||
Players int `json:"players"`
|
||||
}
|
||||
|
||||
0
local/model/steam_2fa.go
Normal file → Executable file
0
local/model/steam_2fa.go
Normal file → Executable file
29
local/model/steam_credentials.go
Normal file → Executable file
29
local/model/steam_credentials.go
Normal file → Executable file
@@ -16,21 +16,18 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SteamCredentials represents stored Steam login credentials
|
||||
type SteamCredentials struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;" json:"id"`
|
||||
Username string `gorm:"not null" json:"username"`
|
||||
Password string `gorm:"not null" json:"-"` // Encrypted, not exposed in JSON
|
||||
Password string `gorm:"not null" json:"-"`
|
||||
DateCreated time.Time `json:"dateCreated"`
|
||||
LastUpdated time.Time `json:"lastUpdated"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (SteamCredentials) TableName() string {
|
||||
return "steam_credentials"
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new credentials
|
||||
func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
|
||||
if s.ID == uuid.Nil {
|
||||
s.ID = uuid.New()
|
||||
@@ -42,7 +39,6 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
|
||||
}
|
||||
s.LastUpdated = now
|
||||
|
||||
// Encrypt password before saving
|
||||
encrypted, err := EncryptPassword(s.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -52,11 +48,9 @@ func (s *SteamCredentials) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate is a GORM hook that runs before updating credentials
|
||||
func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
|
||||
s.LastUpdated = time.Now().UTC()
|
||||
|
||||
// Only encrypt if password field is being updated
|
||||
if tx.Statement.Changed("Password") {
|
||||
encrypted, err := EncryptPassword(s.Password)
|
||||
if err != nil {
|
||||
@@ -68,9 +62,7 @@ func (s *SteamCredentials) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AfterFind is a GORM hook that runs after fetching credentials
|
||||
func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
|
||||
// Decrypt password after fetching
|
||||
if s.Password != "" {
|
||||
decrypted, err := DecryptPassword(s.Password)
|
||||
if err != nil {
|
||||
@@ -81,18 +73,15 @@ func (s *SteamCredentials) AfterFind(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the credentials are valid with enhanced security checks
|
||||
func (s *SteamCredentials) Validate() error {
|
||||
if s.Username == "" {
|
||||
return errors.New("username is required")
|
||||
}
|
||||
|
||||
// Enhanced username validation
|
||||
if len(s.Username) < 3 || len(s.Username) > 64 {
|
||||
return errors.New("username must be between 3 and 64 characters")
|
||||
}
|
||||
|
||||
// Check for valid characters in username (alphanumeric, underscore, hyphen)
|
||||
if matched, _ := regexp.MatchString(`^[a-zA-Z0-9_-]+$`, s.Username); !matched {
|
||||
return errors.New("username contains invalid characters")
|
||||
}
|
||||
@@ -101,7 +90,6 @@ func (s *SteamCredentials) Validate() error {
|
||||
return errors.New("password is required")
|
||||
}
|
||||
|
||||
// Basic password validation
|
||||
if len(s.Password) < 6 {
|
||||
return errors.New("password must be at least 6 characters long")
|
||||
}
|
||||
@@ -110,7 +98,6 @@ func (s *SteamCredentials) Validate() error {
|
||||
return errors.New("password is too long")
|
||||
}
|
||||
|
||||
// Check for obvious weak passwords
|
||||
weakPasswords := []string{"password", "123456", "steam", "admin", "user"}
|
||||
lowerPass := strings.ToLower(s.Password)
|
||||
for _, weak := range weakPasswords {
|
||||
@@ -122,8 +109,6 @@ func (s *SteamCredentials) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEncryptionKey returns the encryption key from config.
|
||||
// The key is loaded from the ENCRYPTION_KEY environment variable.
|
||||
func GetEncryptionKey() []byte {
|
||||
key := []byte(configs.EncryptionKey)
|
||||
if len(key) != 32 {
|
||||
@@ -132,7 +117,6 @@ func GetEncryptionKey() []byte {
|
||||
return key
|
||||
}
|
||||
|
||||
// EncryptPassword encrypts a password using AES-256-GCM with enhanced security
|
||||
func EncryptPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("password cannot be empty")
|
||||
@@ -148,33 +132,27 @@ func EncryptPassword(password string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a new GCM cipher
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a cryptographically secure nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encrypt the password with authenticated encryption
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(password), nil)
|
||||
|
||||
// Return base64 encoded encrypted password
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptPassword decrypts an encrypted password with enhanced validation
|
||||
func DecryptPassword(encryptedPassword string) (string, error) {
|
||||
if encryptedPassword == "" {
|
||||
return "", errors.New("encrypted password cannot be empty")
|
||||
}
|
||||
|
||||
// Validate base64 format
|
||||
if len(encryptedPassword) < 24 { // Minimum reasonable length
|
||||
if len(encryptedPassword) < 24 {
|
||||
return "", errors.New("invalid encrypted password format")
|
||||
}
|
||||
|
||||
@@ -184,13 +162,11 @@ func DecryptPassword(encryptedPassword string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create a new GCM cipher
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Decode base64 encoded password
|
||||
ciphertext, err := base64.StdEncoding.DecodeString(encryptedPassword)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid base64 encoding")
|
||||
@@ -207,7 +183,6 @@ func DecryptPassword(encryptedPassword string) (string, error) {
|
||||
return "", errors.New("decryption failed - invalid ciphertext or key")
|
||||
}
|
||||
|
||||
// Validate decrypted content
|
||||
decrypted := string(plaintext)
|
||||
if len(decrypted) == 0 || len(decrypted) > 1024 {
|
||||
return "", errors.New("invalid decrypted password")
|
||||
|
||||
12
local/model/user.go
Normal file → Executable file
12
local/model/user.go
Normal file → Executable file
@@ -8,7 +8,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User represents a user account in the system.
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"`
|
||||
Username string `json:"username" gorm:"unique_index;not null"`
|
||||
@@ -17,16 +16,13 @@ type User struct {
|
||||
Role Role `json:"role"`
|
||||
}
|
||||
|
||||
// BeforeCreate is a GORM hook that runs before creating new users
|
||||
func (s *User) BeforeCreate(tx *gorm.DB) error {
|
||||
s.ID = uuid.New()
|
||||
|
||||
// Validate password strength
|
||||
if err := password.ValidatePasswordStrength(s.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Hash password before saving
|
||||
hashed, err := password.HashPassword(s.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -36,11 +32,8 @@ func (s *User) BeforeCreate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeforeUpdate is a GORM hook that runs before updating users
|
||||
func (s *User) BeforeUpdate(tx *gorm.DB) error {
|
||||
// Only hash if password field is being updated
|
||||
if tx.Statement.Changed("Password") {
|
||||
// Validate password strength
|
||||
if err := password.ValidatePasswordStrength(s.Password); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -55,14 +48,10 @@ func (s *User) BeforeUpdate(tx *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AfterFind is a GORM hook that runs after fetching users
|
||||
func (s *User) AfterFind(tx *gorm.DB) error {
|
||||
// Password remains hashed - never decrypt
|
||||
// This hook is kept for potential future use
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate checks if the user data is valid
|
||||
func (s *User) Validate() error {
|
||||
if s.Username == "" {
|
||||
return errors.New("username is required")
|
||||
@@ -73,7 +62,6 @@ func (s *User) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyPassword verifies a plain text password against the stored hash
|
||||
func (s *User) VerifyPassword(plainPassword string) error {
|
||||
return password.VerifyPassword(s.Password, plainPassword)
|
||||
}
|
||||
|
||||
85
local/model/websocket.go
Executable file
85
local/model/websocket.go
Executable file
@@ -0,0 +1,85 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ServerCreationStep string
|
||||
|
||||
const (
|
||||
StepValidation ServerCreationStep = "validation"
|
||||
StepDirectoryCreation ServerCreationStep = "directory_creation"
|
||||
StepSteamDownload ServerCreationStep = "steam_download"
|
||||
StepConfigGeneration ServerCreationStep = "config_generation"
|
||||
StepServiceCreation ServerCreationStep = "service_creation"
|
||||
StepFirewallRules ServerCreationStep = "firewall_rules"
|
||||
StepDatabaseSave ServerCreationStep = "database_save"
|
||||
StepCompleted ServerCreationStep = "completed"
|
||||
)
|
||||
|
||||
type StepStatus string
|
||||
|
||||
const (
|
||||
StatusPending StepStatus = "pending"
|
||||
StatusInProgress StepStatus = "in_progress"
|
||||
StatusCompleted StepStatus = "completed"
|
||||
StatusFailed StepStatus = "failed"
|
||||
)
|
||||
|
||||
type WebSocketMessageType string
|
||||
|
||||
const (
|
||||
MessageTypeStep WebSocketMessageType = "step"
|
||||
MessageTypeSteamOutput WebSocketMessageType = "steam_output"
|
||||
MessageTypeError WebSocketMessageType = "error"
|
||||
MessageTypeComplete WebSocketMessageType = "complete"
|
||||
MessageTypeLogLine WebSocketMessageType = "log_line"
|
||||
)
|
||||
|
||||
type WebSocketMessage struct {
|
||||
Type WebSocketMessageType `json:"type"`
|
||||
ServerID *uuid.UUID `json:"server_id,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Data interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type StepMessage struct {
|
||||
Step ServerCreationStep `json:"step"`
|
||||
Status StepStatus `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type SteamOutputMessage struct {
|
||||
Output string `json:"output"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type ErrorMessage struct {
|
||||
Error string `json:"error"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
type CompleteMessage struct {
|
||||
ServerID uuid.UUID `json:"server_id"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type LogLineMessage struct {
|
||||
Line string `json:"line"`
|
||||
}
|
||||
|
||||
func GetStepDescription(step ServerCreationStep) string {
|
||||
descriptions := map[ServerCreationStep]string{
|
||||
StepValidation: "Validating server configuration",
|
||||
StepDirectoryCreation: "Creating server directories",
|
||||
StepSteamDownload: "Downloading server files via Steam",
|
||||
StepConfigGeneration: "Generating server configuration files",
|
||||
StepServiceCreation: "Creating Windows service",
|
||||
StepFirewallRules: "Configuring firewall rules",
|
||||
StepDatabaseSave: "Saving server to database",
|
||||
StepCompleted: "Server creation completed",
|
||||
}
|
||||
return descriptions[step]
|
||||
}
|
||||
29
local/platform/interfaces.go
Normal file
29
local/platform/interfaces.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type ServerRuntime interface {
|
||||
Create(ctx context.Context, server *model.Server) error
|
||||
Delete(ctx context.Context, serverID uuid.UUID) error
|
||||
Start(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Stop(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Restart(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
Status(ctx context.Context, serverID uuid.UUID) (string, error)
|
||||
}
|
||||
|
||||
type FirewallManager interface {
|
||||
CreateServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
DeleteServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error
|
||||
}
|
||||
|
||||
type LogStreamer interface {
|
||||
Start(ctx context.Context, server *model.Server, handleLine func(string)) error
|
||||
Stop(serverID uuid.UUID)
|
||||
GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error)
|
||||
}
|
||||
96
local/repository/activity_log.go
Normal file
96
local/repository/activity_log.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityLogRepository struct {
|
||||
*BaseRepository[model.ActivityLog, model.ActivityLogFilter]
|
||||
}
|
||||
|
||||
func NewActivityLogRepository(db *gorm.DB) *ActivityLogRepository {
|
||||
return &ActivityLogRepository{
|
||||
BaseRepository: NewBaseRepository[model.ActivityLog, model.ActivityLogFilter](db, model.ActivityLog{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ActivityLogRepository) Insert(ctx context.Context, entry *model.ActivityLog) error {
|
||||
return r.BaseRepository.Insert(ctx, entry)
|
||||
}
|
||||
|
||||
func (r *ActivityLogRepository) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
|
||||
result := &[]model.ActivityLog{}
|
||||
query := r.BaseRepository.db.WithContext(ctx).
|
||||
Model(&model.ActivityLog{}).
|
||||
Preload("Server")
|
||||
|
||||
query = filter.ApplyFilter(query)
|
||||
|
||||
offset, limit := filter.Pagination()
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
|
||||
field, desc := filter.GetSorting()
|
||||
if desc {
|
||||
query = query.Order(field + " DESC")
|
||||
} else {
|
||||
query = query.Order(field)
|
||||
}
|
||||
|
||||
if err := query.Find(result).Error; err != nil {
|
||||
return nil, fmt.Errorf("error getting activity logs: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type lastActivityRow struct {
|
||||
ServerID string `gorm:"column:server_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
Username string `gorm:"column:username"`
|
||||
Action model.ActionType `gorm:"column:action"`
|
||||
}
|
||||
|
||||
// GetLastActivityByServerIDs returns the most recent activity entry per server.
|
||||
// Uses a single GROUP BY query with MAX(created_at); SQLite selects the other
|
||||
// columns from the row that holds the max value — documented SQLite behaviour.
|
||||
func (r *ActivityLogRepository) GetLastActivityByServerIDs(ctx context.Context, serverIDs []uuid.UUID) (map[uuid.UUID]*model.LastActivityInfo, error) {
|
||||
out := make(map[uuid.UUID]*model.LastActivityInfo)
|
||||
if len(serverIDs) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Convert to string slice so the IN clause works with SQLite UUID strings.
|
||||
ids := make([]string, len(serverIDs))
|
||||
for i, id := range serverIDs {
|
||||
ids[i] = id.String()
|
||||
}
|
||||
|
||||
var rows []lastActivityRow
|
||||
err := r.BaseRepository.db.WithContext(ctx).
|
||||
Model(&model.ActivityLog{}).
|
||||
Select("server_id, MAX(created_at) as created_at, username, action").
|
||||
Where("server_id IN ?", ids).
|
||||
Group("server_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
serverUUID, err := uuid.Parse(row.ServerID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[serverUUID] = &model.LastActivityInfo{
|
||||
UpdatedAt: row.CreatedAt,
|
||||
Username: row.Username,
|
||||
Action: row.Action,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
13
local/repository/base.go
Normal file → Executable file
13
local/repository/base.go
Normal file → Executable file
@@ -7,13 +7,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseRepository provides generic CRUD operations for any model
|
||||
type BaseRepository[T any, F any] struct {
|
||||
db *gorm.DB
|
||||
modelType T
|
||||
}
|
||||
|
||||
// NewBaseRepository creates a new base repository for the given model type
|
||||
func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F] {
|
||||
return &BaseRepository[T, F]{
|
||||
db: db,
|
||||
@@ -21,23 +19,19 @@ func NewBaseRepository[T any, F any](db *gorm.DB, model T) *BaseRepository[T, F]
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll retrieves all records based on the filter
|
||||
func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, error) {
|
||||
result := new([]T)
|
||||
query := r.db.WithContext(ctx).Model(&r.modelType)
|
||||
|
||||
// Apply filter conditions if filter implements Filterable
|
||||
if filterable, ok := any(filter).(Filterable); ok {
|
||||
query = filterable.ApplyFilter(query)
|
||||
}
|
||||
|
||||
// Apply pagination if filter implements Pageable
|
||||
if pageable, ok := any(filter).(Pageable); ok {
|
||||
offset, limit := pageable.Pagination()
|
||||
query = query.Offset(offset).Limit(limit)
|
||||
}
|
||||
|
||||
// Apply sorting if filter implements Sortable
|
||||
if sortable, ok := any(filter).(Sortable); ok {
|
||||
field, desc := sortable.GetSorting()
|
||||
if desc {
|
||||
@@ -54,7 +48,6 @@ func (r *BaseRepository[T, F]) GetAll(ctx context.Context, filter *F) (*[]T, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetByID retrieves a single record by ID
|
||||
func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T, error) {
|
||||
result := new(T)
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(result).Error; err != nil {
|
||||
@@ -66,7 +59,6 @@ func (r *BaseRepository[T, F]) GetByID(ctx context.Context, id interface{}) (*T,
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Insert creates a new record
|
||||
func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error {
|
||||
if err := r.db.WithContext(ctx).Create(model).Error; err != nil {
|
||||
return fmt.Errorf("error creating record: %w", err)
|
||||
@@ -74,7 +66,6 @@ func (r *BaseRepository[T, F]) Insert(ctx context.Context, model *T) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update modifies an existing record
|
||||
func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error {
|
||||
if err := r.db.WithContext(ctx).Save(model).Error; err != nil {
|
||||
return fmt.Errorf("error updating record: %w", err)
|
||||
@@ -82,7 +73,6 @@ func (r *BaseRepository[T, F]) Update(ctx context.Context, model *T) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a record by ID
|
||||
func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error {
|
||||
if err := r.db.WithContext(ctx).Delete(new(T), id).Error; err != nil {
|
||||
return fmt.Errorf("error deleting record: %w", err)
|
||||
@@ -90,7 +80,6 @@ func (r *BaseRepository[T, F]) Delete(ctx context.Context, id interface{}) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the total number of records matching the filter
|
||||
func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.WithContext(ctx).Model(&r.modelType)
|
||||
@@ -106,8 +95,6 @@ func (r *BaseRepository[T, F]) Count(ctx context.Context, filter *F) (int64, err
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Interfaces for filter capabilities
|
||||
|
||||
type Filterable interface {
|
||||
ApplyFilter(*gorm.DB) *gorm.DB
|
||||
}
|
||||
|
||||
4
local/repository/config.go
Normal file → Executable file
4
local/repository/config.go
Normal file → Executable file
@@ -17,13 +17,11 @@ func NewConfigRepository(db *gorm.DB) *ConfigRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConfig updates or creates a Config record
|
||||
func (r *ConfigRepository) UpdateConfig(ctx context.Context, config *model.Config) *model.Config {
|
||||
if err := r.Update(ctx, config); err != nil {
|
||||
// If update fails, try to insert
|
||||
if err := r.Insert(ctx, config); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
17
local/repository/config_preset.go
Normal file
17
local/repository/config_preset.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigPresetRepository struct {
|
||||
*BaseRepository[model.ConfigPreset, model.ConfigPresetFilter]
|
||||
}
|
||||
|
||||
func NewConfigPresetRepository(db *gorm.DB) *ConfigPresetRepository {
|
||||
return &ConfigPresetRepository{
|
||||
BaseRepository: NewBaseRepository[model.ConfigPreset, model.ConfigPresetFilter](db, model.ConfigPreset{}),
|
||||
}
|
||||
}
|
||||
144
local/repository/leaderboard.go
Executable file
144
local/repository/leaderboard.go
Executable file
@@ -0,0 +1,144 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LeaderboardRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
|
||||
return &LeaderboardRepository{db: db}
|
||||
}
|
||||
|
||||
// GetOrCreateByServerID fetches the leaderboard for a server, creating an empty one if it doesn't exist.
|
||||
func (r *LeaderboardRepository) GetOrCreateByServerID(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
|
||||
lb := new(model.Leaderboard)
|
||||
err := r.db.WithContext(ctx).
|
||||
Preload("Drivers").
|
||||
Preload("Races.Results").
|
||||
Preload("PointRows").
|
||||
Where("server_id = ?", serverID).
|
||||
First(lb).Error
|
||||
|
||||
if err == nil {
|
||||
return lb, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("error fetching leaderboard: %w", err)
|
||||
}
|
||||
|
||||
lb = &model.Leaderboard{
|
||||
ServerID: serverID,
|
||||
FLPoints: 1,
|
||||
FLColor: "#8b5cf6",
|
||||
FLTextColor: "#000000",
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(lb).Error; err != nil {
|
||||
return nil, fmt.Errorf("error creating leaderboard: %w", err)
|
||||
}
|
||||
return lb, nil
|
||||
}
|
||||
|
||||
// FullReplace replaces all leaderboard data for a server in a single transaction.
|
||||
func (r *LeaderboardRepository) FullReplace(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard) (*model.Leaderboard, error) {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
existing := new(model.Leaderboard)
|
||||
err := tx.Where("server_id = ?", serverID).First(existing).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("error fetching existing leaderboard: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
lb.ServerID = serverID
|
||||
return tx.Create(lb).Error
|
||||
}
|
||||
|
||||
// Delete children
|
||||
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardPointRow{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardDriver{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get race IDs to delete results
|
||||
var raceIDs []uuid.UUID
|
||||
tx.Model(&model.LeaderboardRace{}).Where("leaderboard_id = ?", existing.ID).Pluck("id", &raceIDs)
|
||||
if len(raceIDs) > 0 {
|
||||
if err := tx.Where("race_id IN ?", raceIDs).Delete(&model.LeaderboardResult{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("leaderboard_id = ?", existing.ID).Delete(&model.LeaderboardRace{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update parent fields
|
||||
existing.FLPoints = lb.FLPoints
|
||||
existing.FLColor = lb.FLColor
|
||||
existing.FLTextColor = lb.FLTextColor
|
||||
if err := tx.Save(existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Re-insert children with correct leaderboard ID
|
||||
for i := range lb.Drivers {
|
||||
lb.Drivers[i].LeaderboardID = existing.ID
|
||||
}
|
||||
if len(lb.Drivers) > 0 {
|
||||
if err := tx.Create(&lb.Drivers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i := range lb.PointRows {
|
||||
lb.PointRows[i].LeaderboardID = existing.ID
|
||||
lb.PointRows[i].ID = uuid.Nil
|
||||
}
|
||||
if len(lb.PointRows) > 0 {
|
||||
if err := tx.Create(&lb.PointRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i := range lb.Races {
|
||||
lb.Races[i].LeaderboardID = existing.ID
|
||||
}
|
||||
if len(lb.Races) > 0 {
|
||||
// Create races first (without results) to get IDs
|
||||
for i := range lb.Races {
|
||||
results := lb.Races[i].Results
|
||||
lb.Races[i].Results = nil
|
||||
if err := tx.Create(&lb.Races[i]).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Re-attach results with correct race ID
|
||||
for j := range results {
|
||||
results[j].RaceID = lb.Races[i].ID
|
||||
results[j].ID = uuid.Nil
|
||||
}
|
||||
if len(results) > 0 {
|
||||
if err := tx.Create(&results).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lb.ID = existing.ID
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.GetOrCreateByServerID(ctx, serverID)
|
||||
}
|
||||
0
local/repository/lookup.go
Normal file → Executable file
0
local/repository/lookup.go
Normal file → Executable file
19
local/repository/membership.go
Normal file → Executable file
19
local/repository/membership.go
Normal file → Executable file
@@ -8,20 +8,16 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MembershipRepository handles database operations for users, roles, and permissions.
|
||||
type MembershipRepository struct {
|
||||
*BaseRepository[model.User, model.MembershipFilter]
|
||||
}
|
||||
|
||||
// NewMembershipRepository creates a new MembershipRepository.
|
||||
func NewMembershipRepository(db *gorm.DB) *MembershipRepository {
|
||||
return &MembershipRepository{
|
||||
BaseRepository: NewBaseRepository[model.User, model.MembershipFilter](db, model.User{}),
|
||||
}
|
||||
}
|
||||
|
||||
// FindUserByUsername finds a user by their username.
|
||||
// It preloads the user's role and the role's permissions.
|
||||
func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username string) (*model.User, error) {
|
||||
var user model.User
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -32,7 +28,6 @@ func (r *MembershipRepository) FindUserByUsername(ctx context.Context, username
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// FindUserByIDWithPermissions finds a user by their ID and preloads Role and Permissions.
|
||||
func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context, userID string) (*model.User, error) {
|
||||
var user model.User
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -43,13 +38,11 @@ func (r *MembershipRepository) FindUserByIDWithPermissions(ctx context.Context,
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (r *MembershipRepository) CreateUser(ctx context.Context, user *model.User) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Create(user).Error
|
||||
}
|
||||
|
||||
// FindRoleByName finds a role by its name.
|
||||
func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string) (*model.Role, error) {
|
||||
var role model.Role
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -60,13 +53,11 @@ func (r *MembershipRepository) FindRoleByName(ctx context.Context, name string)
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// CreateRole creates a new role.
|
||||
func (r *MembershipRepository) CreateRole(ctx context.Context, role *model.Role) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Create(role).Error
|
||||
}
|
||||
|
||||
// FindPermissionByName finds a permission by its name.
|
||||
func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name string) (*model.Permission, error) {
|
||||
var permission model.Permission
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -77,19 +68,16 @@ func (r *MembershipRepository) FindPermissionByName(ctx context.Context, name st
|
||||
return &permission, nil
|
||||
}
|
||||
|
||||
// CreatePermission creates a new permission.
|
||||
func (r *MembershipRepository) CreatePermission(ctx context.Context, permission *model.Permission) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Create(permission).Error
|
||||
}
|
||||
|
||||
// AssignPermissionsToRole assigns a set of permissions to a role.
|
||||
func (r *MembershipRepository) AssignPermissionsToRole(ctx context.Context, role *model.Role, permissions []model.Permission) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Model(role).Association("Permissions").Replace(permissions)
|
||||
}
|
||||
|
||||
// GetUserPermissions retrieves all permissions for a given user ID.
|
||||
func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uuid.UUID) ([]string, error) {
|
||||
var user model.User
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -106,7 +94,6 @@ func (r *MembershipRepository) GetUserPermissions(ctx context.Context, userID uu
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// ListUsers retrieves all users.
|
||||
func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, error) {
|
||||
var users []*model.User
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -114,13 +101,11 @@ func (r *MembershipRepository) ListUsers(ctx context.Context) ([]*model.User, er
|
||||
return users, err
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user.
|
||||
func (r *MembershipRepository) DeleteUser(ctx context.Context, userID uuid.UUID) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Delete(&model.User{}, "id = ?", userID).Error
|
||||
}
|
||||
|
||||
// FindUserByID finds a user by their ID.
|
||||
func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*model.User, error) {
|
||||
var user model.User
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -131,13 +116,11 @@ func (r *MembershipRepository) FindUserByID(ctx context.Context, userID uuid.UUI
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's details in the database.
|
||||
func (r *MembershipRepository) UpdateUser(ctx context.Context, user *model.User) error {
|
||||
db := r.db.WithContext(ctx)
|
||||
return db.Save(user).Error
|
||||
}
|
||||
|
||||
// FindRoleByID finds a role by its ID.
|
||||
func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUID) (*model.Role, error) {
|
||||
var role model.Role
|
||||
db := r.db.WithContext(ctx)
|
||||
@@ -148,12 +131,10 @@ func (r *MembershipRepository) FindRoleByID(ctx context.Context, roleID uuid.UUI
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// ListUsersWithFilter retrieves users based on the membership filter.
|
||||
func (r *MembershipRepository) ListUsersWithFilter(ctx context.Context, filter *model.MembershipFilter) (*[]model.User, error) {
|
||||
return r.BaseRepository.GetAll(ctx, filter)
|
||||
}
|
||||
|
||||
// ListRoles retrieves all roles.
|
||||
func (r *MembershipRepository) ListRoles(ctx context.Context) ([]*model.Role, error) {
|
||||
var roles []*model.Role
|
||||
db := r.db.WithContext(ctx)
|
||||
|
||||
17
local/repository/repository.go
Normal file → Executable file
17
local/repository/repository.go
Normal file → Executable file
@@ -10,11 +10,7 @@ import (
|
||||
"go.uber.org/dig"
|
||||
)
|
||||
|
||||
// InitializeRepositories
|
||||
// Initializes Dependency Injection modules for repositories
|
||||
//
|
||||
// Args:
|
||||
// *dig.Container: Dig Container
|
||||
// *dig.Container: Dig Container
|
||||
func InitializeRepositories(c *dig.Container) {
|
||||
c.Provide(NewServiceControlRepository)
|
||||
c.Provide(NewStateHistoryRepository)
|
||||
@@ -23,17 +19,18 @@ func InitializeRepositories(c *dig.Container) {
|
||||
c.Provide(NewLookupRepository)
|
||||
c.Provide(NewSteamCredentialsRepository)
|
||||
c.Provide(NewMembershipRepository)
|
||||
c.Provide(NewLeaderboardRepository)
|
||||
c.Provide(NewActivityLogRepository)
|
||||
c.Provide(NewConfigPresetRepository)
|
||||
|
||||
// Provide the Steam2FAManager as a singleton
|
||||
if err := c.Provide(func() *model.Steam2FAManager {
|
||||
manager := model.NewSteam2FAManager()
|
||||
|
||||
// Use graceful shutdown manager for cleanup goroutine
|
||||
|
||||
shutdownManager := graceful.GetManager()
|
||||
shutdownManager.RunGoroutine(func(ctx context.Context) {
|
||||
ticker := time.NewTicker(15 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -43,7 +40,7 @@ func InitializeRepositories(c *dig.Container) {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
return manager
|
||||
}); err != nil {
|
||||
logging.Panic("unable to initialize steam 2fa manager")
|
||||
|
||||
4
local/repository/server.go
Normal file → Executable file
4
local/repository/server.go
Normal file → Executable file
@@ -20,10 +20,6 @@ func NewServerRepository(db *gorm.DB) *ServerRepository {
|
||||
return repo
|
||||
}
|
||||
|
||||
// GetFirstByServiceName
|
||||
// Gets first row from Server table.
|
||||
//
|
||||
// Args:
|
||||
// context.Context: Application context
|
||||
// Returns:
|
||||
// model.ServerModel: Server object from database.
|
||||
|
||||
0
local/repository/service_control.go
Normal file → Executable file
0
local/repository/service_control.go
Normal file → Executable file
17
local/repository/state_history.go
Normal file → Executable file
17
local/repository/state_history.go
Normal file → Executable file
@@ -18,17 +18,14 @@ func NewStateHistoryRepository(db *gorm.DB) *StateHistoryRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// GetAll retrieves all state history records with the given filter
|
||||
func (r *StateHistoryRepository) GetAll(ctx context.Context, filter *model.StateHistoryFilter) (*[]model.StateHistory, error) {
|
||||
return r.BaseRepository.GetAll(ctx, filter)
|
||||
}
|
||||
|
||||
// Insert creates a new state history record
|
||||
func (r *StateHistoryRepository) Insert(ctx context.Context, model *model.StateHistory) error {
|
||||
return r.BaseRepository.Insert(ctx, model)
|
||||
}
|
||||
|
||||
// GetLastSessionID gets the last session ID for a server
|
||||
func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID uuid.UUID) (uuid.UUID, error) {
|
||||
var lastSession model.StateHistory
|
||||
result := r.BaseRepository.db.WithContext(ctx).
|
||||
@@ -38,7 +35,7 @@ func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID
|
||||
|
||||
if result.Error != nil {
|
||||
if result.Error == gorm.ErrRecordNotFound {
|
||||
return uuid.Nil, nil // Return nil UUID if no sessions found
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
return uuid.Nil, result.Error
|
||||
}
|
||||
@@ -46,10 +43,8 @@ func (r *StateHistoryRepository) GetLastSessionID(ctx context.Context, serverID
|
||||
return lastSession.SessionID, nil
|
||||
}
|
||||
|
||||
// GetSummaryStats calculates peak players, total sessions, and average players.
|
||||
func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *model.StateHistoryFilter) (model.StateHistoryStats, error) {
|
||||
var stats model.StateHistoryStats
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return model.StateHistoryStats{}, err
|
||||
@@ -73,12 +68,10 @@ func (r *StateHistoryRepository) GetSummaryStats(ctx context.Context, filter *mo
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetTotalPlaytime calculates the total playtime in minutes.
|
||||
func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *model.StateHistoryFilter) (int, error) {
|
||||
var totalPlaytime struct {
|
||||
TotalMinutes float64
|
||||
}
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -100,10 +93,8 @@ func (r *StateHistoryRepository) GetTotalPlaytime(ctx context.Context, filter *m
|
||||
return int(totalPlaytime.TotalMinutes), nil
|
||||
}
|
||||
|
||||
// GetPlayerCountOverTime gets downsampled player count data.
|
||||
func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, filter *model.StateHistoryFilter) ([]model.PlayerCountPoint, error) {
|
||||
var points []model.PlayerCountPoint
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return points, err
|
||||
@@ -122,10 +113,8 @@ func (r *StateHistoryRepository) GetPlayerCountOverTime(ctx context.Context, fil
|
||||
return points, err
|
||||
}
|
||||
|
||||
// GetSessionTypes counts sessions by type.
|
||||
func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *model.StateHistoryFilter) ([]model.SessionCount, error) {
|
||||
var sessionTypes []model.SessionCount
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return sessionTypes, err
|
||||
@@ -145,10 +134,8 @@ func (r *StateHistoryRepository) GetSessionTypes(ctx context.Context, filter *mo
|
||||
return sessionTypes, err
|
||||
}
|
||||
|
||||
// GetDailyActivity counts sessions per day.
|
||||
func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *model.StateHistoryFilter) ([]model.DailyActivity, error) {
|
||||
var dailyActivity []model.DailyActivity
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return dailyActivity, err
|
||||
@@ -167,10 +154,8 @@ func (r *StateHistoryRepository) GetDailyActivity(ctx context.Context, filter *m
|
||||
return dailyActivity, err
|
||||
}
|
||||
|
||||
// GetRecentSessions retrieves the 10 most recent sessions.
|
||||
func (r *StateHistoryRepository) GetRecentSessions(ctx context.Context, filter *model.StateHistoryFilter) ([]model.RecentSession, error) {
|
||||
var recentSessions []model.RecentSession
|
||||
// Parse ServerID to UUID for query
|
||||
serverUUID, err := uuid.Parse(filter.ServerID)
|
||||
if err != nil {
|
||||
return recentSessions, err
|
||||
|
||||
0
local/repository/steam_credentials.go
Normal file → Executable file
0
local/repository/steam_credentials.go
Normal file → Executable file
65
local/service/activity_log.go
Normal file
65
local/service/activity_log.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ActivityLogService struct {
|
||||
repo *repository.ActivityLogRepository
|
||||
}
|
||||
|
||||
func NewActivityLogService(repo *repository.ActivityLogRepository) *ActivityLogService {
|
||||
return &ActivityLogService{repo: repo}
|
||||
}
|
||||
|
||||
// Log persists an activity log entry. Errors are logged but not returned so
|
||||
// callers can treat this as fire-and-forget.
|
||||
func (s *ActivityLogService) Log(ctx context.Context, entry *model.ActivityLog) error {
|
||||
if err := s.repo.Insert(ctx, entry); err != nil {
|
||||
logging.Error("Failed to insert activity log: %v", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ActivityLogService) GetAll(ctx context.Context, filter *model.ActivityLogFilter) (*[]model.ActivityLog, error) {
|
||||
return s.repo.GetAll(ctx, filter)
|
||||
}
|
||||
|
||||
// BuildConfigDetails produces a JSON string summarising which top-level keys
|
||||
// changed between oldJSON and newJSON.
|
||||
func BuildConfigDetails(file string, oldJSON, newJSON []byte) string {
|
||||
var oldMap, newMap map[string]interface{}
|
||||
|
||||
if err := json.Unmarshal(oldJSON, &oldMap); err != nil {
|
||||
oldMap = map[string]interface{}{}
|
||||
}
|
||||
if err := json.Unmarshal(newJSON, &newMap); err != nil {
|
||||
newMap = map[string]interface{}{}
|
||||
}
|
||||
|
||||
var changedKeys []string
|
||||
for key, newVal := range newMap {
|
||||
oldVal, exists := oldMap[key]
|
||||
if !exists {
|
||||
changedKeys = append(changedKeys, key)
|
||||
continue
|
||||
}
|
||||
oldBytes, _ := json.Marshal(oldVal)
|
||||
newBytes, _ := json.Marshal(newVal)
|
||||
if string(oldBytes) != string(newBytes) {
|
||||
changedKeys = append(changedKeys, key)
|
||||
}
|
||||
}
|
||||
|
||||
if len(changedKeys) == 0 {
|
||||
return ""
|
||||
}
|
||||
keysJSON, _ := json.Marshal(changedKeys)
|
||||
return fmt.Sprintf(`{"file":%q,"changed_keys":%s}`, file, string(keysJSON))
|
||||
}
|
||||
409
local/service/config.go
Normal file → Executable file
409
local/service/config.go
Normal file → Executable file
@@ -68,17 +68,19 @@ type ConfigService struct {
|
||||
repository *repository.ConfigRepository
|
||||
serverRepository *repository.ServerRepository
|
||||
serverService *ServerService
|
||||
activityLog *ActivityLogService
|
||||
configCache *model.ServerConfigCache
|
||||
}
|
||||
|
||||
func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository) *ConfigService {
|
||||
func NewConfigService(repository *repository.ConfigRepository, serverRepository *repository.ServerRepository, activityLog *ActivityLogService) *ConfigService {
|
||||
logging.Debug("Initializing ConfigService with 5m expiration and 1s throttle")
|
||||
return &ConfigService{
|
||||
repository: repository,
|
||||
serverRepository: serverRepository,
|
||||
activityLog: activityLog,
|
||||
configCache: model.NewServerConfigCache(model.CacheConfig{
|
||||
ExpirationTime: 5 * time.Minute, // Cache configs for 5 minutes
|
||||
ThrottleTime: 1 * time.Second, // Prevent rapid re-reads
|
||||
ExpirationTime: 5 * time.Minute,
|
||||
ThrottleTime: 1 * time.Second,
|
||||
DefaultStatus: model.StatusUnknown,
|
||||
}),
|
||||
}
|
||||
@@ -88,23 +90,166 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
|
||||
as.serverService = serverService
|
||||
}
|
||||
|
||||
// UpdateConfig
|
||||
// Updates physical config file and caches it in database.
|
||||
//
|
||||
// Args:
|
||||
// context.Context: Application context
|
||||
// Returns:
|
||||
// string: Application version
|
||||
func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.ConfigurationsUpdateRequest) ([]*model.Config, error) {
|
||||
serverID := ctx.Locals("serverId").(string)
|
||||
override := ctx.QueryBool("override", false)
|
||||
|
||||
serverUUID, err := uuid.Parse(serverID)
|
||||
if err != nil {
|
||||
logging.Error("Invalid server ID format: %v", err)
|
||||
return nil, fmt.Errorf("invalid server ID format")
|
||||
}
|
||||
|
||||
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverUUID)
|
||||
if err != nil {
|
||||
logging.Error("Server not found")
|
||||
return nil, fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
type configEntry struct {
|
||||
fileName string
|
||||
body *map[string]interface{}
|
||||
}
|
||||
|
||||
entries := []configEntry{
|
||||
{ConfigurationJson, req.Configuration},
|
||||
{AssistRulesJson, req.AssistRules},
|
||||
{EventJson, req.Event},
|
||||
{EventRulesJson, req.EventRules},
|
||||
{SettingsJson, req.Settings},
|
||||
}
|
||||
|
||||
actorID, _ := ctx.Locals("userID").(string)
|
||||
actorUsername, _ := ctx.Locals("actorUsername").(string)
|
||||
if actorUsername == "" {
|
||||
actorUsername = "system"
|
||||
}
|
||||
|
||||
var results []*model.Config
|
||||
for _, entry := range entries {
|
||||
if entry.body == nil {
|
||||
continue
|
||||
}
|
||||
oldDataUTF8, newData, err := as.updateConfigFiles(ctx.UserContext(), server, entry.fileName, entry.body, override)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry.fileName == SettingsJson {
|
||||
if nameVal, ok := (*entry.body)["serverName"]; ok {
|
||||
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
|
||||
server.Name = nameStr
|
||||
if err := as.serverRepository.Update(ctx.UserContext(), server); err != nil {
|
||||
logging.Error("Failed to update server name in DB: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result := as.repository.UpdateConfig(ctx.UserContext(), &model.Config{
|
||||
ServerID: serverUUID,
|
||||
ConfigFile: entry.fileName,
|
||||
OldConfig: string(oldDataUTF8),
|
||||
NewConfig: string(newData),
|
||||
ChangedAt: time.Now(),
|
||||
})
|
||||
results = append(results, result)
|
||||
|
||||
if as.activityLog != nil {
|
||||
if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" {
|
||||
go func(details string) {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &serverUUID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionConfigUpdate,
|
||||
Details: details,
|
||||
})
|
||||
}(details)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
as.configCache.InvalidateServerCache(serverID)
|
||||
as.serverService.StartAccServerRuntime(server)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
|
||||
serverID := ctx.Locals("serverId").(string)
|
||||
configFile := ctx.Params("file")
|
||||
override := ctx.QueryBool("override", false)
|
||||
|
||||
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override)
|
||||
actorID, _ := ctx.Locals("userID").(string)
|
||||
actorUsername, _ := ctx.Locals("actorUsername").(string)
|
||||
if actorUsername == "" {
|
||||
actorUsername = "system"
|
||||
}
|
||||
|
||||
return as.updateConfigInternal(ctx.UserContext(), serverID, configFile, body, override, actorID, actorUsername)
|
||||
}
|
||||
|
||||
// updateConfigInternal handles the actual config update logic without Fiber dependencies
|
||||
func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool) (*model.Config, error) {
|
||||
func (as *ConfigService) updateConfigFiles(ctx context.Context, server *model.Server, configFile string, body *map[string]interface{}, override bool) ([]byte, []byte, error) {
|
||||
if server == nil {
|
||||
logging.Error("Server not found")
|
||||
return nil, nil, fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
configPath := filepath.Join(server.GetConfigPath(), configFile)
|
||||
oldData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
oldData = []byte("{}")
|
||||
} else {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
oldDataUTF8, err := DecodeUTF16LEBOM(oldData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
newData, err := json.Marshal(&body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !override {
|
||||
newData, err = jsons.Merge(oldDataUTF8, newData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
newData, err = common.IndentJson(newData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
newDataUTF16, err := EncodeUTF16LEBOM(newData)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, newDataUTF16, 0644); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return oldDataUTF8, newData, nil
|
||||
}
|
||||
|
||||
func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID string, configFile string, body *map[string]interface{}, override bool, actorID, actorUsername string) (*model.Config, error) {
|
||||
serverUUID, err := uuid.Parse(serverID)
|
||||
if err != nil {
|
||||
logging.Error("Invalid server ID format: %v", err)
|
||||
@@ -117,63 +262,39 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
|
||||
return nil, fmt.Errorf("server not found")
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
configPath := filepath.Join(server.GetConfigPath(), configFile)
|
||||
oldData, err := os.ReadFile(configPath)
|
||||
oldDataUTF8, newData, err := as.updateConfigFiles(ctx, server, configFile, body, override)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// Create directory if it doesn't exist
|
||||
dir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if configFile == SettingsJson {
|
||||
if nameVal, ok := (*body)["serverName"]; ok {
|
||||
if nameStr, ok := nameVal.(string); ok && nameStr != "" && nameStr != server.Name {
|
||||
server.Name = nameStr
|
||||
if err := as.serverRepository.Update(ctx, server); err != nil {
|
||||
logging.Error("Failed to update server name in DB: %v", err)
|
||||
}
|
||||
}
|
||||
// Create empty JSON file
|
||||
if err := os.WriteFile(configPath, []byte("{}"), 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldData = []byte("{}")
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
oldDataUTF8, err := DecodeUTF16LEBOM(oldData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write new config
|
||||
newData, err := json.Marshal(&body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !override {
|
||||
newData, err = jsons.Merge(oldDataUTF8, newData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
newData, err = common.IndentJson(newData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newDataUTF16, err := EncodeUTF16LEBOM(newData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(configPath, newDataUTF16, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Invalidate all configs for this server since configs can be interdependent
|
||||
as.configCache.InvalidateServerCache(serverID)
|
||||
|
||||
as.serverService.StartAccServerRuntime(server)
|
||||
|
||||
// Log change
|
||||
if as.activityLog != nil {
|
||||
if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" {
|
||||
go func() {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &serverUUID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionConfigUpdate,
|
||||
Details: details,
|
||||
})
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
return as.repository.UpdateConfig(ctx, &model.Config{
|
||||
ServerID: serverUUID,
|
||||
ConfigFile: configFile,
|
||||
@@ -183,10 +304,6 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
|
||||
}), nil
|
||||
}
|
||||
|
||||
// GetConfig
|
||||
// Gets physical config file and caches it in database.
|
||||
//
|
||||
// Args:
|
||||
// context.Context: Application context
|
||||
// Returns:
|
||||
// string: Application version
|
||||
@@ -197,44 +314,47 @@ func (as *ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) {
|
||||
logging.Debug("Getting config for server ID: %s, file: %s", serverIDStr, configFile)
|
||||
|
||||
server, err := as.serverRepository.GetByID(ctx.UserContext(), serverIDStr)
|
||||
|
||||
if err != nil {
|
||||
logging.Error("Server not found")
|
||||
return nil, fiber.NewError(404, "Server not found")
|
||||
}
|
||||
return as.getConfigFile(server, configFile)
|
||||
}
|
||||
|
||||
// Try to get from cache based on config file type
|
||||
func (as *ConfigService) getConfigFile(server *model.Server, configFile string) (interface{}, error) {
|
||||
serverIDStr := server.ID.String()
|
||||
switch configFile {
|
||||
case ConfigurationJson:
|
||||
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok {
|
||||
logging.Debug("Returning cached configuration for server ID: %s", serverIDStr)
|
||||
return cached, nil
|
||||
return *cached, nil
|
||||
}
|
||||
case AssistRulesJson:
|
||||
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok {
|
||||
logging.Debug("Returning cached assist rules for server ID: %s", serverIDStr)
|
||||
return cached, nil
|
||||
return *cached, nil
|
||||
}
|
||||
case EventJson:
|
||||
if cached, ok := as.configCache.GetEvent(serverIDStr); ok {
|
||||
logging.Debug("Returning cached event config for server ID: %s", serverIDStr)
|
||||
return cached, nil
|
||||
return *cached, nil
|
||||
}
|
||||
case EventRulesJson:
|
||||
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok {
|
||||
logging.Debug("Returning cached event rules for server ID: %s", serverIDStr)
|
||||
return cached, nil
|
||||
return *cached, nil
|
||||
}
|
||||
case SettingsJson:
|
||||
if cached, ok := as.configCache.GetSettings(serverIDStr); ok {
|
||||
logging.Debug("Returning cached settings for server ID: %s", serverIDStr)
|
||||
return cached, nil
|
||||
return *cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
logging.Debug("Cache miss for server ID: %s, file: %s - loading from disk", serverIDStr, configFile)
|
||||
|
||||
// Not in cache, load from disk
|
||||
configPath := filepath.Join(server.GetConfigPath(), configFile)
|
||||
configPath := server.GetConfigPath()
|
||||
decoder := DecodeFileName(configFile)
|
||||
if decoder == nil {
|
||||
return nil, errors.New("invalid config file")
|
||||
@@ -244,43 +364,39 @@ func (as *ConfigService) GetConfig(ctx *fiber.Ctx) (interface{}, error) {
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
logging.Debug("Config file not found, creating default for server ID: %s, file: %s", serverIDStr, configFile)
|
||||
// Return empty config if file doesn't exist
|
||||
switch configFile {
|
||||
case ConfigurationJson:
|
||||
return &model.Configuration{}, nil
|
||||
return model.Configuration{}, nil
|
||||
case AssistRulesJson:
|
||||
return &model.AssistRules{}, nil
|
||||
return model.AssistRules{}, nil
|
||||
case EventJson:
|
||||
return &model.EventConfig{}, nil
|
||||
return model.EventConfig{}, nil
|
||||
case EventRulesJson:
|
||||
return &model.EventRules{}, nil
|
||||
return model.EventRules{}, nil
|
||||
case SettingsJson:
|
||||
return &model.ServerSettings{}, nil
|
||||
return model.ServerSettings{}, nil
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the loaded config
|
||||
switch configFile {
|
||||
case ConfigurationJson:
|
||||
as.configCache.UpdateConfiguration(serverIDStr, *config.(*model.Configuration))
|
||||
as.configCache.UpdateConfiguration(serverIDStr, config.(model.Configuration))
|
||||
case AssistRulesJson:
|
||||
as.configCache.UpdateAssistRules(serverIDStr, *config.(*model.AssistRules))
|
||||
as.configCache.UpdateAssistRules(serverIDStr, config.(model.AssistRules))
|
||||
case EventJson:
|
||||
as.configCache.UpdateEvent(serverIDStr, *config.(*model.EventConfig))
|
||||
as.configCache.UpdateEvent(serverIDStr, config.(model.EventConfig))
|
||||
case EventRulesJson:
|
||||
as.configCache.UpdateEventRules(serverIDStr, *config.(*model.EventRules))
|
||||
as.configCache.UpdateEventRules(serverIDStr, config.(model.EventRules))
|
||||
case SettingsJson:
|
||||
as.configCache.UpdateSettings(serverIDStr, *config.(*model.ServerSettings))
|
||||
as.configCache.UpdateSettings(serverIDStr, config.(model.ServerSettings))
|
||||
}
|
||||
|
||||
logging.Debug("Successfully loaded and cached config for server ID: %s, file: %s", serverIDStr, configFile)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetConfigs
|
||||
// Gets all configurations for a server, using cache when possible.
|
||||
func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, error) {
|
||||
serverID := ctx.Params("id")
|
||||
|
||||
@@ -296,82 +412,33 @@ func (as *ConfigService) GetConfigs(ctx *fiber.Ctx) (*model.Configurations, erro
|
||||
func (as *ConfigService) LoadConfigs(server *model.Server) (*model.Configurations, error) {
|
||||
serverIDStr := server.ID.String()
|
||||
logging.Info("Loading configs for server ID: %s at path: %s", serverIDStr, server.GetConfigPath())
|
||||
configs := &model.Configurations{}
|
||||
|
||||
// Load configuration
|
||||
if cached, ok := as.configCache.GetConfiguration(serverIDStr); ok {
|
||||
logging.Debug("Using cached configuration for server %s", serverIDStr)
|
||||
configs.Configuration = *cached
|
||||
} else {
|
||||
logging.Debug("Loading configuration from disk for server %s", serverIDStr)
|
||||
config, err := mustDecode[model.Configuration](ConfigurationJson, server.GetConfigPath())
|
||||
if err != nil {
|
||||
logging.Error("Failed to load configuration for server %s: %v", serverIDStr, err)
|
||||
return nil, fmt.Errorf("failed to load configuration: %v", err)
|
||||
}
|
||||
configs.Configuration = config
|
||||
as.configCache.UpdateConfiguration(serverIDStr, config)
|
||||
settingsConf, err := as.getConfigFile(server, SettingsJson)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load assist rules
|
||||
if cached, ok := as.configCache.GetAssistRules(serverIDStr); ok {
|
||||
logging.Debug("Using cached assist rules for server %s", serverIDStr)
|
||||
configs.AssistRules = *cached
|
||||
} else {
|
||||
logging.Debug("Loading assist rules from disk for server %s", serverIDStr)
|
||||
rules, err := mustDecode[model.AssistRules](AssistRulesJson, server.GetConfigPath())
|
||||
if err != nil {
|
||||
logging.Error("Failed to load assist rules for server %s: %v", serverIDStr, err)
|
||||
return nil, fmt.Errorf("failed to load assist rules: %v", err)
|
||||
}
|
||||
configs.AssistRules = rules
|
||||
as.configCache.UpdateAssistRules(serverIDStr, rules)
|
||||
eventRulesConf, err := as.getConfigFile(server, EventRulesJson)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load event config
|
||||
if cached, ok := as.configCache.GetEvent(serverIDStr); ok {
|
||||
logging.Debug("Using cached event config for server %s", serverIDStr)
|
||||
configs.Event = *cached
|
||||
} else {
|
||||
logging.Debug("Loading event config from disk for server %s", serverIDStr)
|
||||
event, err := mustDecode[model.EventConfig](EventJson, server.GetConfigPath())
|
||||
if err != nil {
|
||||
logging.Error("Failed to load event config for server %s: %v", serverIDStr, err)
|
||||
return nil, fmt.Errorf("failed to load event config: %v", err)
|
||||
}
|
||||
configs.Event = event
|
||||
logging.Debug("Updating event config for server %s with track: %s", serverIDStr, event.Track)
|
||||
as.configCache.UpdateEvent(serverIDStr, event)
|
||||
eventConf, err := as.getConfigFile(server, EventJson)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load event rules
|
||||
if cached, ok := as.configCache.GetEventRules(serverIDStr); ok {
|
||||
logging.Debug("Using cached event rules for server %s", serverIDStr)
|
||||
configs.EventRules = *cached
|
||||
} else {
|
||||
logging.Debug("Loading event rules from disk for server %s", serverIDStr)
|
||||
rules, err := mustDecode[model.EventRules](EventRulesJson, server.GetConfigPath())
|
||||
if err != nil {
|
||||
logging.Error("Failed to load event rules for server %s: %v", serverIDStr, err)
|
||||
return nil, fmt.Errorf("failed to load event rules: %v", err)
|
||||
}
|
||||
configs.EventRules = rules
|
||||
as.configCache.UpdateEventRules(serverIDStr, rules)
|
||||
assistRulesConf, err := as.getConfigFile(server, AssistRulesJson)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load settings
|
||||
if cached, ok := as.configCache.GetSettings(serverIDStr); ok {
|
||||
logging.Debug("Using cached settings for server %s", serverIDStr)
|
||||
configs.Settings = *cached
|
||||
} else {
|
||||
logging.Debug("Loading settings from disk for server %s", serverIDStr)
|
||||
settings, err := mustDecode[model.ServerSettings](SettingsJson, server.GetConfigPath())
|
||||
if err != nil {
|
||||
logging.Error("Failed to load settings for server %s: %v", serverIDStr, err)
|
||||
return nil, fmt.Errorf("failed to load settings: %v", err)
|
||||
}
|
||||
configs.Settings = settings
|
||||
as.configCache.UpdateSettings(serverIDStr, settings)
|
||||
configurationConf, err := as.getConfigFile(server, ConfigurationJson)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs := &model.Configurations{
|
||||
Settings: settingsConf.(model.ServerSettings),
|
||||
EventRules: eventRulesConf.(model.EventRules),
|
||||
Event: eventConf.(model.EventConfig),
|
||||
AssistRules: assistRulesConf.(model.AssistRules),
|
||||
Configuration: configurationConf.(model.Configuration),
|
||||
}
|
||||
|
||||
logging.Info("Successfully loaded all configs for server %s", serverIDStr)
|
||||
@@ -396,9 +463,6 @@ func readFile(path string, configFile string) ([]byte, error) {
|
||||
configPath := filepath.Join(path, configFile)
|
||||
oldData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("config file %s does not exist at %s", configFile, configPath)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return oldData, nil
|
||||
@@ -475,9 +539,15 @@ func (as *ConfigService) GetConfiguration(server *model.Server) (*model.Configur
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SaveConfiguration saves the configuration for a server
|
||||
func (as *ConfigService) SetIgnorePrematureDisconnects(server *model.Server, value int) error {
|
||||
configMap := map[string]interface{}{
|
||||
"ignorePrematureDisconnects": value,
|
||||
}
|
||||
_, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.Configuration) error {
|
||||
// Convert config to map for UpdateConfig
|
||||
configMap := make(map[string]interface{})
|
||||
configBytes, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
@@ -487,7 +557,20 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C
|
||||
return fmt.Errorf("failed to unmarshal configuration: %v", err)
|
||||
}
|
||||
|
||||
// Update the configuration using the internal method
|
||||
_, err = as.updateConfigInternal(context.Background(), server.ID.String(), ConfigurationJson, &configMap, true)
|
||||
_, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// ApplySection writes a single config section to the server's config file and
|
||||
// records the change. It is used by ConfigPresetService to apply preset sections.
|
||||
func (as *ConfigService) ApplySection(ctx context.Context, serverID, configFile string, body *map[string]interface{}, actorID, actorUsername string) (*model.Config, error) {
|
||||
return as.updateConfigInternal(ctx, serverID, configFile, body, true, actorID, actorUsername)
|
||||
}
|
||||
|
||||
func (as *ConfigService) SetServerName(server *model.Server, name string) error {
|
||||
configMap := map[string]interface{}{
|
||||
"serverName": name,
|
||||
}
|
||||
_, _, err := as.updateConfigFiles(context.Background(), server, SettingsJson, &configMap, false)
|
||||
return err
|
||||
}
|
||||
|
||||
210
local/service/config_preset.go
Normal file
210
local/service/config_preset.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
presetMaxNameLen = 100
|
||||
presetMaxDescLen = 500
|
||||
presetMaxSectionBytes = 64 * 1024 // 64 KB per config section
|
||||
)
|
||||
|
||||
type ConfigPresetService struct {
|
||||
repository *repository.ConfigPresetRepository
|
||||
configService *ConfigService
|
||||
activityLog *ActivityLogService
|
||||
}
|
||||
|
||||
func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService, activityLog *ActivityLogService) *ConfigPresetService {
|
||||
return &ConfigPresetService{
|
||||
repository: repo,
|
||||
configService: configService,
|
||||
activityLog: activityLog,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) ListPresets(ctx context.Context, filter *model.ConfigPresetFilter) (*[]model.ConfigPreset, error) {
|
||||
return s.repository.GetAll(ctx, filter)
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*model.ConfigPreset, error) {
|
||||
preset, err := s.repository.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if preset == nil {
|
||||
return nil, fmt.Errorf("preset not found")
|
||||
}
|
||||
return preset, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest, actorID, actorUsername string) (*model.ConfigPreset, error) {
|
||||
if req.Name == "" {
|
||||
return nil, fmt.Errorf("preset name is required")
|
||||
}
|
||||
if len(req.Name) > presetMaxNameLen {
|
||||
return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen)
|
||||
}
|
||||
if len(req.Description) > presetMaxDescLen {
|
||||
return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen)
|
||||
}
|
||||
|
||||
preset := &model.ConfigPreset{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
}
|
||||
|
||||
if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.Insert(ctx, preset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q}`, preset.ID, preset.Name)
|
||||
go func() {
|
||||
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionPresetCreate,
|
||||
Details: details,
|
||||
}); logErr != nil {
|
||||
logging.Error("Failed to log preset create: %v", logErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return preset, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) UpdatePreset(ctx context.Context, id uuid.UUID, req *model.ConfigPresetUpdateRequest) (*model.ConfigPreset, error) {
|
||||
preset, err := s.GetPreset(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
if len(*req.Name) == 0 {
|
||||
return nil, fmt.Errorf("preset name cannot be empty")
|
||||
}
|
||||
if len(*req.Name) > presetMaxNameLen {
|
||||
return nil, fmt.Errorf("preset name exceeds maximum length of %d characters", presetMaxNameLen)
|
||||
}
|
||||
preset.Name = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
if len(*req.Description) > presetMaxDescLen {
|
||||
return nil, fmt.Errorf("preset description exceeds maximum length of %d characters", presetMaxDescLen)
|
||||
}
|
||||
preset.Description = *req.Description
|
||||
}
|
||||
if err := marshalSections(req.Configuration, req.AssistRules, req.Event, req.EventRules, req.Settings, preset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.Update(ctx, preset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return preset, nil
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) DeletePreset(ctx context.Context, id uuid.UUID) error {
|
||||
return s.repository.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, presetID uuid.UUID, actorID, actorUsername string) ([]*model.Config, error) {
|
||||
preset, err := s.GetPreset(ctx, presetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverUUID, err := uuid.Parse(serverID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid server ID: %v", err)
|
||||
}
|
||||
|
||||
type sectionEntry struct {
|
||||
fileName string
|
||||
raw string
|
||||
}
|
||||
|
||||
sections := []sectionEntry{
|
||||
{ConfigurationJson, preset.Configuration},
|
||||
{AssistRulesJson, preset.AssistRules},
|
||||
{EventJson, preset.Event},
|
||||
{EventRulesJson, preset.EventRules},
|
||||
{SettingsJson, preset.Settings},
|
||||
}
|
||||
|
||||
var results []*model.Config
|
||||
for _, section := range sections {
|
||||
if section.raw == "" {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(section.raw), &body); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal preset section %s: %v", section.fileName, err)
|
||||
}
|
||||
result, err := s.configService.ApplySection(ctx, serverID, section.fileName, &body, actorID, actorUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to apply preset section %s: %v", section.fileName, err)
|
||||
}
|
||||
if result != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
details := fmt.Sprintf(`{"preset_id":%q,"preset_name":%q,"sections_applied":%d}`, presetID, preset.Name, len(results))
|
||||
go func() {
|
||||
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &serverUUID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionPresetApply,
|
||||
Details: details,
|
||||
}); logErr != nil {
|
||||
logging.Error("Failed to log preset apply: %v", logErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// marshalSections marshals each non-nil config section into the preset's JSON string fields,
|
||||
// enforcing the per-section byte size limit.
|
||||
func marshalSections(configuration, assistRules, event, eventRules, settings *map[string]interface{}, preset *model.ConfigPreset) error {
|
||||
type entry struct {
|
||||
src *map[string]interface{}
|
||||
name string
|
||||
dest *string
|
||||
}
|
||||
entries := []entry{
|
||||
{configuration, "configuration", &preset.Configuration},
|
||||
{assistRules, "assistRules", &preset.AssistRules},
|
||||
{event, "event", &preset.Event},
|
||||
{eventRules, "eventRules", &preset.EventRules},
|
||||
{settings, "settings", &preset.Settings},
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.src == nil {
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(e.src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal %s: %v", e.name, err)
|
||||
}
|
||||
if len(b) > presetMaxSectionBytes {
|
||||
return fmt.Errorf("%s section exceeds maximum size of %d bytes", e.name, presetMaxSectionBytes)
|
||||
}
|
||||
*e.dest = string(b)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
local/service/docker_log_streamer.go
Normal file
108
local/service/docker_log_streamer.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DockerLogStreamer struct {
|
||||
client *dockerclient.Client
|
||||
cancels sync.Map // uuid.UUID → context.CancelFunc
|
||||
}
|
||||
|
||||
func NewDockerLogStreamer(client *dockerclient.Client) *DockerLogStreamer {
|
||||
return &DockerLogStreamer{client: client}
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) Start(ctx context.Context, server *model.Server, handleLine func(string)) error {
|
||||
s.Stop(server.ID)
|
||||
|
||||
if server.ContainerID == "" {
|
||||
logging.Warn("DockerLogStreamer.Start: no container ID for server %s, skipping", server.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
since := time.Now().UTC().Format(time.RFC3339)
|
||||
streamCtx, cancel := context.WithCancel(ctx)
|
||||
s.cancels.Store(server.ID, cancel)
|
||||
|
||||
go func() {
|
||||
defer s.cancels.Delete(server.ID)
|
||||
|
||||
rc, err := s.client.ContainerLogs(streamCtx, server.ContainerID, types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Follow: true,
|
||||
Timestamps: false,
|
||||
Since: since,
|
||||
})
|
||||
if err != nil {
|
||||
logging.Error("DockerLogStreamer: failed to attach to container %s: %v", server.ContainerID[:12], err)
|
||||
return
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
|
||||
logging.Warn("DockerLogStreamer: stdcopy error for %s: %v", server.ServiceName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(pr)
|
||||
for scanner.Scan() {
|
||||
handleLine(scanner.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) Stop(serverID uuid.UUID) {
|
||||
if v, ok := s.cancels.LoadAndDelete(serverID); ok {
|
||||
v.(context.CancelFunc)()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DockerLogStreamer) GetLastLines(ctx context.Context, server *model.Server, n int) ([]string, error) {
|
||||
if n <= 0 || server.ContainerID == "" {
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
rc, err := s.client.ContainerLogs(ctx, server.ContainerID, types.ContainerLogsOptions{
|
||||
ShowStdout: true,
|
||||
ShowStderr: true,
|
||||
Tail: strconv.Itoa(n),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
if _, err := stdcopy.StdCopy(pw, pw, rc); err != nil && err != io.EOF {
|
||||
logging.Warn("DockerLogStreamer.GetLastLines: stdcopy error for %s: %v", server.ServiceName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
var lines []string
|
||||
scanner := bufio.NewScanner(pr)
|
||||
for scanner.Scan() {
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
return lines, scanner.Err()
|
||||
}
|
||||
238
local/service/docker_runtime.go
Normal file
238
local/service/docker_runtime.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/env"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/filters"
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"github.com/docker/go-connections/nat"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type DockerRuntime struct {
|
||||
client *dockerclient.Client
|
||||
repo *repository.ServerRepository
|
||||
}
|
||||
|
||||
func NewDockerRuntime(client *dockerclient.Client, repo *repository.ServerRepository) *DockerRuntime {
|
||||
return &DockerRuntime{
|
||||
client: client,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Create(ctx context.Context, server *model.Server) error {
|
||||
image := env.GetACCImage()
|
||||
// ACC uses the same port number for both TCP (client connections) and UDP (car positions/ping).
|
||||
portStr := strconv.Itoa(server.Port)
|
||||
|
||||
natTCP, err := nat.NewPort("tcp", portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port %d: %v", server.Port, err)
|
||||
}
|
||||
natUDP, err := nat.NewPort("udp", portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port %d: %v", server.Port, err)
|
||||
}
|
||||
|
||||
portBindings := nat.PortMap{
|
||||
natTCP: []nat.PortBinding{{HostPort: portStr}},
|
||||
natUDP: []nat.PortBinding{{HostPort: portStr}},
|
||||
}
|
||||
exposedPorts := nat.PortSet{
|
||||
natTCP: struct{}{},
|
||||
natUDP: struct{}{},
|
||||
}
|
||||
|
||||
containerBase := env.GetACCServersPath()
|
||||
hostBase := env.GetACCServersHostPath()
|
||||
serverSubPath := strings.TrimPrefix(server.GetServerPath(), containerBase)
|
||||
hostServerPath := filepath.Join(hostBase, serverSubPath)
|
||||
|
||||
binds := []string{
|
||||
fmt.Sprintf("%s:/acc/game:rw", hostServerPath),
|
||||
}
|
||||
|
||||
resp, err := r.client.ContainerCreate(ctx, &container.Config{
|
||||
Image: image,
|
||||
ExposedPorts: exposedPorts,
|
||||
Env: []string{
|
||||
fmt.Sprintf("SERVER_PORT=%d", server.Port),
|
||||
},
|
||||
}, &container.HostConfig{
|
||||
Binds: binds,
|
||||
PortBindings: portBindings,
|
||||
RestartPolicy: container.RestartPolicy{Name: "unless-stopped"},
|
||||
// NET_RAW is required for tcpdump packet capture in debug mode (DEBUG_MODE=1).
|
||||
CapAdd: []string{"NET_RAW"},
|
||||
}, nil, nil, server.ServiceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create container %s: %v", server.ServiceName, err)
|
||||
}
|
||||
|
||||
server.ContainerID = resp.ID
|
||||
logging.Info("Created Docker container %s (ID: %s) for server %s", server.ServiceName, resp.ID[:12], server.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Delete(ctx context.Context, serverID uuid.UUID) error {
|
||||
containerID, err := r.resolveContainerID(ctx, serverID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if containerID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = r.client.ContainerRemove(ctx, containerID, types.ContainerRemoveOptions{
|
||||
Force: true,
|
||||
RemoveVolumes: false,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove container %s: %v", containerID[:12], err)
|
||||
}
|
||||
logging.Info("Removed Docker container %s for server %s", containerID[:12], serverID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
|
||||
}
|
||||
if server == nil {
|
||||
return "", fmt.Errorf("server %s not found", serverID)
|
||||
}
|
||||
|
||||
containerID, err := r.ensureContainer(ctx, server)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := r.client.ContainerStart(ctx, containerID, types.ContainerStartOptions{}); err != nil {
|
||||
return "", fmt.Errorf("failed to start container: %v", err)
|
||||
}
|
||||
return model.StatusRunning.String(), nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
containerID, err := r.resolveContainerID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := r.client.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil {
|
||||
return "", fmt.Errorf("failed to stop container: %v", err)
|
||||
}
|
||||
return model.StatusStopped.String(), nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
|
||||
}
|
||||
if server == nil {
|
||||
return "", fmt.Errorf("server %s not found", serverID)
|
||||
}
|
||||
|
||||
containerID, err := r.ensureContainer(ctx, server)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{}); err != nil {
|
||||
return "", fmt.Errorf("failed to restart container: %v", err)
|
||||
}
|
||||
return model.StatusRunning.String(), nil
|
||||
}
|
||||
|
||||
// ensureContainer returns the container ID for a server, recreating the container
|
||||
// from the server's current DB record if it no longer exists in Docker.
|
||||
func (r *DockerRuntime) ensureContainer(ctx context.Context, server *model.Server) (string, error) {
|
||||
if server.ContainerID != "" {
|
||||
if _, err := r.client.ContainerInspect(ctx, server.ContainerID); err == nil {
|
||||
return server.ContainerID, nil
|
||||
}
|
||||
logging.Info("Container %s for server %s no longer exists, recreating", server.ContainerID[:min(12, len(server.ContainerID))], server.ID)
|
||||
}
|
||||
|
||||
if err := r.Create(ctx, server); err != nil {
|
||||
return "", fmt.Errorf("failed to recreate container: %v", err)
|
||||
}
|
||||
if err := r.repo.Update(ctx, server); err != nil {
|
||||
return "", fmt.Errorf("container recreated but failed to persist container ID: %v", err)
|
||||
}
|
||||
return server.ContainerID, nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil || server == nil {
|
||||
return model.StatusUnknown.String(), err
|
||||
}
|
||||
|
||||
containerID := server.ContainerID
|
||||
if containerID == "" {
|
||||
containerID, err = r.findContainerByName(ctx, server.ServiceName)
|
||||
if err != nil || containerID == "" {
|
||||
return model.StatusStopped.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
info, err := r.client.ContainerInspect(ctx, containerID)
|
||||
if err != nil {
|
||||
return model.StatusUnknown.String(), nil
|
||||
}
|
||||
|
||||
return dockerStateToStatus(info.State.Status), nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) resolveContainerID(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to look up server %s: %v", serverID, err)
|
||||
}
|
||||
if server == nil {
|
||||
return "", fmt.Errorf("server %s not found", serverID)
|
||||
}
|
||||
return server.ContainerID, nil
|
||||
}
|
||||
|
||||
func (r *DockerRuntime) findContainerByName(ctx context.Context, name string) (string, error) {
|
||||
containers, err := r.client.ContainerList(ctx, types.ContainerListOptions{
|
||||
All: true,
|
||||
Filters: filters.NewArgs(filters.Arg("name", "/"+name)),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return containers[0].ID, nil
|
||||
}
|
||||
|
||||
func dockerStateToStatus(state string) string {
|
||||
switch state {
|
||||
case "running":
|
||||
return model.StatusRunning.String()
|
||||
case "exited", "dead":
|
||||
return model.StatusStopped.String()
|
||||
case "restarting":
|
||||
return model.StatusRestarting.String()
|
||||
case "paused":
|
||||
return model.StatusStopped.String()
|
||||
default:
|
||||
return model.StatusUnknown.String()
|
||||
}
|
||||
}
|
||||
77
local/service/file_log_streamer.go
Normal file
77
local/service/file_log_streamer.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/tracking"
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FileLogStreamer struct {
|
||||
tailers sync.Map // uuid.UUID → *tracking.LogTailer
|
||||
}
|
||||
|
||||
func NewFileLogStreamer() *FileLogStreamer {
|
||||
return &FileLogStreamer{}
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) Start(_ context.Context, server *model.Server, handleLine func(string)) error {
|
||||
if _, exists := s.tailers.Load(server.ID); exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
logPath := filepath.Join(server.GetLogPath(), "server.log")
|
||||
tailer := tracking.NewLogTailer(logPath, handleLine)
|
||||
s.tailers.Store(server.ID, tailer)
|
||||
|
||||
go tailer.Start()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) Stop(serverID uuid.UUID) {
|
||||
if v, exists := s.tailers.LoadAndDelete(serverID); exists {
|
||||
v.(*tracking.LogTailer).Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *FileLogStreamer) GetLastLines(_ context.Context, server *model.Server, n int) ([]string, error) {
|
||||
if n <= 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
logPath := filepath.Join(server.GetLogPath(), "server.log")
|
||||
f, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
ring := make([]string, n)
|
||||
head := 0
|
||||
count := 0
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
ring[head] = scanner.Text()
|
||||
head = (head + 1) % n
|
||||
if count < n {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count < n {
|
||||
return ring[:count], nil
|
||||
}
|
||||
out := make([]string, n)
|
||||
copy(out, ring[head:])
|
||||
copy(out[n-head:], ring[:head])
|
||||
return out, nil
|
||||
}
|
||||
4
local/service/firewall_service.go
Normal file → Executable file
4
local/service/firewall_service.go
Normal file → Executable file
@@ -96,11 +96,9 @@ func (s *FirewallService) DeleteServerRules(serverName string, tcpPorts, udpPort
|
||||
}
|
||||
|
||||
func (s *FirewallService) UpdateServerRules(serverName string, tcpPorts, udpPorts []int) error {
|
||||
// First delete existing rules
|
||||
if err := s.DeleteServerRules(serverName, tcpPorts, udpPorts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Then create new rules
|
||||
return s.CreateServerRules(serverName, tcpPorts, udpPorts)
|
||||
}
|
||||
}
|
||||
|
||||
59
local/service/leaderboard.go
Executable file
59
local/service/leaderboard.go
Executable file
@@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type LeaderboardService struct {
|
||||
repo *repository.LeaderboardRepository
|
||||
activityLog *ActivityLogService
|
||||
}
|
||||
|
||||
func NewLeaderboardService(repo *repository.LeaderboardRepository, activityLog *ActivityLogService) *LeaderboardService {
|
||||
return &LeaderboardService{repo: repo, activityLog: activityLog}
|
||||
}
|
||||
|
||||
// Get returns the leaderboard for a server.
|
||||
func (s *LeaderboardService) Get(ctx context.Context, serverID uuid.UUID) (*model.Leaderboard, error) {
|
||||
return s.repo.GetOrCreateByServerID(ctx, serverID)
|
||||
}
|
||||
|
||||
// Update replaces the leaderboard for a server.
|
||||
func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb *model.Leaderboard, actorID, actorUsername string) (*model.Leaderboard, error) {
|
||||
if lb == nil {
|
||||
return nil, fmt.Errorf("input is required")
|
||||
}
|
||||
for i := range lb.Drivers {
|
||||
lb.Drivers[i].Position = i
|
||||
}
|
||||
for i := range lb.Races {
|
||||
lb.Races[i].Position = i
|
||||
}
|
||||
result, err := s.repo.FullReplace(ctx, serverID, lb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driverCount := len(lb.Drivers)
|
||||
raceCount := len(lb.Races)
|
||||
details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount)
|
||||
go func() {
|
||||
if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &serverID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionLeaderboardUpdate,
|
||||
Details: details,
|
||||
}); logErr != nil {
|
||||
logging.Error("Failed to log leaderboard update: %v", logErr)
|
||||
}
|
||||
}()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
0
local/service/lookup.go
Normal file → Executable file
0
local/service/lookup.go
Normal file → Executable file
38
local/service/membership.go
Normal file → Executable file
38
local/service/membership.go
Normal file → Executable file
@@ -12,13 +12,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CacheInvalidator interface for cache invalidation
|
||||
type CacheInvalidator interface {
|
||||
InvalidateUserPermissions(userID string)
|
||||
InvalidateAllUserPermissions()
|
||||
}
|
||||
|
||||
// MembershipService provides business logic for membership-related operations.
|
||||
type MembershipService struct {
|
||||
repo *repository.MembershipRepository
|
||||
cacheInvalidator CacheInvalidator
|
||||
@@ -26,29 +24,25 @@ type MembershipService struct {
|
||||
openJwtHandler *jwt.OpenJWTHandler
|
||||
}
|
||||
|
||||
// NewMembershipService creates a new MembershipService.
|
||||
func NewMembershipService(repo *repository.MembershipRepository, jwtHandler *jwt.JWTHandler, openJwtHandler *jwt.OpenJWTHandler) *MembershipService {
|
||||
return &MembershipService{
|
||||
repo: repo,
|
||||
cacheInvalidator: nil, // Will be set later via SetCacheInvalidator
|
||||
cacheInvalidator: nil,
|
||||
jwtHandler: jwtHandler,
|
||||
openJwtHandler: openJwtHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// SetCacheInvalidator sets the cache invalidator after service initialization
|
||||
func (s *MembershipService) SetCacheInvalidator(invalidator CacheInvalidator) {
|
||||
s.cacheInvalidator = invalidator
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT.
|
||||
func (s *MembershipService) HandleLogin(ctx context.Context, username, password string) (*model.User, error) {
|
||||
user, err := s.repo.FindUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
// Use secure password verification with constant-time comparison
|
||||
if err := user.VerifyPassword(password); err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
@@ -56,7 +50,6 @@ func (s *MembershipService) HandleLogin(ctx context.Context, username, password
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login authenticates a user and returns a JWT.
|
||||
func (s *MembershipService) Login(ctx context.Context, username, password string) (string, error) {
|
||||
user, err := s.HandleLogin(ctx, username, password)
|
||||
if err != nil {
|
||||
@@ -70,7 +63,6 @@ func (s *MembershipService) GenerateOpenToken(ctx context.Context, userId string
|
||||
return s.openJwtHandler.GenerateToken(userId)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user.
|
||||
func (s *MembershipService) CreateUser(ctx context.Context, username, password, roleName string) (*model.User, error) {
|
||||
|
||||
role, err := s.repo.FindRoleByName(ctx, roleName)
|
||||
@@ -94,43 +86,35 @@ func (s *MembershipService) CreateUser(ctx context.Context, username, password,
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ListUsers retrieves all users.
|
||||
func (s *MembershipService) ListUsers(ctx context.Context) ([]*model.User, error) {
|
||||
return s.repo.ListUsers(ctx)
|
||||
}
|
||||
|
||||
// GetUser retrieves a single user by ID.
|
||||
func (s *MembershipService) GetUser(ctx context.Context, userID uuid.UUID) (*model.User, error) {
|
||||
return s.repo.FindUserByID(ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserWithPermissions retrieves a single user by ID with their role and permissions.
|
||||
func (s *MembershipService) GetUserWithPermissions(ctx context.Context, userID string) (*model.User, error) {
|
||||
return s.repo.FindUserByIDWithPermissions(ctx, userID)
|
||||
}
|
||||
|
||||
// UpdateUserRequest defines the request body for updating a user.
|
||||
type UpdateUserRequest struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
RoleID *uuid.UUID `json:"roleId"`
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user with validation to prevent Super Admin deletion.
|
||||
func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) error {
|
||||
// Get user with role information
|
||||
user, err := s.repo.FindUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
return errors.New("user not found")
|
||||
}
|
||||
|
||||
// Get role to check if it's Super Admin
|
||||
role, err := s.repo.FindRoleByID(ctx, user.RoleID)
|
||||
if err != nil {
|
||||
return errors.New("user role not found")
|
||||
}
|
||||
|
||||
// Prevent deletion of Super Admin users
|
||||
if role.Name == "Super Admin" {
|
||||
return errors.New("cannot delete Super Admin user")
|
||||
}
|
||||
@@ -140,7 +124,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate cache for deleted user
|
||||
if s.cacheInvalidator != nil {
|
||||
s.cacheInvalidator.InvalidateUserPermissions(userID.String())
|
||||
}
|
||||
@@ -149,7 +132,6 @@ func (s *MembershipService) DeleteUser(ctx context.Context, userID uuid.UUID) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateUser updates a user's details.
|
||||
func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, req UpdateUserRequest) (*model.User, error) {
|
||||
user, err := s.repo.FindUserByID(ctx, userID)
|
||||
if err != nil {
|
||||
@@ -161,12 +143,10 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
// Password will be automatically hashed in BeforeUpdate hook
|
||||
user.Password = *req.Password
|
||||
}
|
||||
|
||||
if req.RoleID != nil {
|
||||
// Check if role exists
|
||||
_, err := s.repo.FindRoleByID(ctx, *req.RoleID)
|
||||
if err != nil {
|
||||
return nil, errors.New("role not found")
|
||||
@@ -178,7 +158,6 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Invalidate cache if role was changed
|
||||
if req.RoleID != nil && s.cacheInvalidator != nil {
|
||||
s.cacheInvalidator.InvalidateUserPermissions(userID.String())
|
||||
}
|
||||
@@ -187,14 +166,12 @@ func (s *MembershipService) UpdateUser(ctx context.Context, userID uuid.UUID, re
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// HasPermission checks if a user has a specific permission.
|
||||
func (s *MembershipService) HasPermission(ctx context.Context, userID string, permissionName string) (bool, error) {
|
||||
user, err := s.repo.FindUserByIDWithPermissions(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Super admin and Admin have all permissions
|
||||
if user.Role.Name == "Super Admin" || user.Role.Name == "Admin" {
|
||||
return true, nil
|
||||
}
|
||||
@@ -208,15 +185,13 @@ func (s *MembershipService) HasPermission(ctx context.Context, userID string, pe
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// SetupInitialData creates the initial roles and permissions.
|
||||
func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
// Define all permissions
|
||||
permissions := model.AllPermissions()
|
||||
|
||||
createdPermissions := make([]model.Permission, 0)
|
||||
for _, pName := range permissions {
|
||||
perm, err := s.repo.FindPermissionByName(ctx, pName)
|
||||
if err != nil { // Assuming error means not found
|
||||
if err != nil {
|
||||
perm = &model.Permission{Name: pName}
|
||||
if err := s.repo.CreatePermission(ctx, perm); err != nil {
|
||||
return err
|
||||
@@ -225,7 +200,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
createdPermissions = append(createdPermissions, *perm)
|
||||
}
|
||||
|
||||
// Create Super Admin role with all permissions
|
||||
superAdminRole, err := s.repo.FindRoleByName(ctx, "Super Admin")
|
||||
if err != nil {
|
||||
superAdminRole = &model.Role{Name: "Super Admin"}
|
||||
@@ -237,7 +211,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create Admin role with same permissions as Super Admin
|
||||
adminRole, err := s.repo.FindRoleByName(ctx, "Admin")
|
||||
if err != nil {
|
||||
adminRole = &model.Role{Name: "Admin"}
|
||||
@@ -249,7 +222,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create Manager role with limited permissions (excluding membership, role, user, server create/delete)
|
||||
managerRole, err := s.repo.FindRoleByName(ctx, "Manager")
|
||||
if err != nil {
|
||||
managerRole = &model.Role{Name: "Manager"}
|
||||
@@ -258,7 +230,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Define manager permissions (limited set)
|
||||
managerPermissionNames := []string{
|
||||
model.ServerView,
|
||||
model.ServerUpdate,
|
||||
@@ -282,16 +253,14 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate all caches after role setup changes
|
||||
if s.cacheInvalidator != nil {
|
||||
s.cacheInvalidator.InvalidateAllUserPermissions()
|
||||
}
|
||||
|
||||
// Create a default admin user if one doesn't exist
|
||||
_, err = s.repo.FindUserByUsername(ctx, "admin")
|
||||
if err != nil {
|
||||
logging.Debug("Creating default admin user")
|
||||
_, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin") // Default password, should be changed
|
||||
_, err = s.CreateUser(ctx, "admin", os.Getenv("PASSWORD"), "Super Admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -300,7 +269,6 @@ func (s *MembershipService) SetupInitialData(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllRoles retrieves all roles for dropdown selection.
|
||||
func (s *MembershipService) GetAllRoles(ctx context.Context) ([]*model.Role, error) {
|
||||
return s.repo.ListRoles(ctx)
|
||||
}
|
||||
|
||||
7
local/service/noop_firewall.go
Normal file
7
local/service/noop_firewall.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package service
|
||||
|
||||
type NoOpFirewall struct{}
|
||||
|
||||
func (f *NoOpFirewall) CreateServerRules(_ string, _, _ []int) error { return nil }
|
||||
func (f *NoOpFirewall) DeleteServerRules(_ string, _, _ []int) error { return nil }
|
||||
func (f *NoOpFirewall) UpdateServerRules(_ string, _, _ []int) error { return nil }
|
||||
467
local/service/server.go
Normal file → Executable file
467
local/service/server.go
Normal file → Executable file
@@ -2,9 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/platform"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/env"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"acc-server-manager/local/utl/network"
|
||||
"acc-server-manager/local/utl/tracking"
|
||||
"context"
|
||||
"fmt"
|
||||
@@ -12,30 +14,31 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"acc-server-manager/local/utl/network"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultStartPort = 9600
|
||||
RequiredPortCount = 1 // Update this if ACC needs more ports
|
||||
RequiredPortCount = 1
|
||||
)
|
||||
|
||||
type ServerService struct {
|
||||
repository *repository.ServerRepository
|
||||
stateHistoryRepo *repository.StateHistoryRepository
|
||||
activityLogRepo *repository.ActivityLogRepository
|
||||
apiService *ServiceControlService
|
||||
configService *ConfigService
|
||||
steamService *SteamService
|
||||
windowsService *WindowsService
|
||||
firewallService *FirewallService
|
||||
instances sync.Map // Track instances per server
|
||||
lastInsertTimes sync.Map // Track last insert time per server
|
||||
debouncers sync.Map // Track debounce timers per server
|
||||
logTailers sync.Map // Track log tailers per server
|
||||
sessionIDs sync.Map // Track current session ID per server
|
||||
runtime platform.ServerRuntime
|
||||
firewall platform.FirewallManager
|
||||
logStreamer platform.LogStreamer
|
||||
portPool *network.PortPoolManager
|
||||
webSocketService *WebSocketService
|
||||
instances sync.Map // uuid.UUID → *tracking.AccServerInstance
|
||||
lastInsertTimes sync.Map // uuid.UUID → time.Time
|
||||
debouncers sync.Map // uuid.UUID → *pendingState
|
||||
sessionIDs sync.Map // uuid.UUID → uuid.UUID
|
||||
}
|
||||
|
||||
type pendingState struct {
|
||||
@@ -44,42 +47,55 @@ type pendingState struct {
|
||||
}
|
||||
|
||||
func (s *ServerService) ensureLogTailing(server *model.Server, instance *tracking.AccServerInstance) {
|
||||
// Check if we already have a tailer
|
||||
if _, exists := s.logTailers.Load(server.ID); exists {
|
||||
return
|
||||
}
|
||||
|
||||
// Start tailing in a goroutine that handles file creation/deletion
|
||||
go func() {
|
||||
logPath := filepath.Join(server.GetLogPath(), "server.log")
|
||||
tailer := tracking.NewLogTailer(logPath, instance.HandleLogLine)
|
||||
s.logTailers.Store(server.ID, tailer)
|
||||
|
||||
// Start tailing and automatically handle file changes
|
||||
tailer.Start()
|
||||
handleFn := func(line string) {
|
||||
instance.HandleLogLine(line)
|
||||
s.webSocketService.BroadcastLogLine(server.ID, line)
|
||||
}
|
||||
if err := s.logStreamer.Start(context.Background(), server, handleFn); err != nil {
|
||||
logging.Error("Failed to start log streaming for server %s: %v", server.ID, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ServerService) GetLastLogLines(ctx *fiber.Ctx, serverID uuid.UUID, n int) ([]string, error) {
|
||||
server, err := s.repository.GetByID(ctx.UserContext(), serverID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
return s.logStreamer.GetLastLines(ctx.UserContext(), server, n)
|
||||
}
|
||||
|
||||
func NewServerService(
|
||||
repository *repository.ServerRepository,
|
||||
stateHistoryRepo *repository.StateHistoryRepository,
|
||||
activityLogRepo *repository.ActivityLogRepository,
|
||||
apiService *ServiceControlService,
|
||||
configService *ConfigService,
|
||||
steamService *SteamService,
|
||||
windowsService *WindowsService,
|
||||
firewallService *FirewallService,
|
||||
runtime platform.ServerRuntime,
|
||||
firewall platform.FirewallManager,
|
||||
logStreamer platform.LogStreamer,
|
||||
portPool *network.PortPoolManager,
|
||||
webSocketService *WebSocketService,
|
||||
) *ServerService {
|
||||
service := &ServerService{
|
||||
repository: repository,
|
||||
stateHistoryRepo: stateHistoryRepo,
|
||||
activityLogRepo: activityLogRepo,
|
||||
apiService: apiService,
|
||||
configService: configService,
|
||||
steamService: steamService,
|
||||
windowsService: windowsService,
|
||||
firewallService: firewallService,
|
||||
runtime: runtime,
|
||||
firewall: firewall,
|
||||
logStreamer: logStreamer,
|
||||
portPool: portPool,
|
||||
webSocketService: webSocketService,
|
||||
}
|
||||
|
||||
// Initialize server instances
|
||||
servers, err := repository.GetAll(context.Background(), &model.ServerFilter{})
|
||||
if err != nil {
|
||||
logging.Error("Failed to get servers: %v", err)
|
||||
@@ -87,7 +103,6 @@ func NewServerService(
|
||||
}
|
||||
|
||||
for i := range *servers {
|
||||
// Initialize instance regardless of status
|
||||
logging.Info("Starting server runtime for server ID: %d", (*servers)[i].ID)
|
||||
service.StartAccServerRuntime(&(*servers)[i])
|
||||
}
|
||||
@@ -96,7 +111,7 @@ func NewServerService(
|
||||
}
|
||||
|
||||
func (s *ServerService) shouldInsertStateHistory(serverID uuid.UUID) bool {
|
||||
insertInterval := 5 * time.Minute // Configure this as needed
|
||||
insertInterval := 5 * time.Minute
|
||||
|
||||
lastInsertInterface, exists := s.lastInsertTimes.Load(serverID)
|
||||
if !exists {
|
||||
@@ -119,16 +134,15 @@ func (s *ServerService) getNextSessionID(serverID uuid.UUID) uuid.UUID {
|
||||
lastID, err := s.stateHistoryRepo.GetLastSessionID(context.Background(), serverID)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get last session ID for server %s: %v", serverID, err)
|
||||
return uuid.New() // Return new UUID as fallback
|
||||
return uuid.New()
|
||||
}
|
||||
if lastID == uuid.Nil {
|
||||
return uuid.New() // Return new UUID if no previous session
|
||||
return uuid.New()
|
||||
}
|
||||
return uuid.New() // Always generate new UUID for each session
|
||||
return uuid.New()
|
||||
}
|
||||
|
||||
func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.ServerState) {
|
||||
// Get or create session ID when session changes
|
||||
currentSessionInterface, exists := s.instances.Load(serverID)
|
||||
var sessionID uuid.UUID
|
||||
if !exists {
|
||||
@@ -159,8 +173,7 @@ func (s *ServerService) insertStateHistory(serverID uuid.UUID, state *model.Serv
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType string) {
|
||||
// Get configs using helper methods
|
||||
func (s *ServerService) updateSessionDuration(server *model.Server, sessionType model.TrackSession) {
|
||||
event, err := s.configService.GetEventConfig(server)
|
||||
if err != nil {
|
||||
event = &model.EventConfig{}
|
||||
@@ -178,9 +191,7 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
|
||||
serverInstance.State.Track = event.Track
|
||||
serverInstance.State.MaxConnections = configuration.MaxConnections.ToInt()
|
||||
|
||||
// Check if session type has changed
|
||||
if serverInstance.State.Session != sessionType {
|
||||
// Get new session ID for the new session
|
||||
sessionID := s.getNextSessionID(server.ID)
|
||||
s.sessionIDs.Store(server.ID, sessionID)
|
||||
}
|
||||
@@ -201,32 +212,37 @@ func (s *ServerService) updateSessionDuration(server *model.Server, sessionType
|
||||
}
|
||||
|
||||
func (s *ServerService) GenerateServerPath(server *model.Server) {
|
||||
// Get the base steamcmd path from environment variable
|
||||
steamCMDPath := env.GetSteamCMDDirPath()
|
||||
server.Path = server.GenerateServerPath(steamCMDPath)
|
||||
server.GenerateUUID()
|
||||
if server.ServiceName == "" {
|
||||
server.ServiceName = server.GenerateServiceName()
|
||||
}
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
server.Path = filepath.Join(env.GetACCServersPath(), server.ServiceName)
|
||||
server.FromSteamCMD = true
|
||||
} else {
|
||||
steamCMDPath := env.GetSteamCMDDirPath()
|
||||
server.Path = server.GenerateServerPath(steamCMDPath)
|
||||
server.FromSteamCMD = true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) handleStateChange(server *model.Server, state *model.ServerState) {
|
||||
// Update session duration when session changes
|
||||
s.updateSessionDuration(server, state.Session)
|
||||
|
||||
// Invalidate status cache when server state changes
|
||||
s.apiService.statusCache.InvalidateStatus(server.ServiceName)
|
||||
|
||||
// Cancel existing timer if any
|
||||
if debouncer, exists := s.debouncers.Load(server.ID); exists {
|
||||
pending := debouncer.(*pendingState)
|
||||
pending.timer.Stop()
|
||||
}
|
||||
|
||||
// Create new timer
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
s.debouncers.Store(server.ID, &pendingState{
|
||||
timer: timer,
|
||||
state: state,
|
||||
})
|
||||
|
||||
// Start goroutine to handle the delayed insert
|
||||
go func() {
|
||||
<-timer.C
|
||||
if debouncer, exists := s.debouncers.Load(server.ID); exists {
|
||||
@@ -236,14 +252,12 @@ func (s *ServerService) handleStateChange(server *model.Server, state *model.Ser
|
||||
}
|
||||
}()
|
||||
|
||||
// If enough time has passed since last insert, insert immediately
|
||||
if s.shouldInsertStateHistory(server.ID) {
|
||||
s.insertStateHistory(server.ID, state)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) StartAccServerRuntime(server *model.Server) {
|
||||
// Get or create instance
|
||||
instanceInterface, exists := s.instances.Load(server.ID)
|
||||
var instance *tracking.AccServerInstance
|
||||
if !exists {
|
||||
@@ -255,23 +269,14 @@ func (s *ServerService) StartAccServerRuntime(server *model.Server) {
|
||||
instance = instanceInterface.(*tracking.AccServerInstance)
|
||||
}
|
||||
|
||||
// Invalidate config cache for this server before loading new configs
|
||||
serverIDStr := server.ID.String()
|
||||
s.configService.configCache.InvalidateServerCache(serverIDStr)
|
||||
|
||||
s.updateSessionDuration(server, instance.State.Session)
|
||||
|
||||
// Ensure log tailing is running (regardless of server status)
|
||||
s.ensureLogTailing(server, instance)
|
||||
}
|
||||
|
||||
// GetAll
|
||||
// Gets All rows from Server table.
|
||||
//
|
||||
// Args:
|
||||
// context.Context: Application context
|
||||
// Returns:
|
||||
// string: Application version
|
||||
func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]model.Server, error) {
|
||||
servers, err := s.repository.GetAll(ctx.UserContext(), filter)
|
||||
if err != nil {
|
||||
@@ -279,6 +284,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Collect server IDs for a single last-activity lookup.
|
||||
serverIDs := make([]uuid.UUID, len(*servers))
|
||||
for i, srv := range *servers {
|
||||
serverIDs[i] = srv.ID
|
||||
}
|
||||
lastActivities, laErr := s.activityLogRepo.GetLastActivityByServerIDs(ctx.UserContext(), serverIDs)
|
||||
if laErr != nil {
|
||||
logging.Error("Failed to get last activities for server list: %v", laErr)
|
||||
}
|
||||
|
||||
for i := range *servers {
|
||||
server := &(*servers)[i]
|
||||
status, err := s.apiService.GetCachedStatus(server.ServiceName)
|
||||
@@ -295,18 +310,16 @@ func (s *ServerService) GetAll(ctx *fiber.Ctx, filter *model.ServerFilter) (*[]m
|
||||
server.State = serverInstance.State
|
||||
}
|
||||
}
|
||||
if lastActivities != nil {
|
||||
if info, ok := lastActivities[server.ID]; ok {
|
||||
server.LastActivity = info
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
// GetById
|
||||
// Gets rows by ID from Server table.
|
||||
//
|
||||
// Args:
|
||||
// context.Context: Application context
|
||||
// Returns:
|
||||
// string: Application version
|
||||
func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Server, error) {
|
||||
server, err := as.repository.GetByID(ctx.UserContext(), serverID)
|
||||
if err != nil {
|
||||
@@ -314,7 +327,7 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
|
||||
}
|
||||
status, err := as.apiService.GetCachedStatus(server.ServiceName)
|
||||
if err != nil {
|
||||
logging.Error(err.Error())
|
||||
logging.Error("Failed to get cached status: %v", err)
|
||||
}
|
||||
server.Status = model.ParseServiceStatus(status)
|
||||
instance, ok := as.instances.Load(server.ID)
|
||||
@@ -330,184 +343,259 @@ func (as *ServerService) GetById(ctx *fiber.Ctx, serverID uuid.UUID) (*model.Ser
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func (s *ServerService) CreateServer(ctx *fiber.Ctx, server *model.Server) error {
|
||||
// Validate basic server configuration
|
||||
func (s *ServerService) CreateServerAsync(ctx *fiber.Ctx, server *model.Server) error {
|
||||
logging.Info("create server start")
|
||||
if err := server.Validate(); err != nil {
|
||||
logging.Info("create server validation failed")
|
||||
return err
|
||||
}
|
||||
|
||||
// Install server using SteamCMD
|
||||
if err := s.steamService.InstallServer(ctx.UserContext(), server.GetServerPath(), &server.ID); err != nil {
|
||||
return fmt.Errorf("failed to install server: %v", err)
|
||||
}
|
||||
s.GenerateServerPath(server)
|
||||
|
||||
// Create Windows service with correct paths
|
||||
execPath := filepath.Join(server.GetServerPath(), "accServer.exe")
|
||||
serverWorkingDir := filepath.Join(server.GetServerPath(), "server")
|
||||
if err := s.windowsService.CreateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil {
|
||||
// Cleanup on failure
|
||||
s.steamService.UninstallServer(server.Path)
|
||||
return fmt.Errorf("failed to create Windows service: %v", err)
|
||||
}
|
||||
bgCtx := context.Background()
|
||||
|
||||
s.configureFirewall(server)
|
||||
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find available ports: %v", err)
|
||||
}
|
||||
|
||||
// Use the first port for both TCP and UDP
|
||||
serverPort := ports[0]
|
||||
tcpPorts := []int{serverPort}
|
||||
udpPorts := []int{serverPort}
|
||||
if err := s.firewallService.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
|
||||
// Cleanup on failure
|
||||
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName)
|
||||
s.steamService.UninstallServer(server.Path)
|
||||
return fmt.Errorf("failed to create firewall rules: %v", err)
|
||||
}
|
||||
|
||||
// Update server configuration with the allocated port
|
||||
if err := s.updateServerPort(server, serverPort); err != nil {
|
||||
return fmt.Errorf("failed to update server configuration: %v", err)
|
||||
}
|
||||
|
||||
// Insert server into database
|
||||
if err := s.repository.Insert(ctx.UserContext(), server); err != nil {
|
||||
// Cleanup on failure
|
||||
s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
|
||||
s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName)
|
||||
s.steamService.UninstallServer(server.Path)
|
||||
return fmt.Errorf("failed to insert server into database: %v", err)
|
||||
}
|
||||
|
||||
// Initialize server runtime
|
||||
s.StartAccServerRuntime(server)
|
||||
go func() {
|
||||
logging.Info("create server start background")
|
||||
if err := s.createServerBackground(bgCtx, server); err != nil {
|
||||
logging.Error("Async server creation failed for server %s: %v", server.ID, err)
|
||||
s.webSocketService.BroadcastError(server.ID, "Server creation failed", err.Error())
|
||||
s.webSocketService.BroadcastComplete(server.ID, false, fmt.Sprintf("Server creation failed: %v", err))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type createServerStep struct {
|
||||
stepType model.ServerCreationStep
|
||||
important bool
|
||||
callback func() (string, error)
|
||||
description string
|
||||
}
|
||||
|
||||
func (s *ServerService) createServerBackground(ctx context.Context, server *model.Server) error {
|
||||
var serverPort int
|
||||
var tcpPorts, udpPorts []int
|
||||
|
||||
steps := []createServerStep{
|
||||
{
|
||||
stepType: model.StepValidation,
|
||||
important: true,
|
||||
description: "Server configuration validated successfully",
|
||||
callback: func() (string, error) {
|
||||
if err := server.Validate(); err != nil {
|
||||
return "", fmt.Errorf("validation failed: %v", err)
|
||||
}
|
||||
return "Server configuration validated successfully", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepDirectoryCreation,
|
||||
important: true,
|
||||
description: "Server directories prepared",
|
||||
callback: func() (string, error) {
|
||||
return "Server directories prepared", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepSteamDownload,
|
||||
important: true,
|
||||
description: "Server files downloaded successfully",
|
||||
callback: func() (string, error) {
|
||||
if err := s.steamService.InstallServerWithWebSocket(ctx, server.Path, &server.ID, s.webSocketService); err != nil {
|
||||
return "", fmt.Errorf("failed to install server: %v", err)
|
||||
}
|
||||
return "Server files downloaded successfully", nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepConfigGeneration,
|
||||
important: true,
|
||||
description: "",
|
||||
callback: func() (string, error) {
|
||||
var port int
|
||||
var err error
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
port, err = s.portPool.Acquire(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to acquire port from pool: %v", err)
|
||||
}
|
||||
} else {
|
||||
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to find available ports: %v", err)
|
||||
}
|
||||
port = ports[0]
|
||||
}
|
||||
|
||||
serverPort = port
|
||||
server.Port = port
|
||||
|
||||
if err := s.updateServerPort(server, serverPort); err != nil {
|
||||
return "", fmt.Errorf("failed to update server configuration: %v", err)
|
||||
}
|
||||
|
||||
if err := s.configService.SetServerName(server, server.Name); err != nil {
|
||||
logging.Error("Failed to set server name in config: %v", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepServiceCreation,
|
||||
important: true,
|
||||
description: "",
|
||||
callback: func() (string, error) {
|
||||
server.Platform = env.GetPlatform()
|
||||
if err := s.runtime.Create(ctx, server); err != nil {
|
||||
return "", fmt.Errorf("failed to create server instance: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("Server instance '%s' created successfully", server.ServiceName), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepFirewallRules,
|
||||
important: false,
|
||||
description: "",
|
||||
callback: func() (string, error) {
|
||||
tcpPorts = []int{serverPort}
|
||||
udpPorts = []int{serverPort}
|
||||
if err := s.firewall.CreateServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
|
||||
return "", fmt.Errorf("failed to create firewall rules: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("Firewall rules configured for port %d", serverPort), nil
|
||||
},
|
||||
},
|
||||
{
|
||||
stepType: model.StepDatabaseSave,
|
||||
important: true,
|
||||
description: "Server saved to database successfully",
|
||||
callback: func() (string, error) {
|
||||
if err := s.repository.Insert(ctx, server); err != nil {
|
||||
return "", fmt.Errorf("failed to insert server into database: %v", err)
|
||||
}
|
||||
return "Server saved to database successfully", nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for i, step := range steps {
|
||||
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusInProgress,
|
||||
model.GetStepDescription(step.stepType), "")
|
||||
|
||||
successMessage, err := step.callback()
|
||||
if err != nil {
|
||||
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusFailed,
|
||||
"", err.Error())
|
||||
|
||||
if step.important {
|
||||
s.rollbackSteps(ctx, server, steps[:i], tcpPorts, udpPorts)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
s.webSocketService.BroadcastStep(server.ID, step.stepType, model.StatusCompleted,
|
||||
successMessage, "")
|
||||
}
|
||||
|
||||
s.StartAccServerRuntime(server)
|
||||
|
||||
s.webSocketService.BroadcastStep(server.ID, model.StepCompleted, model.StatusCompleted,
|
||||
model.GetStepDescription(model.StepCompleted), "")
|
||||
|
||||
s.webSocketService.BroadcastComplete(server.ID, true,
|
||||
fmt.Sprintf("Server '%s' created successfully on port %d", server.Name, serverPort))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerService) rollbackSteps(ctx context.Context, server *model.Server, completedSteps []createServerStep, tcpPorts, udpPorts []int) {
|
||||
for i := len(completedSteps) - 1; i >= 0; i-- {
|
||||
step := completedSteps[i]
|
||||
switch step.stepType {
|
||||
case model.StepDatabaseSave:
|
||||
s.repository.Delete(ctx, server.ID)
|
||||
case model.StepFirewallRules:
|
||||
if len(tcpPorts) > 0 && len(udpPorts) > 0 {
|
||||
s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts)
|
||||
}
|
||||
case model.StepServiceCreation:
|
||||
s.runtime.Delete(ctx, server.ID)
|
||||
case model.StepSteamDownload:
|
||||
s.steamService.UninstallServer(server.Path)
|
||||
case model.StepDirectoryCreation:
|
||||
s.steamService.UninstallServer(server.Path)
|
||||
case model.StepConfigGeneration:
|
||||
if env.IsDockerPlatform() && server.Port > 0 {
|
||||
s.portPool.Release(ctx, server.Port)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ServerService) DeleteServer(ctx *fiber.Ctx, serverID uuid.UUID) error {
|
||||
// Get server details
|
||||
server, err := s.repository.GetByID(ctx.UserContext(), serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get server details: %v", err)
|
||||
}
|
||||
|
||||
// Stop and remove Windows service
|
||||
if err := s.windowsService.DeleteService(ctx.UserContext(), server.ServiceName); err != nil {
|
||||
logging.Error("Failed to delete Windows service: %v", err)
|
||||
if err := s.runtime.Delete(ctx.UserContext(), server.ID); err != nil {
|
||||
logging.Error("Failed to delete server instance: %v", err)
|
||||
}
|
||||
|
||||
// Remove firewall rules
|
||||
configuration, err := s.configService.GetConfiguration(server)
|
||||
if err != nil {
|
||||
logging.Error("Failed to get configuration for server %d: %v", server.ID, err)
|
||||
}
|
||||
tcpPorts := []int{configuration.TcpPort.ToInt()}
|
||||
udpPorts := []int{configuration.UdpPort.ToInt()}
|
||||
if err := s.firewallService.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
|
||||
logging.Error("Failed to delete firewall rules: %v", err)
|
||||
if configuration != nil {
|
||||
tcpPorts := []int{configuration.TcpPort.ToInt()}
|
||||
udpPorts := []int{configuration.UdpPort.ToInt()}
|
||||
if err := s.firewall.DeleteServerRules(server.ServiceName, tcpPorts, udpPorts); err != nil {
|
||||
logging.Error("Failed to delete firewall rules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Uninstall server files
|
||||
if err := s.steamService.UninstallServer(server.Path); err != nil {
|
||||
logging.Error("Failed to uninstall server: %v", err)
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
if env.IsDockerPlatform() && server.Port > 0 {
|
||||
s.portPool.Release(ctx.UserContext(), server.Port)
|
||||
}
|
||||
|
||||
if err := s.repository.Delete(ctx.UserContext(), serverID); err != nil {
|
||||
return fmt.Errorf("failed to delete server from database: %v", err)
|
||||
}
|
||||
|
||||
// Cleanup runtime resources
|
||||
if tailer, exists := s.logTailers.Load(server.ID); exists {
|
||||
tailer.(*tracking.LogTailer).Stop()
|
||||
s.logTailers.Delete(server.ID)
|
||||
}
|
||||
s.logStreamer.Stop(server.ID)
|
||||
s.instances.Delete(server.ID)
|
||||
s.lastInsertTimes.Delete(server.ID)
|
||||
s.debouncers.Delete(server.ID)
|
||||
s.sessionIDs.Delete(server.ID)
|
||||
|
||||
// Invalidate status cache for deleted server
|
||||
s.apiService.statusCache.InvalidateStatus(server.ServiceName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerService) UpdateServer(ctx *fiber.Ctx, server *model.Server) error {
|
||||
// Validate server configuration
|
||||
if err := server.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get existing server details
|
||||
existingServer, err := s.repository.GetByID(ctx.UserContext(), server.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing server details: %v", err)
|
||||
}
|
||||
|
||||
// Update server files if path changed
|
||||
if existingServer.Path != server.Path {
|
||||
if err := s.steamService.InstallServer(ctx.UserContext(), server.Path, &server.ID); err != nil {
|
||||
return fmt.Errorf("failed to install server to new location: %v", err)
|
||||
}
|
||||
// Clean up old installation
|
||||
if err := s.steamService.UninstallServer(existingServer.Path); err != nil {
|
||||
logging.Error("Failed to remove old server installation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update Windows service if necessary
|
||||
if existingServer.ServiceName != server.ServiceName || existingServer.Path != server.Path {
|
||||
execPath := filepath.Join(server.GetServerPath(), "accServer.exe")
|
||||
serverWorkingDir := server.GetServerPath()
|
||||
if err := s.windowsService.UpdateService(ctx.UserContext(), server.ServiceName, execPath, serverWorkingDir, nil); err != nil {
|
||||
return fmt.Errorf("failed to update Windows service: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Update firewall rules if service name changed
|
||||
if existingServer.ServiceName != server.ServiceName {
|
||||
if err := s.configureFirewall(server); err != nil {
|
||||
return fmt.Errorf("failed to update firewall rules: %v", err)
|
||||
}
|
||||
// Invalidate cache for old service name
|
||||
s.apiService.statusCache.InvalidateStatus(existingServer.ServiceName)
|
||||
}
|
||||
|
||||
// Update database record
|
||||
if err := s.repository.Update(ctx.UserContext(), server); err != nil {
|
||||
return fmt.Errorf("failed to update server in database: %v", err)
|
||||
}
|
||||
|
||||
// Restart server runtime
|
||||
s.StartAccServerRuntime(server)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerService) configureFirewall(server *model.Server) error {
|
||||
// Find available ports for the server
|
||||
ports, err := network.FindAvailablePortRange(DefaultStartPort, RequiredPortCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find available ports: %v", err)
|
||||
}
|
||||
|
||||
// Use the first port for both TCP and UDP
|
||||
serverPort := ports[0]
|
||||
tcpPorts := []int{serverPort}
|
||||
udpPorts := []int{serverPort}
|
||||
|
||||
logging.Info("Configuring firewall for server %d with port %d", server.ID, serverPort)
|
||||
|
||||
// Configure firewall rules
|
||||
if err := s.firewallService.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil {
|
||||
if err := s.firewall.UpdateServerRules(server.Name, tcpPorts, udpPorts); err != nil {
|
||||
return fmt.Errorf("failed to configure firewall: %v", err)
|
||||
}
|
||||
|
||||
// Update server configuration with the allocated port
|
||||
if err := s.updateServerPort(server, serverPort); err != nil {
|
||||
return fmt.Errorf("failed to update server configuration: %v", err)
|
||||
}
|
||||
@@ -516,7 +604,6 @@ func (s *ServerService) configureFirewall(server *model.Server) error {
|
||||
}
|
||||
|
||||
func (s *ServerService) updateServerPort(server *model.Server, port int) error {
|
||||
// Load current configuration
|
||||
config, err := s.configService.GetConfiguration(server)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load server configuration: %v", err)
|
||||
@@ -524,11 +611,17 @@ func (s *ServerService) updateServerPort(server *model.Server, port int) error {
|
||||
|
||||
config.TcpPort = model.IntString(port)
|
||||
config.UdpPort = model.IntString(port)
|
||||
config.RegisterToLobby = model.IntString(1)
|
||||
|
||||
// Save the updated configuration
|
||||
if err := s.configService.SaveConfiguration(server, config); err != nil {
|
||||
return fmt.Errorf("failed to save server configuration: %v", err)
|
||||
}
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
if err := s.configService.SetIgnorePrematureDisconnects(server, 0); err != nil {
|
||||
logging.Error("Failed to set ignorePrematureDisconnects for Docker server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
79
local/service/service.go
Normal file → Executable file
79
local/service/service.go
Normal file → Executable file
@@ -1,42 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/platform"
|
||||
"acc-server-manager/local/repository"
|
||||
"acc-server-manager/local/utl/docker"
|
||||
"acc-server-manager/local/utl/env"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"acc-server-manager/local/utl/network"
|
||||
"fmt"
|
||||
|
||||
dockerclient "github.com/docker/docker/client"
|
||||
"go.uber.org/dig"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// InitializeServices
|
||||
// Initializes Dependency Injection modules for services
|
||||
//
|
||||
// Args:
|
||||
// *dig.Container: Dig Container
|
||||
// InitializeServices registers all service providers into the DI container.
|
||||
// Platform-specific components (ServerRuntime, FirewallManager, LogStreamer)
|
||||
// are conditionally provided based on the PLATFORM environment variable.
|
||||
func InitializeServices(c *dig.Container) {
|
||||
logging.Debug("Initializing repositories")
|
||||
repository.InitializeRepositories(c)
|
||||
|
||||
logging.Debug("Registering services")
|
||||
// Provide services
|
||||
c.Provide(NewSteamService)
|
||||
c.Provide(NewActivityLogService)
|
||||
c.Provide(NewServerService)
|
||||
c.Provide(NewStateHistoryService)
|
||||
c.Provide(NewServiceControlService)
|
||||
c.Provide(NewConfigService)
|
||||
c.Provide(NewLookupService)
|
||||
c.Provide(NewWindowsService)
|
||||
c.Provide(NewFirewallService)
|
||||
c.Provide(NewMembershipService)
|
||||
c.Provide(NewWebSocketService)
|
||||
c.Provide(NewLeaderboardService)
|
||||
c.Provide(NewConfigPresetService)
|
||||
|
||||
// Always provide the PortPoolManager; in Windows mode it seeds from an
|
||||
// empty result set and sits idle.
|
||||
c.Provide(func(db *gorm.DB) (*network.PortPoolManager, error) {
|
||||
return network.NewPortPoolManager(db)
|
||||
})
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
registerDockerProviders(c)
|
||||
} else {
|
||||
registerWindowsProviders(c)
|
||||
}
|
||||
|
||||
logging.Debug("Initializing service dependencies")
|
||||
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {
|
||||
logging.Debug("Setting up service cross-references")
|
||||
api.SetServerService(server)
|
||||
config.SetServerService(server)
|
||||
|
||||
})
|
||||
if err != nil {
|
||||
logging.Panic("unable to initialize services: " + err.Error())
|
||||
}
|
||||
logging.Debug("Completed service initialization")
|
||||
}
|
||||
|
||||
func registerWindowsProviders(c *dig.Container) {
|
||||
logging.Debug("Registering Windows platform providers")
|
||||
|
||||
c.Provide(NewWindowsService)
|
||||
|
||||
c.Provide(func(ws *WindowsService, repo *repository.ServerRepository) platform.ServerRuntime {
|
||||
return NewWindowsRuntime(ws, repo)
|
||||
})
|
||||
|
||||
c.Provide(func() platform.FirewallManager {
|
||||
return NewFirewallService()
|
||||
})
|
||||
|
||||
c.Provide(func() platform.LogStreamer {
|
||||
return NewFileLogStreamer()
|
||||
})
|
||||
}
|
||||
|
||||
func registerDockerProviders(c *dig.Container) {
|
||||
logging.Debug("Registering Docker platform providers")
|
||||
|
||||
if err := c.Provide(func() (*dockerclient.Client, error) {
|
||||
client, err := docker.NewDockerClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Docker daemon: %v", err)
|
||||
}
|
||||
return client, nil
|
||||
}); err != nil {
|
||||
logging.Panic("failed to provide Docker client: " + err.Error())
|
||||
}
|
||||
|
||||
c.Provide(func(client *dockerclient.Client, repo *repository.ServerRepository) platform.ServerRuntime {
|
||||
return NewDockerRuntime(client, repo)
|
||||
})
|
||||
|
||||
c.Provide(func() platform.FirewallManager {
|
||||
return &NoOpFirewall{}
|
||||
})
|
||||
|
||||
c.Provide(func(client *dockerclient.Client) platform.LogStreamer {
|
||||
return NewDockerLogStreamer(client)
|
||||
})
|
||||
}
|
||||
|
||||
147
local/service/service_control.go
Normal file → Executable file
147
local/service/service_control.go
Normal file → Executable file
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/platform"
|
||||
"acc-server-manager/local/repository"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -14,24 +15,52 @@ type ServiceControlService struct {
|
||||
repository *repository.ServiceControlRepository
|
||||
serverRepository *repository.ServerRepository
|
||||
serverService *ServerService
|
||||
activityLog *ActivityLogService
|
||||
statusCache *model.ServerStatusCache
|
||||
windowsService *WindowsService
|
||||
runtime platform.ServerRuntime
|
||||
}
|
||||
|
||||
func NewServiceControlService(repository *repository.ServiceControlRepository,
|
||||
serverRepository *repository.ServerRepository) *ServiceControlService {
|
||||
func NewServiceControlService(
|
||||
repository *repository.ServiceControlRepository,
|
||||
serverRepository *repository.ServerRepository,
|
||||
runtime platform.ServerRuntime,
|
||||
activityLog *ActivityLogService,
|
||||
) *ServiceControlService {
|
||||
return &ServiceControlService{
|
||||
repository: repository,
|
||||
serverRepository: serverRepository,
|
||||
runtime: runtime,
|
||||
activityLog: activityLog,
|
||||
statusCache: model.NewServerStatusCache(model.CacheConfig{
|
||||
ExpirationTime: 30 * time.Second, // Cache expires after 30 seconds
|
||||
ThrottleTime: 5 * time.Second, // Minimum 5 seconds between checks
|
||||
DefaultStatus: model.StatusRunning, // Default to running if throttled
|
||||
ExpirationTime: 30 * time.Second,
|
||||
ThrottleTime: 5 * time.Second,
|
||||
DefaultStatus: model.StatusRunning,
|
||||
}),
|
||||
windowsService: NewWindowsService(),
|
||||
}
|
||||
}
|
||||
|
||||
// getServerFromCtx resolves the server from ctx locals (serverId or service name).
|
||||
func (as *ServiceControlService) getServerFromCtx(ctx *fiber.Ctx) (*model.Server, error) {
|
||||
serviceName, ok := ctx.Locals("service").(string)
|
||||
if !ok || serviceName == "" {
|
||||
serverId, ok2 := ctx.Locals("serverId").(string)
|
||||
if !ok2 || serverId == "" {
|
||||
return nil, errors.New("service name missing")
|
||||
}
|
||||
return as.serverRepository.GetByID(ctx.UserContext(), serverId)
|
||||
}
|
||||
return as.serverRepository.GetFirstByServiceName(ctx.UserContext(), serviceName)
|
||||
}
|
||||
|
||||
func extractActor(ctx *fiber.Ctx) (actorID, actorUsername string) {
|
||||
actorID, _ = ctx.Locals("userID").(string)
|
||||
actorUsername, _ = ctx.Locals("actorUsername").(string)
|
||||
if actorUsername == "" {
|
||||
actorUsername = "system"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) SetServerService(serverService *ServerService) {
|
||||
as.serverService = serverService
|
||||
}
|
||||
@@ -42,30 +71,27 @@ func (as *ServiceControlService) GetStatus(ctx *fiber.Ctx) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Try to get status from cache
|
||||
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
// If cache miss or expired, check actual status
|
||||
statusStr, err := as.StatusServer(serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse and update cache with new status
|
||||
status := model.ParseServiceStatus(statusStr)
|
||||
as.statusCache.UpdateStatus(serviceName, status)
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (string, error) {
|
||||
serviceName, err := as.GetServiceName(ctx)
|
||||
server, err := as.getServerFromCtx(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serviceName := server.ServiceName
|
||||
|
||||
// Update status cache for this service before starting
|
||||
as.statusCache.UpdateStatus(serviceName, model.StatusStarting)
|
||||
|
||||
_, err = as.StartServer(serviceName)
|
||||
@@ -77,19 +103,32 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse and update cache with new status
|
||||
status := model.ParseServiceStatus(statusStr)
|
||||
as.statusCache.UpdateStatus(serviceName, status)
|
||||
|
||||
if as.activityLog != nil {
|
||||
actorID, actorUsername := extractActor(ctx)
|
||||
go func() {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &server.ID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionServerStart,
|
||||
Details: `{"service":"` + serviceName + `"}`,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (string, error) {
|
||||
serviceName, err := as.GetServiceName(ctx)
|
||||
server, err := as.getServerFromCtx(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serviceName := server.ServiceName
|
||||
|
||||
// Update status cache for this service before stopping
|
||||
as.statusCache.UpdateStatus(serviceName, model.StatusStopping)
|
||||
|
||||
_, err = as.StopServer(serviceName)
|
||||
@@ -101,19 +140,32 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse and update cache with new status
|
||||
status := model.ParseServiceStatus(statusStr)
|
||||
as.statusCache.UpdateStatus(serviceName, status)
|
||||
|
||||
if as.activityLog != nil {
|
||||
actorID, actorUsername := extractActor(ctx)
|
||||
go func() {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &server.ID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionServerStop,
|
||||
Details: `{"service":"` + serviceName + `"}`,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (string, error) {
|
||||
serviceName, err := as.GetServiceName(ctx)
|
||||
server, err := as.getServerFromCtx(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serviceName := server.ServiceName
|
||||
|
||||
// Update status cache for this service before restarting
|
||||
as.statusCache.UpdateStatus(serviceName, model.StatusRestarting)
|
||||
|
||||
_, err = as.RestartServer(serviceName)
|
||||
@@ -125,74 +177,89 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse and update cache with new status
|
||||
status := model.ParseServiceStatus(statusStr)
|
||||
as.statusCache.UpdateStatus(serviceName, status)
|
||||
|
||||
if as.activityLog != nil {
|
||||
actorID, actorUsername := extractActor(ctx)
|
||||
go func() {
|
||||
_ = as.activityLog.Log(context.Background(), &model.ActivityLog{
|
||||
ServerID: &server.ID,
|
||||
UserID: actorID,
|
||||
Username: actorUsername,
|
||||
Action: model.ActionServerRestart,
|
||||
Details: `{"service":"` + serviceName + `"}`,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) StatusServer(serviceName string) (string, error) {
|
||||
return as.windowsService.Status(context.Background(), serviceName)
|
||||
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return as.runtime.Status(context.Background(), server.ID)
|
||||
}
|
||||
|
||||
// GetCachedStatus gets the cached status for a service name without requiring fiber context
|
||||
func (as *ServiceControlService) GetCachedStatus(serviceName string) (string, error) {
|
||||
// Try to get status from cache
|
||||
if status, shouldCheck := as.statusCache.GetStatus(serviceName); !shouldCheck {
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
// If cache miss or expired, check actual status
|
||||
statusStr, err := as.StatusServer(serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Parse and update cache with new status
|
||||
status := model.ParseServiceStatus(statusStr)
|
||||
as.statusCache.UpdateStatus(serviceName, status)
|
||||
return status.String(), nil
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) StartServer(serviceName string) (string, error) {
|
||||
status, err := as.windowsService.Start(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
status, err := as.runtime.Start(context.Background(), server.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
as.serverService.StartAccServerRuntime(server)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) StopServer(serviceName string) (string, error) {
|
||||
status, err := as.windowsService.Stop(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
as.serverService.instances.Delete(server.ID)
|
||||
|
||||
status, err := as.runtime.Stop(context.Background(), server.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
as.serverService.instances.Delete(server.ID)
|
||||
return status, err
|
||||
}
|
||||
|
||||
func (as *ServiceControlService) RestartServer(serviceName string) (string, error) {
|
||||
status, err := as.windowsService.Restart(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
server, err := as.serverRepository.GetFirstByServiceName(context.Background(), serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
status, err := as.runtime.Restart(context.Background(), server.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
as.serverService.StartAccServerRuntime(server)
|
||||
return status, err
|
||||
}
|
||||
|
||||
11
local/service/service_manager.go
Normal file → Executable file
11
local/service/service_manager.go
Normal file → Executable file
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
type ServiceManager struct {
|
||||
executor *command.CommandExecutor
|
||||
executor *command.CommandExecutor
|
||||
psExecutor *command.CommandExecutor
|
||||
}
|
||||
|
||||
@@ -24,17 +24,14 @@ func NewServiceManager() *ServiceManager {
|
||||
}
|
||||
|
||||
func (s *ServiceManager) ManageService(serviceName, action string) (string, error) {
|
||||
// Run NSSM command through PowerShell to ensure elevation
|
||||
output, err := s.psExecutor.ExecuteWithOutput("-nologo", "-noprofile", ".\\nssm", action, serviceName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Clean up output by removing null bytes and trimming whitespace
|
||||
cleaned := strings.TrimSpace(strings.ReplaceAll(output, "\x00", ""))
|
||||
// Remove \r\n from status strings
|
||||
cleaned = strings.TrimSuffix(cleaned, "\r\n")
|
||||
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
@@ -51,11 +48,9 @@ func (s *ServiceManager) Stop(serviceName string) (string, error) {
|
||||
}
|
||||
|
||||
func (s *ServiceManager) Restart(serviceName string) (string, error) {
|
||||
// First stop the service
|
||||
if _, err := s.Stop(serviceName); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Then start it again
|
||||
return s.Start(serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
6
local/service/state_history.go
Normal file → Executable file
6
local/service/state_history.go
Normal file → Executable file
@@ -46,7 +46,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
|
||||
eg, gCtx := errgroup.WithContext(ctx.UserContext())
|
||||
|
||||
// Get Summary Stats (Peak/Avg Players, Total Sessions)
|
||||
eg.Go(func() error {
|
||||
summary, err := s.repository.GetSummaryStats(gCtx, filter)
|
||||
if err != nil {
|
||||
@@ -61,7 +60,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get Total Playtime
|
||||
eg.Go(func() error {
|
||||
playtime, err := s.repository.GetTotalPlaytime(gCtx, filter)
|
||||
if err != nil {
|
||||
@@ -74,7 +72,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get Player Count Over Time
|
||||
eg.Go(func() error {
|
||||
playerCount, err := s.repository.GetPlayerCountOverTime(gCtx, filter)
|
||||
if err != nil {
|
||||
@@ -87,7 +84,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get Session Types
|
||||
eg.Go(func() error {
|
||||
sessionTypes, err := s.repository.GetSessionTypes(gCtx, filter)
|
||||
if err != nil {
|
||||
@@ -100,7 +96,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get Daily Activity
|
||||
eg.Go(func() error {
|
||||
dailyActivity, err := s.repository.GetDailyActivity(gCtx, filter)
|
||||
if err != nil {
|
||||
@@ -113,7 +108,6 @@ func (s *StateHistoryService) GetStatistics(ctx *fiber.Ctx, filter *model.StateH
|
||||
return nil
|
||||
})
|
||||
|
||||
// Get Recent Sessions
|
||||
eg.Go(func() error {
|
||||
recentSessions, err := s.repository.GetRecentSessions(gCtx, filter)
|
||||
if err != nil {
|
||||
|
||||
261
local/service/steam_service.go
Normal file → Executable file
261
local/service/steam_service.go
Normal file → Executable file
@@ -11,6 +11,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -21,12 +22,11 @@ const (
|
||||
)
|
||||
|
||||
type SteamService struct {
|
||||
executor *command.CommandExecutor
|
||||
interactiveExecutor *command.InteractiveCommandExecutor
|
||||
repository *repository.SteamCredentialsRepository
|
||||
tfaManager *model.Steam2FAManager
|
||||
pathValidator *security.PathValidator
|
||||
downloadVerifier *security.DownloadVerifier
|
||||
executor *command.CommandExecutor
|
||||
repository *repository.SteamCredentialsRepository
|
||||
tfaManager *model.Steam2FAManager
|
||||
pathValidator *security.PathValidator
|
||||
downloadVerifier *security.DownloadVerifier
|
||||
}
|
||||
|
||||
func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManager *model.Steam2FAManager) *SteamService {
|
||||
@@ -36,15 +36,22 @@ func NewSteamService(repository *repository.SteamCredentialsRepository, tfaManag
|
||||
}
|
||||
|
||||
return &SteamService{
|
||||
executor: baseExecutor,
|
||||
interactiveExecutor: command.NewInteractiveCommandExecutor(baseExecutor, tfaManager),
|
||||
repository: repository,
|
||||
tfaManager: tfaManager,
|
||||
pathValidator: security.NewPathValidator(),
|
||||
downloadVerifier: security.NewDownloadVerifier(),
|
||||
executor: baseExecutor,
|
||||
repository: repository,
|
||||
tfaManager: tfaManager,
|
||||
pathValidator: security.NewPathValidator(),
|
||||
downloadVerifier: security.NewDownloadVerifier(),
|
||||
}
|
||||
}
|
||||
|
||||
// getSteamCMDPath returns the platform-appropriate SteamCMD binary path.
|
||||
func getSteamCMDPath() string {
|
||||
if env.IsDockerPlatform() {
|
||||
return env.GetLinuxSteamCMDPath()
|
||||
}
|
||||
return env.GetSteamCMDPath()
|
||||
}
|
||||
|
||||
func (s *SteamService) GetCredentials(ctx context.Context) (*model.SteamCredentials, error) {
|
||||
return s.repository.GetCurrent(ctx)
|
||||
}
|
||||
@@ -57,21 +64,22 @@ func (s *SteamService) SaveCredentials(ctx context.Context, creds *model.SteamCr
|
||||
}
|
||||
|
||||
func (s *SteamService) ensureSteamCMD(_ context.Context) error {
|
||||
// Get SteamCMD path from environment variable
|
||||
// In Docker mode, SteamCMD is pre-installed in the manager container image.
|
||||
if env.IsDockerPlatform() {
|
||||
return nil
|
||||
}
|
||||
|
||||
steamCMDPath := env.GetSteamCMDPath()
|
||||
steamCMDDir := filepath.Dir(steamCMDPath)
|
||||
|
||||
// Check if SteamCMD exists
|
||||
if _, err := os.Stat(steamCMDPath); !os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if err := os.MkdirAll(steamCMDDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create SteamCMD directory: %v", err)
|
||||
}
|
||||
|
||||
// Download and install SteamCMD securely
|
||||
logging.Info("Downloading SteamCMD...")
|
||||
steamCMDZip := filepath.Join(steamCMDDir, "steamcmd.zip")
|
||||
if err := s.downloadVerifier.VerifyAndDownload(
|
||||
@@ -81,106 +89,263 @@ func (s *SteamService) ensureSteamCMD(_ context.Context) error {
|
||||
return fmt.Errorf("failed to download SteamCMD: %v", err)
|
||||
}
|
||||
|
||||
// Extract SteamCMD
|
||||
logging.Info("Extracting SteamCMD...")
|
||||
if err := s.executor.Execute("-Command",
|
||||
fmt.Sprintf("Expand-Archive -Path 'steamcmd.zip' -DestinationPath '%s'", steamCMDDir)); err != nil {
|
||||
return fmt.Errorf("failed to extract SteamCMD: %v", err)
|
||||
}
|
||||
|
||||
// Clean up zip file
|
||||
os.Remove("steamcmd.zip")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SteamService) InstallServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
|
||||
func (s *SteamService) InstallServerWithWebSocket(ctx context.Context, installPath string, serverID *uuid.UUID, wsService *WebSocketService) error {
|
||||
if err := s.ensureSteamCMD(ctx); err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true)
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate installation path for security
|
||||
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
|
||||
return fmt.Errorf("invalid installation path: %v", err)
|
||||
if !env.IsDockerPlatform() {
|
||||
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
|
||||
return fmt.Errorf("invalid installation path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to absolute path and ensure proper Windows path format
|
||||
absPath, err := filepath.Abs(installPath)
|
||||
if err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get absolute path: %v", err), true)
|
||||
return fmt.Errorf("failed to get absolute path: %v", err)
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
// Ensure install path exists
|
||||
if err := os.MkdirAll(absPath, 0755); err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to create install directory: %v", err), true)
|
||||
return fmt.Errorf("failed to create install directory: %v", err)
|
||||
}
|
||||
|
||||
// Get Steam credentials
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false)
|
||||
|
||||
creds, err := s.GetCredentials(ctx)
|
||||
if err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Failed to get Steam credentials: %v", err), true)
|
||||
return fmt.Errorf("failed to get Steam credentials: %v", err)
|
||||
}
|
||||
|
||||
// Get SteamCMD path from environment variable
|
||||
steamCMDPath := env.GetSteamCMDPath()
|
||||
steamCMDPath := getSteamCMDPath()
|
||||
|
||||
// Build SteamCMD command arguments
|
||||
steamCMDArgs := []string{
|
||||
"+force_install_dir", absPath,
|
||||
"+login",
|
||||
}
|
||||
|
||||
if creds != nil && creds.Username != "" {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false)
|
||||
steamCMDArgs = append(steamCMDArgs, creds.Username)
|
||||
if creds.Password != "" {
|
||||
steamCMDArgs = append(steamCMDArgs, creds.Password)
|
||||
}
|
||||
} else {
|
||||
wsService.BroadcastSteamOutput(*serverID, "Using anonymous Steam login", false)
|
||||
steamCMDArgs = append(steamCMDArgs, "anonymous")
|
||||
}
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
|
||||
}
|
||||
|
||||
steamCMDArgs = append(steamCMDArgs,
|
||||
"+app_update", ACCServerAppID,
|
||||
"validate",
|
||||
"+quit",
|
||||
)
|
||||
|
||||
// Build PowerShell arguments to execute SteamCMD directly
|
||||
// This matches the format: powershell -nologo -noprofile c:\steamcmd\steamcmd.exe +args...
|
||||
args := []string{"-nologo", "-noprofile"}
|
||||
args = append(args, steamCMDPath)
|
||||
args = append(args, steamCMDArgs...)
|
||||
args := steamCMDArgs
|
||||
|
||||
// Use interactive executor to handle potential 2FA prompts with timeout
|
||||
logging.Info("Installing ACC server to %s...", absPath)
|
||||
|
||||
// Create a context with timeout to prevent hanging indefinitely
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false)
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := s.interactiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
|
||||
|
||||
callbackConfig := &command.CallbackConfig{
|
||||
OnOutput: func(serverID uuid.UUID, output string, isError bool) {
|
||||
wsService.BroadcastSteamOutput(serverID, output, isError)
|
||||
},
|
||||
OnCommand: func(serverID uuid.UUID, command string, args []string, completed bool, success bool, error string) {
|
||||
if completed {
|
||||
if success {
|
||||
wsService.BroadcastSteamOutput(serverID, "Command completed successfully", false)
|
||||
} else {
|
||||
wsService.BroadcastSteamOutput(serverID, fmt.Sprintf("Command failed: %s", error), true)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
callbackInteractiveExecutor := command.NewCallbackInteractiveCommandExecutor(s.executor, s.tfaManager, callbackConfig, *serverID)
|
||||
callbackInteractiveExecutor.ExePath = steamCMDPath
|
||||
|
||||
if err := callbackInteractiveExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("SteamCMD execution failed: %v", err), true)
|
||||
if timeoutCtx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("SteamCMD operation timed out after 10 minutes")
|
||||
return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required")
|
||||
}
|
||||
return fmt.Errorf("failed to run SteamCMD: %v", err)
|
||||
}
|
||||
|
||||
// Add a delay to allow Steam to properly cleanup
|
||||
logging.Info("Waiting for Steam operations to complete...")
|
||||
wsService.BroadcastSteamOutput(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false)
|
||||
|
||||
wsService.BroadcastSteamOutput(*serverID, "Waiting for Steam operations to complete...", false)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// Verify installation
|
||||
exePath := filepath.Join(absPath, "server", "accServer.exe")
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Checking for ACC server executable at: %s", exePath), false)
|
||||
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("server installation failed: accServer.exe not found in %s", absPath)
|
||||
wsService.BroadcastSteamOutput(*serverID, "accServer.exe not found, checking directory contents...", false)
|
||||
|
||||
if entries, dirErr := os.ReadDir(absPath); dirErr == nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", absPath), false)
|
||||
for _, entry := range entries {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
|
||||
}
|
||||
}
|
||||
|
||||
serverDir := filepath.Join(absPath, "server")
|
||||
if entries, dirErr := os.ReadDir(serverDir); dirErr == nil {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Contents of %s:", serverDir), false)
|
||||
for _, entry := range entries {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
|
||||
}
|
||||
} else {
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr), true)
|
||||
}
|
||||
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation failed: accServer.exe not found in %s", exePath), true)
|
||||
return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath)
|
||||
}
|
||||
|
||||
logging.Info("Server installation completed successfully")
|
||||
wsService.BroadcastSteamOutput(*serverID, fmt.Sprintf("Server installation completed successfully - accServer.exe found at %s", exePath), false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SteamService) UpdateServer(ctx context.Context, installPath string, serverID *uuid.UUID) error {
|
||||
return s.InstallServer(ctx, installPath, serverID) // Same process as install
|
||||
func (s *SteamService) InstallServerWithCallbacks(ctx context.Context, installPath string, serverID *uuid.UUID, outputCallback command.OutputCallback) error {
|
||||
if err := s.ensureSteamCMD(ctx); err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Error ensuring SteamCMD: %v", err), true)
|
||||
return err
|
||||
}
|
||||
|
||||
if !env.IsDockerPlatform() {
|
||||
if err := s.pathValidator.ValidateInstallPath(installPath); err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Invalid installation path: %v", err), true)
|
||||
return fmt.Errorf("invalid installation path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(installPath)
|
||||
if err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Failed to get absolute path: %v", err), true)
|
||||
return fmt.Errorf("failed to get absolute path: %v", err)
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
if err := os.MkdirAll(absPath, 0755); err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Failed to create install directory: %v", err), true)
|
||||
return fmt.Errorf("failed to create install directory: %v", err)
|
||||
}
|
||||
|
||||
outputCallback(*serverID, fmt.Sprintf("Installation directory prepared: %s", absPath), false)
|
||||
|
||||
creds, err := s.GetCredentials(ctx)
|
||||
if err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Failed to get Steam credentials: %v", err), true)
|
||||
return fmt.Errorf("failed to get Steam credentials: %v", err)
|
||||
}
|
||||
|
||||
steamCMDPath := getSteamCMDPath()
|
||||
|
||||
steamCMDArgs := []string{
|
||||
"+force_install_dir", absPath,
|
||||
"+login",
|
||||
}
|
||||
|
||||
if creds != nil && creds.Username != "" {
|
||||
outputCallback(*serverID, fmt.Sprintf("Using Steam credentials for user: %s", creds.Username), false)
|
||||
steamCMDArgs = append(steamCMDArgs, creds.Username)
|
||||
if creds.Password != "" {
|
||||
steamCMDArgs = append(steamCMDArgs, creds.Password)
|
||||
}
|
||||
} else {
|
||||
outputCallback(*serverID, "Using anonymous Steam login", false)
|
||||
steamCMDArgs = append(steamCMDArgs, "anonymous")
|
||||
}
|
||||
|
||||
if env.IsDockerPlatform() {
|
||||
steamCMDArgs = append(steamCMDArgs, "+@sSteamCmdForcePlatformType", "windows")
|
||||
}
|
||||
|
||||
steamCMDArgs = append(steamCMDArgs,
|
||||
"+app_update", ACCServerAppID,
|
||||
"validate",
|
||||
"+quit",
|
||||
)
|
||||
|
||||
args := steamCMDArgs
|
||||
|
||||
outputCallback(*serverID, fmt.Sprintf("Starting SteamCMD: %s %s", steamCMDPath, strings.Join(args, " ")), false)
|
||||
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
callbacks := &command.CallbackConfig{
|
||||
OnOutput: outputCallback,
|
||||
}
|
||||
|
||||
callbackExecutor := command.NewCallbackInteractiveCommandExecutor(s.executor, s.tfaManager, callbacks, *serverID)
|
||||
callbackExecutor.ExePath = steamCMDPath
|
||||
|
||||
if err := callbackExecutor.ExecuteInteractive(timeoutCtx, serverID, args...); err != nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("SteamCMD execution failed: %v", err), true)
|
||||
if timeoutCtx.Err() == context.DeadlineExceeded {
|
||||
return fmt.Errorf("SteamCMD operation timed out after 15 minutes - this usually means Steam Guard confirmation is required")
|
||||
}
|
||||
return fmt.Errorf("failed to run SteamCMD: %v", err)
|
||||
}
|
||||
|
||||
outputCallback(*serverID, "SteamCMD execution completed successfully, proceeding with verification...", false)
|
||||
|
||||
outputCallback(*serverID, "Waiting for Steam operations to complete...", false)
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
exePath := filepath.Join(absPath, "server", "accServer.exe")
|
||||
outputCallback(*serverID, fmt.Sprintf("Checking for ACC server executable at: %s", exePath), false)
|
||||
|
||||
if _, err := os.Stat(exePath); os.IsNotExist(err) {
|
||||
outputCallback(*serverID, "accServer.exe not found, checking directory contents...", false)
|
||||
|
||||
if entries, dirErr := os.ReadDir(absPath); dirErr == nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Contents of %s:", absPath), false)
|
||||
for _, entry := range entries {
|
||||
outputCallback(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
|
||||
}
|
||||
}
|
||||
|
||||
serverDir := filepath.Join(absPath, "server")
|
||||
if entries, dirErr := os.ReadDir(serverDir); dirErr == nil {
|
||||
outputCallback(*serverID, fmt.Sprintf("Contents of %s:", serverDir), false)
|
||||
for _, entry := range entries {
|
||||
outputCallback(*serverID, fmt.Sprintf(" - %s (dir: %v)", entry.Name(), entry.IsDir()), false)
|
||||
}
|
||||
} else {
|
||||
outputCallback(*serverID, fmt.Sprintf("Server directory %s does not exist or cannot be read: %v", serverDir, dirErr), true)
|
||||
}
|
||||
|
||||
outputCallback(*serverID, fmt.Sprintf("Server installation failed: accServer.exe not found in %s", exePath), true)
|
||||
return fmt.Errorf("server installation failed: accServer.exe not found in %s", exePath)
|
||||
}
|
||||
|
||||
outputCallback(*serverID, fmt.Sprintf("Server installation completed successfully - accServer.exe found at %s", exePath), false)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SteamService) UninstallServer(installPath string) error {
|
||||
|
||||
202
local/service/websocket.go
Executable file
202
local/service/websocket.go
Executable file
@@ -0,0 +1,202 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/utl/logging"
|
||||
"encoding/json"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/websocket/v2"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WebSocketConnection struct {
|
||||
conn *websocket.Conn
|
||||
serverID *uuid.UUID
|
||||
userID *uuid.UUID
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type WebSocketService struct {
|
||||
connections sync.Map
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewWebSocketService() *WebSocketService {
|
||||
return &WebSocketService{}
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) AddConnection(connID string, conn *websocket.Conn, userID *uuid.UUID) {
|
||||
wsConn := &WebSocketConnection{
|
||||
conn: conn,
|
||||
userID: userID,
|
||||
}
|
||||
ws.connections.Store(connID, wsConn)
|
||||
logging.Info("WebSocket connection added: %s for user: %v", connID, userID)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) RemoveConnection(connID string) {
|
||||
if conn, exists := ws.connections.LoadAndDelete(connID); exists {
|
||||
if wsConn, ok := conn.(*WebSocketConnection); ok {
|
||||
wsConn.conn.Close()
|
||||
}
|
||||
}
|
||||
logging.Info("WebSocket connection removed: %s", connID)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) SetServerID(connID string, serverID uuid.UUID) {
|
||||
if conn, exists := ws.connections.Load(connID); exists {
|
||||
if wsConn, ok := conn.(*WebSocketConnection); ok {
|
||||
wsConn.serverID = &serverID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastStep(serverID uuid.UUID, step model.ServerCreationStep, status model.StepStatus, message string, errorMsg string) {
|
||||
stepMsg := model.StepMessage{
|
||||
Step: step,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Error: errorMsg,
|
||||
}
|
||||
|
||||
wsMsg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeStep,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: stepMsg,
|
||||
}
|
||||
|
||||
ws.broadcastToServer(serverID, wsMsg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastSteamOutput(serverID uuid.UUID, output string, isError bool) {
|
||||
steamMsg := model.SteamOutputMessage{
|
||||
Output: output,
|
||||
IsError: isError,
|
||||
}
|
||||
|
||||
wsMsg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeSteamOutput,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: steamMsg,
|
||||
}
|
||||
|
||||
ws.broadcastToServer(serverID, wsMsg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastError(serverID uuid.UUID, error string, details string) {
|
||||
errorMsg := model.ErrorMessage{
|
||||
Error: error,
|
||||
Details: details,
|
||||
}
|
||||
|
||||
wsMsg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeError,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: errorMsg,
|
||||
}
|
||||
|
||||
ws.broadcastToServer(serverID, wsMsg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastLogLine(serverID uuid.UUID, line string) {
|
||||
msg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeLogLine,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: model.LogLineMessage{Line: line},
|
||||
}
|
||||
ws.broadcastToServer(serverID, msg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastComplete(serverID uuid.UUID, success bool, message string) {
|
||||
completeMsg := model.CompleteMessage{
|
||||
ServerID: serverID,
|
||||
Success: success,
|
||||
Message: message,
|
||||
}
|
||||
|
||||
wsMsg := model.WebSocketMessage{
|
||||
Type: model.MessageTypeComplete,
|
||||
ServerID: &serverID,
|
||||
Timestamp: time.Now().Unix(),
|
||||
Data: completeMsg,
|
||||
}
|
||||
|
||||
ws.broadcastToServer(serverID, wsMsg)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) broadcastToServer(serverID uuid.UUID, message model.WebSocketMessage) {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
logging.Error("Failed to marshal WebSocket message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
sentToAssociatedConnections := false
|
||||
|
||||
ws.connections.Range(func(key, value interface{}) bool {
|
||||
if wsConn, ok := value.(*WebSocketConnection); ok {
|
||||
if wsConn.serverID != nil && *wsConn.serverID == serverID {
|
||||
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
|
||||
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
|
||||
ws.RemoveConnection(key.(string))
|
||||
} else {
|
||||
sentToAssociatedConnections = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if !sentToAssociatedConnections && (message.Type == model.MessageTypeStep || message.Type == model.MessageTypeError || message.Type == model.MessageTypeComplete) {
|
||||
ws.connections.Range(func(key, value interface{}) bool {
|
||||
if wsConn, ok := value.(*WebSocketConnection); ok {
|
||||
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
|
||||
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
|
||||
ws.RemoveConnection(key.(string))
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) BroadcastToUser(userID uuid.UUID, message model.WebSocketMessage) {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
logging.Error("Failed to marshal WebSocket message: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
ws.connections.Range(func(key, value interface{}) bool {
|
||||
if wsConn, ok := value.(*WebSocketConnection); ok {
|
||||
if wsConn.userID != nil && *wsConn.userID == userID {
|
||||
if err := wsConn.writeMessage(websocket.TextMessage, data); err != nil {
|
||||
logging.Error("Failed to send WebSocket message to connection %s: %v", key, err)
|
||||
ws.RemoveConnection(key.(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (wc *WebSocketConnection) writeMessage(messageType int, data []byte) error {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
return wc.conn.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (ws *WebSocketService) GetActiveConnections() int {
|
||||
count := 0
|
||||
ws.connections.Range(func(key, value interface{}) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
return count
|
||||
}
|
||||
68
local/service/windows_runtime.go
Normal file
68
local/service/windows_runtime.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"acc-server-manager/local/model"
|
||||
"acc-server-manager/local/repository"
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WindowsRuntime struct {
|
||||
windowsService *WindowsService
|
||||
repo *repository.ServerRepository
|
||||
}
|
||||
|
||||
func NewWindowsRuntime(windowsService *WindowsService, repo *repository.ServerRepository) *WindowsRuntime {
|
||||
return &WindowsRuntime{
|
||||
windowsService: windowsService,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Create(ctx context.Context, server *model.Server) error {
|
||||
execPath := filepath.Join(server.GetServerPath(), "accServer.exe")
|
||||
serverWorkingDir := server.GetServerPath()
|
||||
return r.windowsService.CreateService(ctx, server.ServiceName, execPath, serverWorkingDir, nil)
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Delete(ctx context.Context, serverID uuid.UUID) error {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.windowsService.DeleteService(ctx, server.ServiceName)
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Start(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.windowsService.Start(ctx, server.ServiceName)
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Stop(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.windowsService.Stop(ctx, server.ServiceName)
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Restart(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.windowsService.Restart(ctx, server.ServiceName)
|
||||
}
|
||||
|
||||
func (r *WindowsRuntime) Status(ctx context.Context, serverID uuid.UUID) (string, error) {
|
||||
server, err := r.repo.GetByID(ctx, serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return r.windowsService.Status(ctx, server.ServiceName)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user