first commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user