Compare commits

...

10 Commits

Author SHA1 Message Date
1100072245 feat: sync server name when updating all configs 2026-03-22 13:22:46 +01:00
49dfa38475 feat: sync server name with config 2026-03-22 13:19:43 +01:00
747adc77a5 version bump
Some checks failed
Release and Deploy / deploy (push) Has been skipped
Release and Deploy / build (push) Failing after 6m7s
2026-03-22 12:24:54 +01:00
c9a32e8889 feat: batch update config endpoint 2026-03-22 12:16:40 +01:00
7d470f3638 update headers 2026-03-05 21:50:05 +01:00
cf7473b373 update models to use uuid referencing
Some checks failed
Release and Deploy / build (push) Failing after 6m58s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 16:46:06 +01:00
35c53cc54c public endpoint for leaderboard 2026-03-01 14:35:02 +01:00
016824e846 Merge branch 'feature/leaderboard'
Some checks failed
Release and Deploy / build (push) Failing after 7m6s
Release and Deploy / deploy (push) Has been skipped
2026-03-01 12:18:01 +01:00
d62c8cef34 add build scripts and resolve migration
Some checks failed
Release and Deploy / build (push) Failing after 8m24s
Release and Deploy / deploy (push) Has been skipped
2026-02-22 17:49:44 +01:00
190e1a3a16 add leaderboard 2026-02-22 12:28:48 +01:00
20 changed files with 783 additions and 35 deletions

View File

@@ -32,6 +32,7 @@ func Init(di *dig.Container, app *fiber.App) {
Membership: groups.Group("/membership"), Membership: groups.Group("/membership"),
System: groups.Group("/system"), System: groups.Group("/system"),
WebSocket: groups.Group("/ws"), WebSocket: groups.Group("/ws"),
Leaderboard: serverIdGroup.Group("/leaderboard"),
} }
accessKeyMiddleware := middleware.NewAccessKeyMiddleware() accessKeyMiddleware := middleware.NewAccessKeyMiddleware()

View File

@@ -2,6 +2,7 @@ package controller
import ( import (
"acc-server-manager/local/middleware" "acc-server-manager/local/middleware"
"acc-server-manager/local/model"
"acc-server-manager/local/service" "acc-server-manager/local/service"
"acc-server-manager/local/utl/common" "acc-server-manager/local/utl/common"
"acc-server-manager/local/utl/error_handler" "acc-server-manager/local/utl/error_handler"
@@ -34,6 +35,7 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
configGroup := routeGroups.Config configGroup := routeGroups.Config
configGroup.Use(auth.Authenticate) configGroup.Use(auth.Authenticate)
configGroup.Patch("/", ac.UpdateAllConfigs)
configGroup.Put("/:file", ac.UpdateConfig) configGroup.Put("/:file", ac.UpdateConfig)
configGroup.Get("/:file", ac.GetConfig) configGroup.Get("/:file", ac.GetConfig)
configGroup.Get("/", ac.GetConfigs) configGroup.Get("/", ac.GetConfigs)
@@ -41,6 +43,54 @@ func NewConfigController(as *service.ConfigService, routeGroups *common.RouteGro
return ac 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 // updateConfig returns Config
// //
// @Summary Update server configuration file // @Summary Update server configuration file

View File

@@ -59,4 +59,9 @@ func InitializeControllers(c *dig.Container) {
if err != nil { if err != nil {
logging.Panic("unable to initialize websocket controller") logging.Panic("unable to initialize websocket controller")
} }
err = c.Invoke(NewLeaderboardController)
if err != nil {
logging.Panic("unable to initialize leaderboard controller")
}
} }

View File

@@ -0,0 +1,69 @@
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)
}
data, err := lc.service.Update(c.UserContext(), serverID, &input)
if err != nil {
return lc.errorHandler.HandleServiceError(c, err)
}
return c.JSON(data)
}

View 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()
}

View File

