package server import ( "context" "encoding/json" "errors" "net/http" "strings" "time" "vocat/internal/store" ) const loggingSettingKey = "logs.retention" // loggingConfig is the persisted log retention policy. type loggingConfig struct { Mode string `json:"mode"` // "unlimited" (default) | "count" | "days" Count int `json:"count"` // keep newest N entries when mode is "count" Days int `json:"days"` // keep entries from the last N days when mode is "days" } func defaultLoggingConfig() loggingConfig { return loggingConfig{Mode: "unlimited", Count: 10000, Days: 30} } func parseLoggingConfig(config loggingConfig) (loggingConfig, error) { mode := strings.ToLower(strings.TrimSpace(config.Mode)) if mode == "" { mode = "unlimited" } if mode != "unlimited" && mode != "count" && mode != "days" { return loggingConfig{}, errors.New("mode must be \"unlimited\", \"count\", or \"days\"") } config.Mode = mode if config.Count < 1 { config.Count = 10000 } if config.Days < 1 { config.Days = 30 } return config, nil } // loadLoggingConfig reads the persisted retention policy, defaulting to unlimited. func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig { config := defaultLoggingConfig() setting, err := s.store.AppSetting(ctx, loggingSettingKey) if err == nil { var stored loggingConfig if json.Unmarshal(setting.Value, &stored) == nil { if parsed, parseErr := parseLoggingConfig(stored); parseErr == nil { config = parsed } } } return config } // applyLogRetention enforces the current retention policy against the persisted // log events. The "unlimited" mode prunes nothing. func (s *Server) applyLogRetention(ctx context.Context) error { config := s.loadLoggingConfig(ctx) switch config.Mode { case "days": cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour) _, err := s.store.PruneLogEvents(ctx, cutoff) return err case "count": _, err := s.store.PruneLogEventsToCount(ctx, config.Count) return err default: return nil } } // StartLogRetentionLoop enforces the retention policy once at startup and then // on the given interval until the context is cancelled. func (s *Server) StartLogRetentionLoop(ctx context.Context, interval time.Duration) { if interval <= 0 { interval = time.Minute } if err := s.applyLogRetention(ctx); err != nil { s.logger.Warn("apply log retention failed", "error", err) } ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: if err := s.applyLogRetention(ctx); err != nil { s.logger.Warn("apply log retention failed", "error", err) } } } } // handleLoggingSettings reads and writes the log retention policy. // // GET /api/settings/logging // PUT /api/settings/logging func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: config := s.loadLoggingConfig(r.Context()) stored, err := s.store.CountLogEvents(r.Context()) if err != nil { s.writeStoreError(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "mode": config.Mode, "count": config.Count, "days": config.Days, "stored_logs": stored, }, }) case http.MethodPut: var request loggingConfig if err := s.decodeJSON(w, r, &request); err != nil { writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) return } config, err := parseLoggingConfig(request) if err != nil { writeError(w, http.StatusBadRequest, "invalid_logging_policy", err.Error()) return } payload, err := json.Marshal(config) if err != nil { writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") return } if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ Key: loggingSettingKey, Value: payload, }); err != nil { s.writeStoreError(w, err) return } s.audit(r, "settings.logging.update", "settings", "logging", "success") if err := s.applyLogRetention(r.Context()); err != nil { s.logger.Warn("apply log retention failed", "error", err) } stored, _ := s.store.CountLogEvents(r.Context()) writeJSON(w, http.StatusOK, map[string]any{ "data": map[string]any{ "mode": config.Mode, "count": config.Count, "days": config.Days, "stored_logs": stored, }, }) default: w.Header().Set("Allow", "GET, PUT") writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") } }