// Package config reads and writes data/settings.conf, a plain key=value // file holding non-secret app settings plus the AES key (app_secret) used // to encrypt exchange credentials stored in the database. package config import ( "bufio" "crypto/rand" "encoding/hex" "fmt" "os" "strconv" "strings" "sync" ) type Config struct { Port int PollIntervalSeconds int HistoryRefreshSeconds int DBPath string AppSecret []byte // 32 bytes, AES-256 key path string mu sync.RWMutex baseCurrency string // guarded by mu: changeable at runtime via the settings page } func defaults() *Config { return &Config{ Port: 8080, PollIntervalSeconds: 60, HistoryRefreshSeconds: 300, DBPath: "data/app.db", baseCurrency: "GBP", } } // Load reads path, creating it with defaults (and a fresh random app_secret) // if it doesn't exist yet. func Load(path string) (*Config, error) { cfg := defaults() cfg.path = path f, err := os.Open(path) if os.IsNotExist(err) { secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("generate app_secret: %w", err) } cfg.AppSecret = secret if err := cfg.save(); err != nil { return nil, err } return cfg, nil } if err != nil { return nil, err } defer f.Close() values := map[string]string{} scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } k, v, ok := strings.Cut(line, "=") if !ok { continue } values[strings.TrimSpace(k)] = strings.TrimSpace(v) } if err := scanner.Err(); err != nil { return nil, err } if v, ok := values["port"]; ok { if n, err := strconv.Atoi(v); err == nil { cfg.Port = n } } if v, ok := values["poll_interval_seconds"]; ok { if n, err := strconv.Atoi(v); err == nil { cfg.PollIntervalSeconds = n } } if v, ok := values["history_refresh_seconds"]; ok { if n, err := strconv.Atoi(v); err == nil { cfg.HistoryRefreshSeconds = n } } if v, ok := values["db_path"]; ok && v != "" { cfg.DBPath = v } if v, ok := values["base_currency"]; ok && v != "" { cfg.baseCurrency = strings.ToUpper(v) } secretHex, ok := values["app_secret"] if !ok || secretHex == "" { return nil, fmt.Errorf("settings.conf missing app_secret") } secret, err := hex.DecodeString(secretHex) if err != nil || len(secret) != 32 { return nil, fmt.Errorf("settings.conf app_secret must be 32 random bytes hex-encoded") } cfg.AppSecret = secret return cfg, nil } // BaseCurrency is the fiat currency (e.g. "GBP") all values are shown in. func (c *Config) BaseCurrency() string { c.mu.RLock() defer c.mu.RUnlock() return c.baseCurrency } // SetBaseCurrency updates and persists the base currency. func (c *Config) SetBaseCurrency(v string) error { v = strings.ToUpper(strings.TrimSpace(v)) if v == "" { return fmt.Errorf("base currency required") } c.mu.Lock() defer c.mu.Unlock() c.baseCurrency = v return c.save() } // save must be called with mu held (directly or via the zero-value path // during Load, before the Config is shared across goroutines). func (c *Config) save() error { var b strings.Builder fmt.Fprintf(&b, "port=%d\n", c.Port) fmt.Fprintf(&b, "poll_interval_seconds=%d\n", c.PollIntervalSeconds) fmt.Fprintf(&b, "history_refresh_seconds=%d\n", c.HistoryRefreshSeconds) fmt.Fprintf(&b, "db_path=%s\n", c.DBPath) fmt.Fprintf(&b, "base_currency=%s\n", c.baseCurrency) fmt.Fprintf(&b, "app_secret=%s\n", hex.EncodeToString(c.AppSecret)) return os.WriteFile(c.path, []byte(b.String()), 0600) }