317 lines
9.1 KiB
Go
317 lines
9.1 KiB
Go
// 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"
|
|
"strings"
|
|
"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
|
|
|
|
// prefix scopes every key to the active profile (e.g. "p3."), so each
|
|
// station profile keeps its own complete set of settings. Empty = unscoped
|
|
// (used briefly at startup before the active profile is known).
|
|
prefix string
|
|
|
|
// 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} }
|
|
|
|
// CopyProfile duplicates every setting from one profile to another (used when
|
|
// a profile is duplicated), preserving raw/encrypted values verbatim.
|
|
func (s *Store) CopyProfile(ctx context.Context, srcID, dstID int64) error {
|
|
if err := s.ensureCache(ctx); err != nil {
|
|
return err
|
|
}
|
|
srcPrefix := fmt.Sprintf("p%d.", srcID)
|
|
dstPrefix := fmt.Sprintf("p%d.", dstID)
|
|
s.mu.RLock()
|
|
pairs := make(map[string]string)
|
|
for k, v := range s.cache {
|
|
if strings.HasPrefix(k, srcPrefix) {
|
|
pairs[dstPrefix+strings.TrimPrefix(k, srcPrefix)] = v
|
|
}
|
|
}
|
|
s.mu.RUnlock()
|
|
q := "INSERT INTO settings(`key`, value, updated_at) VALUES(?, ?, ?) " +
|
|
"ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
|
|
for fullKey, val := range pairs {
|
|
if _, err := s.db.ExecContext(ctx, q, fullKey, val, db.NowISO()); err != nil {
|
|
return err
|
|
}
|
|
s.cachePut(fullKey, val)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetProfile scopes all subsequent reads/writes to the given profile id, so
|
|
// each profile has its own settings. Called at startup and whenever the active
|
|
// profile changes.
|
|
func (s *Store) SetProfile(id int64) {
|
|
s.mu.Lock()
|
|
s.prefix = fmt.Sprintf("p%d.", id)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// profileKey returns the storage key for the active profile.
|
|
func (s *Store) profileKey(key string) string {
|
|
s.mu.RLock()
|
|
p := s.prefix
|
|
s.mu.RUnlock()
|
|
return p + key
|
|
}
|
|
|
|
// 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
|
|
}
|
|
full := s.profileKey(key)
|
|
s.mu.RLock()
|
|
v := s.cache[full] // "" 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 {
|
|
full := s.profileKey(key)
|
|
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, full, value, db.NowISO()); err != nil {
|
|
return fmt.Errorf("set %s: %w", key, err)
|
|
}
|
|
s.cachePut(full, 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))
|
|
}
|
|
|
|
// GetGlobal reads a value stored WITHOUT the profile prefix — for settings that
|
|
// are shared across every profile (e.g. award definitions, which are the
|
|
// operator's own work and shouldn't be re-created per profile). Sensitive
|
|
// decryption still applies.
|
|
func (s *Store) GetGlobal(ctx context.Context, key string) (string, error) {
|
|
if err := s.ensureCache(ctx); err != nil {
|
|
return "", err
|
|
}
|
|
s.mu.RLock()
|
|
v := s.cache[key]
|
|
s.mu.RUnlock()
|
|
return s.decodeRead(key, v), nil
|
|
}
|
|
|
|
// SetGlobal upserts a value WITHOUT the profile prefix (shared across profiles).
|
|
func (s *Store) SetGlobal(ctx context.Context, key, value string) error {
|
|
value = s.encodeWrite(key, value)
|
|
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 global %s: %w", key, err)
|
|
}
|
|
s.cachePut(key, value)
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|
|
prefix := s.profileKey("")
|
|
s.mu.RLock()
|
|
raw := make(map[string]string, len(s.cache))
|
|
for k, v := range s.cache {
|
|
if strings.HasPrefix(k, prefix) {
|
|
raw[strings.TrimPrefix(k, prefix)] = 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
|
|
}
|
|
prefix := s.profileKey("")
|
|
s.mu.RLock()
|
|
raw := make([]string, len(keys))
|
|
for i, k := range keys {
|
|
raw[i] = s.cache[prefix+k]
|
|
}
|
|
s.mu.RUnlock()
|
|
for i, k := range keys {
|
|
out[k] = s.decodeRead(k, raw[i])
|
|
}
|
|
return out, nil
|
|
}
|