144 lines
3.6 KiB
Go
144 lines
3.6 KiB
Go
// 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)
|
|
}
|