up
This commit is contained in:
+100
-40
@@ -13,6 +13,7 @@ import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"hamlog/internal/db"
|
||||
"hamlog/internal/secret"
|
||||
)
|
||||
|
||||
@@ -27,10 +28,62 @@ type Store struct {
|
||||
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()
|
||||
@@ -101,39 +154,36 @@ func (s *Store) encodeWrite(key, val string) string {
|
||||
|
||||
// Get returns the value for key, or "" if not set.
|
||||
func (s *Store) Get(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
raw, err := s.GetRaw(ctx, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.decodeRead(key, v), nil
|
||||
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) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
if err := s.ensureCache(ctx); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, 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 {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO settings(key, value) VALUES(?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`,
|
||||
key, value)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -144,31 +194,41 @@ func (s *Store) Set(ctx context.Context, key, value string) error {
|
||||
|
||||
// All returns every stored setting (sensitive values decrypted when unlocked).
|
||||
func (s *Store) All(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM settings`)
|
||||
if err != nil {
|
||||
if err := s.ensureCache(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = s.decodeRead(k, v)
|
||||
s.mu.RLock()
|
||||
raw := make(map[string]string, len(s.cache))
|
||||
for k, v := range s.cache {
|
||||
raw[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetMany fetches several keys in a single round-trip.
|
||||
func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string, error) {
|
||||
out := make(map[string]string, len(keys))
|
||||
for _, k := range keys {
|
||||
v, err := s.Get(ctx, k)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user