@@ -40,6 +40,14 @@ type Configurations struct {
Settings ServerSettings `json:"settings"` 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 { type ServerSettings struct {
ServerName string `json:"serverName"` ServerName string `json:"serverName"`
AdminPassword string `json:"adminPassword"` AdminPassword string `json:"adminPassword"`

113
local/model/leaderboard.go Normal file
View 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
}

View 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)
}

View File

@@ -19,6 +19,7 @@ func InitializeRepositories(c *dig.Container) {
c.Provide(NewLookupRepository) c.Provide(NewLookupRepository)
c.Provide(NewSteamCredentialsRepository) c.Provide(NewSteamCredentialsRepository)
c.Provide(NewMembershipRepository) c.Provide(NewMembershipRepository)
c.Provide(NewLeaderboardRepository)
if err := c.Provide(func() *model.Steam2FAManager { if err := c.Provide(func() *model.Steam2FAManager {
manager := model.NewSteam2FAManager() manager := model.NewSteam2FAManager()

View File

@@ -91,6 +91,72 @@ func (as *ConfigService) SetServerService(serverService *ServerService) {
// context.Context: Application context // context.Context: Application context
// Returns: // Returns:
// string: Application version // 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},
}
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 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) { func (as *ConfigService) UpdateConfig(ctx *fiber.Ctx, body *map[string]interface{}) (*model.Config, error) {
serverID := ctx.Locals("serverId").(string) serverID := ctx.Locals("serverId").(string)
configFile := ctx.Params("file") configFile := ctx.Params("file")
@@ -173,6 +239,17 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri
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)
}
}
}
}
as.configCache.InvalidateServerCache(serverID) as.configCache.InvalidateServerCache(serverID)
as.serverService.StartAccServerRuntime(server) as.serverService.StartAccServerRuntime(server)
@@ -433,3 +510,11 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C
_, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true) _, _, err = as.updateConfigFiles(context.Background(), server, ConfigurationJson, &configMap, true)
return err return err
} }
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
}

View File

@@ -0,0 +1,37 @@
package service
import (
"acc-server-manager/local/model"
"acc-server-manager/local/repository"
"context"
"fmt"
"github.com/google/uuid"
)
type LeaderboardService struct {
repo *repository.LeaderboardRepository
}
func NewLeaderboardService(repo *repository.LeaderboardRepository) *LeaderboardService {
return &LeaderboardService{repo: repo}
}
// 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) (*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
}
return s.repo.FullReplace(ctx, serverID, lb)
}

View File

@@ -388,6 +388,10 @@ func (s *ServerService) createServerBackground(ctx context.Context, server *mode
return "", fmt.Errorf("failed to update server configuration: %v", err) 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 return fmt.Sprintf("Server configuration generated (Port: %d)", serverPort), nil
}, },
}, },

View File

