first commit

This commit is contained in:
2026-08-25 06:37:14 +01:00
commit 5c3e72b40e
26 changed files with 6301 additions and 0 deletions
+143
View File
@@ -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)
}