// Package settings is a tiny key/value store backed by the SQLite settings table. // // Sensitive keys (passwords) are encrypted transparently when a passphrase // cipher is installed: Set encrypts them, Get/GetMany/All decrypt them. When a // passphrase is configured but not yet unlocked (cipher nil) an encrypted value // reads back as "" rather than leaking ciphertext — callers treat that as // "not configured" and degrade gracefully. package settings import ( "context" "database/sql" "fmt" "sync" "hamlog/internal/db" "hamlog/internal/secret" ) // Cipher is the subset of *secret.Cipher the store needs. type Cipher interface { Encrypt(plain string) string Decrypt(stored string) (string, error) } type Store struct { db *sql.DB mu sync.RWMutex cipher Cipher // non-nil when secrets are unlocked sensitive func(key string) bool // which keys are encrypted at rest // cache holds every setting's RAW (as-stored) value, loaded once. Reads are // served from memory so the Preferences dialog (dozens of keys) doesn't pay // a network round-trip per key against a remote MySQL. Decryption still // happens on read, so a later Unlock takes effect without reloading. cache map[string]string cached bool } func NewStore(db *sql.DB) *Store { return &Store{db: db} } // ensureCache lazily loads all settings into memory on first read. A concurrent // double-load is harmless (the result is identical), so it's done without a // long-held lock. func (s *Store) ensureCache(ctx context.Context) error { s.mu.RLock() ok := s.cached s.mu.RUnlock() if ok { return nil } rows, err := s.db.QueryContext(ctx, "SELECT `key`, value FROM settings") if err != nil { return err } defer rows.Close() m := make(map[string]string, 256) for rows.Next() { var k, v string if err := rows.Scan(&k, &v); err != nil { return err } m[k] = v } if err := rows.Err(); err != nil { return err } s.mu.Lock() if !s.cached { s.cache = m s.cached = true } s.mu.Unlock() return nil } // cachePut updates the in-memory copy after a write so reads stay coherent. func (s *Store) cachePut(key, raw string) { s.mu.Lock() if s.cache == nil { s.cache = map[string]string{} } s.cache[key] = raw s.mu.Unlock() } // SetSensitivePredicate registers which keys hold secrets. Set once at startup. func (s *Store) SetSensitivePredicate(fn func(key string) bool) { s.mu.Lock() s.sensitive = fn s.mu.Unlock() } // Unlock installs the cipher derived from the user's passphrase so sensitive // values encrypt on write and decrypt on read. func (s *Store) Unlock(c Cipher) { s.mu.Lock() s.cipher = c s.mu.Unlock() } // Lock drops the cipher (secrets become unreadable until the next Unlock). func (s *Store) Lock() { s.mu.Lock() s.cipher = nil s.mu.Unlock() } // Unlocked reports whether a cipher is currently installed. func (s *Store) Unlocked() bool { s.mu.RLock() defer s.mu.RUnlock() return s.cipher != nil } func (s *Store) isSensitive(key string) bool { if s.sensitive == nil { return false } return s.sensitive(key) } // decodeRead decrypts a sensitive value for reading (plaintext passes through; // locked → ""). func (s *Store) decodeRead(key, val string) string { if val == "" || !s.isSensitive(key) || !secret.IsEncrypted(val) { return val } s.mu.RLock() c := s.cipher s.mu.RUnlock() if c == nil { return "" // locked: don't leak ciphertext to callers } if pt, err := c.Decrypt(val); err == nil { return pt } return "" } // encodeWrite encrypts a sensitive value for storage when unlocked. func (s *Store) encodeWrite(key, val string) string { if val == "" || !s.isSensitive(key) { return val } s.mu.RLock() c := s.cipher s.mu.RUnlock() if c == nil { return val // no passphrase / locked → store as-is (legacy behaviour) } return c.Encrypt(val) } // Get returns the value for key, or "" if not set. func (s *Store) Get(ctx context.Context, key string) (string, error) { raw, err := s.GetRaw(ctx, key) if err != nil { return "", err } return s.decodeRead(key, raw), nil } // GetRaw returns the stored value WITHOUT decryption — used by the passphrase // migration which must read/re-write the raw ciphertext or plaintext. func (s *Store) GetRaw(ctx context.Context, key string) (string, error) { if err := s.ensureCache(ctx); err != nil { return "", err } s.mu.RLock() v := s.cache[key] // "" when absent s.mu.RUnlock() return v, nil } // SetRaw stores a value verbatim (no encryption) — used by the migration. // The settings table always lives in the local SQLite database (config is // per-operator, never on the shared MySQL logbook), so SQLite syntax is used // unconditionally. The backticks around `key` are accepted by SQLite too. func (s *Store) SetRaw(ctx context.Context, key, value string) error { q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " + "ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" if _, err := s.db.ExecContext(ctx, q, key, value, db.NowISO()); err != nil { return fmt.Errorf("set %s: %w", key, err) } s.cachePut(key, value) return nil } // Set upserts a key/value pair (encrypting sensitive keys when unlocked). func (s *Store) Set(ctx context.Context, key, value string) error { return s.SetRaw(ctx, key, s.encodeWrite(key, value)) } // All returns every stored setting (sensitive values decrypted when unlocked). func (s *Store) All(ctx context.Context) (map[string]string, error) { if err := s.ensureCache(ctx); err != nil { return nil, err } s.mu.RLock() raw := make(map[string]string, len(s.cache)) for k, v := range s.cache { raw[k] = v } s.mu.RUnlock() out := make(map[string]string, len(raw)) for k, v := range raw { out[k] = s.decodeRead(k, v) } return out, nil } // GetMany fetches several keys, all served from the in-memory cache (one DB // round-trip total, on first access). Every requested key is present in the // result (absent settings map to ""). func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) { out := make(map[string]string, len(keys)) if len(keys) == 0 { return out, nil } if err := s.ensureCache(ctx); err != nil { return nil, err } s.mu.RLock() raw := make([]string, len(keys)) for i, k := range keys { raw[i] = s.cache[k] } s.mu.RUnlock() for i, k := range keys { out[k] = s.decodeRead(k, raw[i]) } return out, nil }