@@ -23,6 +23,7 @@ func InitializeServices(c *dig.Container) {
c.Provide(NewFirewallService) c.Provide(NewFirewallService)
c.Provide(NewMembershipService) c.Provide(NewMembershipService)
c.Provide(NewWebSocketService) c.Provide(NewWebSocketService)
c.Provide(NewLeaderboardService)
logging.Debug("Initializing service dependencies") logging.Debug("Initializing service dependencies")
err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) { err := c.Invoke(func(server *ServerService, api *ServiceControlService, config *ConfigService) {

View File

@@ -26,6 +26,7 @@ type RouteGroups struct {
Membership fiber.Router Membership fiber.Router
System fiber.Router System fiber.Router
WebSocket fiber.Router WebSocket fiber.Router
Leaderboard fiber.Router
} }
func CheckError(err error) { func CheckError(err error) {

View File

@@ -8,7 +8,7 @@ import (
) )
var ( var (
Version = "0.10.7" Version = "0.12.3"
Prefix = "v1" Prefix = "v1"
Secret string Secret string
SecretCode string SecretCode string

View File

@@ -33,6 +33,8 @@ func Start(di *dig.Container) {
func Migrate(db *gorm.DB) { func Migrate(db *gorm.DB) {
logging.Info("Migrating database") logging.Info("Migrating database")
runMigrations(db)
err := db.AutoMigrate( err := db.AutoMigrate(
&model.ServiceControlModel{}, &model.ServiceControlModel{},
&model.Config{}, &model.Config{},
@@ -47,6 +49,11 @@ func Migrate(db *gorm.DB) {
&model.User{}, &model.User{},
&model.Role{}, &model.Role{},
&model.Permission{}, &model.Permission{},
&model.Leaderboard{},
&model.LeaderboardDriver{},
&model.LeaderboardRace{},
&model.LeaderboardResult{},
&model.LeaderboardPointRow{},
) )
if err != nil { if err != nil {
@@ -61,6 +68,10 @@ func Migrate(db *gorm.DB) {
func runMigrations(db *gorm.DB) { func runMigrations(db *gorm.DB) {
logging.Info("Running custom database migrations...") logging.Info("Running custom database migrations...")
if err := migrations.RunFixDDLCommentsMigration(db); err != nil {
logging.Error("Failed to run DDL comment fix migration: %v", err)
}
if err := migrations.RunPasswordSecurityMigration(db); err != nil { if err := migrations.RunPasswordSecurityMigration(db); err != nil {
logging.Error("Failed to run password security migration: %v", err) logging.Error("Failed to run password security migration: %v", err)
} }

2
local/utl/server/server.go Normal file → Executable file
View File

@@ -44,7 +44,7 @@ func Start(di *dig.Container) *fiber.App {
app.Use(cors.New(cors.Config{ app.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigin, AllowOrigins: allowedOrigin,
AllowHeaders: "Origin, Content-Type, Accept, Authorization", AllowHeaders: "Origin, Content-Type, Accept, Authorization, Access-Token",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS", AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
AllowCredentials: true, AllowCredentials: true,
MaxAge: 86400, // 24 hours MaxAge: 86400, // 24 hours

57
scripts/build.ps1 Normal file
View File

@@ -0,0 +1,57 @@
param(
[string]$BinaryName = "acc-server-manager",
[string]$MigrateBinaryName = "acc-migrate",
[string]$GOOS = "windows",
[string]$GOARCH = "amd64",
[string]$OutputPath = ".\build",
[switch]$SkipTests
)
$ErrorActionPreference = "Stop"
function Write-Status {
param([string]$Message, [string]$Color = "Green")
Write-Host "[$(Get-Date -Format 'HH:mm:ss')] $Message" -ForegroundColor $Color
}
# Check if Go is installed
if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
throw "Go is not installed or not in PATH"
}
# Clean output directory
if (Test-Path $OutputPath) {
Remove-Item -Path $OutputPath -Recurse -Force
}
New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null
# Run tests
if (-not $SkipTests) {
Write-Status "Running tests..."
& go test ./...
if ($LASTEXITCODE -ne 0) {
throw "Tests failed"
}
}
# Set build environment
$env:GOOS = $GOOS
$env:GOARCH = $GOARCH
$ext = if ($GOOS -eq "windows") { ".exe" } else { "" }
# Build API binary
Write-Status "Building API binary ($GOOS/$GOARCH)..."
& go build -o "$OutputPath\$BinaryName$ext" .\cmd\api
if ($LASTEXITCODE -ne 0) { throw "Failed to build API binary" }
# Build migration binary
Write-Status "Building migration binary ($GOOS/$GOARCH)..."
& go build -o "$OutputPath\$MigrateBinaryName$ext" .\cmd\migrate
if ($LASTEXITCODE -ne 0) { throw "Failed to build migration binary" }
# Reset environment
Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
Write-Status "Build completed -> $OutputPath"

79
scripts/build.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
BINARY_NAME="acc-server-manager"
MIGRATE_BINARY_NAME="acc-migrate"
GOOS="windows"
GOARCH="amd64"
OUTPUT_PATH="./build"
SKIP_TESTS=false
usage() {
cat <<EOF
Usage: $0 [options]
Options:
--binary-name <name> Main binary name (default: acc-server-manager)
--migrate-binary-name <n> Migration binary name (default: acc-migrate)
--goos <os> Target OS (default: windows)
--goarch <arch> Target arch (default: amd64)
--output-path <path> Output directory (default: ./build)
--skip-tests Skip running tests
-h, --help Show this help
EOF
}
log() { echo "[$(date '+%H:%M:%S')] $*"; }
die() { echo "[$(date '+%H:%M:%S')] ERROR: $*" >&2; exit 1; }
while [[ $# -gt 0 ]]; do
case $1 in
--binary-name) BINARY_NAME="$2"; shift 2 ;;
--migrate-binary-name) MIGRATE_BINARY_NAME="$2"; shift 2 ;;
--goos) GOOS="$2"; shift 2 ;;
--goarch) GOARCH="$2"; shift 2 ;;
--output-path) OUTPUT_PATH="$2"; shift 2 ;;
--skip-tests) SKIP_TESTS=true; shift ;;
-h|--help) usage; exit 0 ;;
*) die "Unknown option: $1" ;;
esac
done
command -v go >/dev/null 2>&1 || die "Go is not installed or not in PATH"
# Clean output directory
rm -rf "$OUTPUT_PATH"
mkdir -p "$OUTPUT_PATH"
# Run tests on host before setting cross-compilation environment
if [[ "$SKIP_TESTS" == false ]]; then
log "Running tests..."
go test ./...
fi
# CGo cross-compilation setup (set after tests so they run natively)
HOST_OS="$(go env GOHOSTOS)"
if [[ "$GOOS" == "windows" && "$HOST_OS" != "windows" ]]; then
command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1 \
|| die "mingw-w64 not found. Install it with: sudo apt install gcc-mingw-w64-x86-64"
export CC=x86_64-w64-mingw32-gcc
export CGO_ENABLED=1
log "Cross-compiling for Windows (CC=$CC)"
fi
EXT=""
[[ "$GOOS" == "windows" ]] && EXT=".exe"
export GOOS GOARCH
# Build API binary
log "Building API binary ($GOOS/$GOARCH)..."
go build -o "$OUTPUT_PATH/$BINARY_NAME$EXT" ./cmd/api
# Build migration binary
log "Building migration binary ($GOOS/$GOARCH)..."
go build -o "$OUTPUT_PATH/$MIGRATE_BINARY_NAME$EXT" ./cmd/migrate
unset GOOS GOARCH CC CGO_ENABLED
log "Build completed -> $OUTPUT_PATH"

View File

@@ -61,44 +61,20 @@ function Build-Application {
Write-Status "Building application..." Write-Status "Building application..."
# Clean build directory $buildScript = Join-Path $PSScriptRoot "build.ps1"
if (Test-Path $LocalBuildPath) { $buildArgs = @(
Remove-Item -Path $LocalBuildPath -Recurse -Force "-BinaryName", $BinaryName,
} "-MigrateBinaryName", $MigrateBinaryName,
New-Item -ItemType Directory -Path $LocalBuildPath -Force | Out-Null "-OutputPath", $LocalBuildPath
)
if ($SkipTests) { $buildArgs += "-SkipTests" }
# Run tests & $buildScript @buildArgs
if (-not $SkipTests) {
Write-Status "Running tests..."
& go test -v ./...
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
throw "Tests failed" throw "Build failed"
} }
} }
# Build API for Windows
Write-Status "Building API binary..."
$env:GOOS = "windows"
$env:GOARCH = "amd64"
& go build -o "$LocalBuildPath\$BinaryName.exe" .\cmd\api
if ($LASTEXITCODE -ne 0) {
throw "Failed to build API binary"
}
# Build migration tool for Windows
Write-Status "Building migration binary..."
& go build -o "$LocalBuildPath\$MigrateBinaryName.exe" .\cmd\migrate
if ($LASTEXITCODE -ne 0) {
throw "Failed to build migration binary"
}
# Reset environment variables
Remove-Item Env:\GOOS -ErrorAction SilentlyContinue
Remove-Item Env:\GOARCH -ErrorAction SilentlyContinue
Write-Status "Build completed successfully"
}
function Copy-FilesToServer { function Copy-FilesToServer {
Write-Status "Copying files to server..." Write-Status "Copying files to server..."