ipapi/internal/state/state.go
sneak 2a1710cca8 Implement IP API daemon with GeoIP database support
- Create modular architecture with separate packages for config, database, HTTP, logging, and state management
- Implement Cobra CLI with daemon command
- Set up Uber FX dependency injection
- Add Chi router with health check and IP lookup endpoints
- Implement GeoIP database downloader with automatic updates
- Add state persistence for tracking database download times
- Include comprehensive test coverage for all components
- Configure structured logging with slog
- Add Makefile with test, lint, and build targets
- Support both IPv4 and IPv6 lookups
- Return country, city, ASN, and location data in JSON format
2025-07-27 18:15:38 +02:00

135 lines
3.1 KiB
Go

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