From 180555c90334eb20f39ea4681aad8effe0e08354 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 21:45:15 +0100 Subject: [PATCH 1/3] feat: config presets --- local/api/api.go | 1 + local/controller/config_preset.go | 240 +++++++++++++++++++++ local/controller/controller.go | 5 + local/migrations/007_add_config_presets.go | 51 +++++ local/model/config_preset.go | 48 +++++ local/model/filter.go | 12 ++ local/model/permissions.go | 9 + local/repository/config_preset.go | 17 ++ local/repository/repository.go | 1 + local/service/config.go | 6 + local/service/config_preset.go | 176 +++++++++++++++ local/service/service.go | 1 + local/utl/common/common.go | 1 + local/utl/db/db.go | 5 + 14 files changed, 573 insertions(+) create mode 100644 local/controller/config_preset.go create mode 100644 local/migrations/007_add_config_presets.go create mode 100644 local/model/config_preset.go create mode 100644 local/repository/config_preset.go create mode 100644 local/service/config_preset.go diff --git a/local/api/api.go b/local/api/api.go index aca6eb3..d98697f 100755 --- a/local/api/api.go +++ b/local/api/api.go @@ -37,6 +37,7 @@ func Init(di *dig.Container, app *fiber.App) { ActivityLog: serverIdGroup.Group("/activity-log"), Groups: groups, Log: serverIdGroup.Group("/log"), + Presets: groups.Group("/presets"), } accessKeyMiddleware := middleware.NewAccessKeyMiddleware() diff --git a/local/controller/config_preset.go b/local/controller/config_preset.go new file mode 100644 index 0000000..7fb12e5 --- /dev/null +++ b/local/controller/config_preset.go @@ -0,0 +1,240 @@ +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) + } + + preset, err := c.service.CreatePreset(ctx.UserContext(), &req) + 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) +} diff --git a/local/controller/controller.go b/local/controller/controller.go index 9984e65..185d511 100755 --- a/local/controller/controller.go +++ b/local/controller/controller.go @@ -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") diff --git a/local/migrations/007_add_config_presets.go b/local/migrations/007_add_config_presets.go new file mode 100644 index 0000000..574e102 --- /dev/null +++ b/local/migrations/007_add_config_presets.go @@ -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 +} diff --git a/local/model/config_preset.go b/local/model/config_preset.go new file mode 100644 index 0000000..fe62b36 --- /dev/null +++ b/local/model/config_preset.go @@ -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"` +} diff --git a/local/model/filter.go b/local/model/filter.go index b9df064..138afd0 100755 --- a/local/model/filter.go +++ b/local/model/filter.go @@ -30,6 +30,18 @@ type ConfigFilter struct { ChangedAt time.Time `query:"changed_at" time_format:"2006-01-02T15:04:05Z07:00"` } +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"` diff --git a/local/model/permissions.go b/local/model/permissions.go index d57c097..8748d25 100755 --- a/local/model/permissions.go +++ b/local/model/permissions.go @@ -24,6 +24,11 @@ const ( MembershipCreate = "membership.create" MembershipView = "membership.view" MembershipEdit = "membership.edit" + + PresetView = "preset.view" + PresetCreate = "preset.create" + PresetUpdate = "preset.update" + PresetDelete = "preset.delete" ) func AllPermissions() []string { @@ -47,5 +52,9 @@ func AllPermissions() []string { MembershipCreate, MembershipView, MembershipEdit, + PresetView, + PresetCreate, + PresetUpdate, + PresetDelete, } } diff --git a/local/repository/config_preset.go b/local/repository/config_preset.go new file mode 100644 index 0000000..264c03a --- /dev/null +++ b/local/repository/config_preset.go @@ -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{}), + } +} diff --git a/local/repository/repository.go b/local/repository/repository.go index 66833c9..6305709 100755 --- a/local/repository/repository.go +++ b/local/repository/repository.go @@ -21,6 +21,7 @@ func InitializeRepositories(c *dig.Container) { c.Provide(NewMembershipRepository) c.Provide(NewLeaderboardRepository) c.Provide(NewActivityLogRepository) + c.Provide(NewConfigPresetRepository) if err := c.Provide(func() *model.Steam2FAManager { manager := model.NewSteam2FAManager() diff --git a/local/service/config.go b/local/service/config.go index 0bc67d0..68f593f 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -561,6 +561,12 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C 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, false, actorID, actorUsername) +} + func (as *ConfigService) SetServerName(server *model.Server, name string) error { configMap := map[string]interface{}{ "serverName": name, diff --git a/local/service/config_preset.go b/local/service/config_preset.go new file mode 100644 index 0000000..0b7d2e6 --- /dev/null +++ b/local/service/config_preset.go @@ -0,0 +1,176 @@ +package service + +import ( + "acc-server-manager/local/model" + "acc-server-manager/local/repository" + "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 +} + +func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService) *ConfigPresetService { + return &ConfigPresetService{ + repository: repo, + configService: configService, + } +} + +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) (*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 + } + 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 + } + + 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) + } + } + + 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 +} diff --git a/local/service/service.go b/local/service/service.go index 2f51d4e..43271b0 100755 --- a/local/service/service.go +++ b/local/service/service.go @@ -32,6 +32,7 @@ func InitializeServices(c *dig.Container) { 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. diff --git a/local/utl/common/common.go b/local/utl/common/common.go index 7c844f1..47f963a 100755 --- a/local/utl/common/common.go +++ b/local/utl/common/common.go @@ -31,6 +31,7 @@ type RouteGroups struct { ActivityLog fiber.Router Groups fiber.Router Log fiber.Router + Presets fiber.Router } func CheckError(err error) { diff --git a/local/utl/db/db.go b/local/utl/db/db.go index aefcf60..5a02000 100755 --- a/local/utl/db/db.go +++ b/local/utl/db/db.go @@ -55,6 +55,7 @@ func Migrate(db *gorm.DB) { &model.LeaderboardResult{}, &model.LeaderboardPointRow{}, &model.ActivityLog{}, + &model.ConfigPreset{}, ) if err != nil { @@ -85,6 +86,10 @@ func runMigrations(db *gorm.DB) { logging.Error("Failed to run activity log migration: %v", err) } + if err := migrations.RunAddConfigPresetsMigration(db); err != nil { + logging.Error("Failed to run config presets migration: %v", err) + } + logging.Info("Custom database migrations completed") } From f23f276ee5a745ae0f63ef8f58436586c98812dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 22:32:24 +0100 Subject: [PATCH 2/3] fix: preset logging --- local/model/activity_log.go | 2 ++ local/service/config.go | 2 +- local/service/config_preset.go | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/local/model/activity_log.go b/local/model/activity_log.go index b925b1a..e23523b 100644 --- a/local/model/activity_log.go +++ b/local/model/activity_log.go @@ -15,6 +15,8 @@ const ( ActionServerStop ActionType = "server_stop" ActionServerRestart ActionType = "server_restart" ActionLeaderboardUpdate ActionType = "leaderboard_update" + ActionPresetCreate ActionType = "preset_create" + ActionPresetApply ActionType = "preset_apply" ) type ActivityLog struct { diff --git a/local/service/config.go b/local/service/config.go index 68f593f..95601b4 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -564,7 +564,7 @@ func (as *ConfigService) SaveConfiguration(server *model.Server, config *model.C // 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, false, actorID, actorUsername) + return as.updateConfigInternal(ctx, serverID, configFile, body, true, actorID, actorUsername) } func (as *ConfigService) SetServerName(server *model.Server, name string) error { diff --git a/local/service/config_preset.go b/local/service/config_preset.go index 0b7d2e6..be0f4c4 100644 --- a/local/service/config_preset.go +++ b/local/service/config_preset.go @@ -3,6 +3,7 @@ package service import ( "acc-server-manager/local/model" "acc-server-manager/local/repository" + "acc-server-manager/local/utl/logging" "context" "encoding/json" "fmt" @@ -19,12 +20,14 @@ const ( type ConfigPresetService struct { repository *repository.ConfigPresetRepository configService *ConfigService + activityLog *ActivityLogService } -func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService) *ConfigPresetService { +func NewConfigPresetService(repo *repository.ConfigPresetRepository, configService *ConfigService, activityLog *ActivityLogService) *ConfigPresetService { return &ConfigPresetService{ repository: repo, configService: configService, + activityLog: activityLog, } } @@ -66,6 +69,8 @@ func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.Confi if err := s.repository.Insert(ctx, preset); err != nil { return nil, err } + + logging.Info("Preset created: id=%s name=%q", preset.ID, preset.Name) return preset, nil } @@ -110,6 +115,11 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, 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 @@ -141,6 +151,19 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, } } + 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 } From f2580b6de0532c720a5ff693194ca292324fe295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=20Jurmanovi=C4=87?= Date: Thu, 26 Mar 2026 22:48:59 +0100 Subject: [PATCH 3/3] fix: nullable server id in activity log and fix preset logging --- local/controller/config_preset.go | 8 +++++++- local/model/activity_log.go | 2 +- local/service/config.go | 4 ++-- local/service/config_preset.go | 23 +++++++++++++++++------ local/service/leaderboard.go | 2 +- local/service/service_control.go | 6 +++--- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/local/controller/config_preset.go b/local/controller/config_preset.go index 7fb12e5..fb792b1 100644 --- a/local/controller/config_preset.go +++ b/local/controller/config_preset.go @@ -117,7 +117,13 @@ func (c *ConfigPresetController) CreatePreset(ctx *fiber.Ctx) error { return c.errorHandler.HandleParsingError(ctx, err) } - preset, err := c.service.CreatePreset(ctx.UserContext(), &req) + 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) } diff --git a/local/model/activity_log.go b/local/model/activity_log.go index e23523b..66c79e8 100644 --- a/local/model/activity_log.go +++ b/local/model/activity_log.go @@ -21,7 +21,7 @@ const ( type ActivityLog struct { ID uuid.UUID `json:"id" gorm:"type:uuid;primary_key;"` - ServerID uuid.UUID `json:"serverId" gorm:"not null;type:uuid;index"` + 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"` diff --git a/local/service/config.go b/local/service/config.go index 95601b4..84ddeaa 100755 --- a/local/service/config.go +++ b/local/service/config.go @@ -160,7 +160,7 @@ func (as *ConfigService) UpdateAllConfigs(ctx *fiber.Ctx, req *model.Configurati if details := BuildConfigDetails(entry.fileName, oldDataUTF8, newData); details != "" { go func(details string) { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionConfigUpdate, @@ -285,7 +285,7 @@ func (as *ConfigService) updateConfigInternal(ctx context.Context, serverID stri if details := BuildConfigDetails(configFile, oldDataUTF8, newData); details != "" { go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverUUID, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionConfigUpdate, diff --git a/local/service/config_preset.go b/local/service/config_preset.go index be0f4c4..caada5d 100644 --- a/local/service/config_preset.go +++ b/local/service/config_preset.go @@ -12,9 +12,9 @@ import ( ) const ( - presetMaxNameLen = 100 - presetMaxDescLen = 500 - presetMaxSectionBytes = 64 * 1024 // 64 KB per config section + presetMaxNameLen = 100 + presetMaxDescLen = 500 + presetMaxSectionBytes = 64 * 1024 // 64 KB per config section ) type ConfigPresetService struct { @@ -46,7 +46,7 @@ func (s *ConfigPresetService) GetPreset(ctx context.Context, id uuid.UUID) (*mod return preset, nil } -func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.ConfigPresetCreateRequest) (*model.ConfigPreset, error) { +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") } @@ -70,7 +70,18 @@ func (s *ConfigPresetService) CreatePreset(ctx context.Context, req *model.Confi return nil, err } - logging.Info("Preset created: id=%s name=%q", preset.ID, preset.Name) + 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 } @@ -154,7 +165,7 @@ func (s *ConfigPresetService) ApplyPreset(ctx context.Context, serverID string, 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, + ServerID: &serverUUID, UserID: actorID, Username: actorUsername, Action: model.ActionPresetApply, diff --git a/local/service/leaderboard.go b/local/service/leaderboard.go index f43437d..c1407e0 100755 --- a/local/service/leaderboard.go +++ b/local/service/leaderboard.go @@ -45,7 +45,7 @@ func (s *LeaderboardService) Update(ctx context.Context, serverID uuid.UUID, lb details := fmt.Sprintf(`{"driver_count":%d,"race_count":%d}`, driverCount, raceCount) go func() { if logErr := s.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: serverID, + ServerID: &serverID, UserID: actorID, Username: actorUsername, Action: model.ActionLeaderboardUpdate, diff --git a/local/service/service_control.go b/local/service/service_control.go index 89ad683..06dc391 100755 --- a/local/service/service_control.go +++ b/local/service/service_control.go @@ -110,7 +110,7 @@ func (as *ServiceControlService) ServiceControlStartServer(ctx *fiber.Ctx) (stri actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerStart, @@ -147,7 +147,7 @@ func (as *ServiceControlService) ServiceControlStopServer(ctx *fiber.Ctx) (strin actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerStop, @@ -184,7 +184,7 @@ func (as *ServiceControlService) ServiceControlRestartServer(ctx *fiber.Ctx) (st actorID, actorUsername := extractActor(ctx) go func() { _ = as.activityLog.Log(context.Background(), &model.ActivityLog{ - ServerID: server.ID, + ServerID: &server.ID, UserID: actorID, Username: actorUsername, Action: model.ActionServerRestart,