// Package state manages daemon state persistence. package state import ( "context" "encoding/json" "fmt" "log/slog" "os" "path/filepath" "time" "git.eeqj.de/sneak/ipapi/internal/config" ) const ( stateFileName = "daemon.json" dirPermissions = 0750 filePermissions = 0600 ) // State holds the daemon's persistent state information. type State struct { LastASNDownload time.Time `json:"lastAsnDownload"` LastCityDownload time.Time `json:"lastCityDownload"` LastCountryDownload time.Time `json:"lastCountryDownload"` } // Manager handles state file operations. type Manager struct { config *config.Config logger *slog.Logger statePath string } // New creates a new state manager. func New(cfg *config.Config, logger *slog.Logger) (*Manager, error) { statePath := filepath.Join(cfg.StateDir, stateFileName) return &Manager{ config: cfg, logger: logger, statePath: statePath, }, nil } // Initialize ensures the state directory exists. func (m *Manager) Initialize(_ context.Context) error { // Ensure state directory exists dir := filepath.Dir(m.statePath) if err := os.MkdirAll(dir, dirPermissions); err != nil { return fmt.Errorf("failed to create state directory: %w", err) } m.logger.Info("State manager initialized", "path", m.statePath) return nil } // Load reads the state from disk. func (m *Manager) Load() (*State, error) { data, err := os.ReadFile(m.statePath) if err != nil { if os.IsNotExist(err) { // Return empty state if file doesn't exist return &State{}, nil } return nil, fmt.Errorf("failed to read state file: %w", err) } var state State if err := json.Unmarshal(data, &state); err != nil { return nil, fmt.Errorf("failed to parse state file: %w", err) } return &state, nil } // Save writes the state to disk. func (m *Manager) Save(state *State) error { data, err := json.MarshalIndent(state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) } // Write to temporary file first tmpPath := m.statePath + ".tmp" if err := os.WriteFile(tmpPath, data, filePermissions); err != nil { return fmt.Errorf("failed to write state file: %w", err) } // Rename to final path (atomic operation) if err := os.Rename(tmpPath, m.statePath); err != nil { return fmt.Errorf("failed to save state file: %w", err) } m.logger.Debug("State saved", "path", m.statePath) return nil } // UpdateASNDownloadTime updates the ASN database download timestamp. func (m *Manager) UpdateASNDownloadTime() error { state, err := m.Load() if err != nil { return err } state.LastASNDownload = time.Now().UTC() return m.Save(state) } // UpdateCityDownloadTime updates the City database download timestamp. func (m *Manager) UpdateCityDownloadTime() error { state, err := m.Load() if err != nil { return err } state.LastCityDownload = time.Now().UTC() return m.Save(state) } // UpdateCountryDownloadTime updates the Country database download timestamp. func (m *Manager) UpdateCountryDownloadTime() error { state, err := m.Load() if err != nil { return err } state.LastCountryDownload = time.Now().UTC() return m.Save(state) }