up
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"hamlog/internal/db"
|
||||
@@ -29,6 +30,11 @@ type Store struct {
|
||||
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
|
||||
@@ -39,6 +45,50 @@ type Store struct {
|
||||
|
||||
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.
|
||||
@@ -167,8 +217,9 @@ 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[key] // "" when absent
|
||||
v := s.cache[full] // "" when absent
|
||||
s.mu.RUnlock()
|
||||
return v, nil
|
||||
}
|
||||
@@ -178,12 +229,13 @@ func (s *Store) GetRaw(ctx context.Context, key string) (string, error) {
|
||||
// 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, key, value, db.NowISO()); err != nil {
|
||||
if _, err := s.db.ExecContext(ctx, q, full, value, db.NowISO()); err != nil {
|
||||
return fmt.Errorf("set %s: %w", key, err)
|
||||
}
|
||||
s.cachePut(key, value)
|
||||
s.cachePut(full, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -192,15 +244,44 @@ 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 {
|
||||
raw[k] = v
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
raw[strings.TrimPrefix(k, prefix)] = v
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
out := make(map[string]string, len(raw))
|
||||
@@ -221,10 +302,11 @@ func (s *Store) GetMany(ctx context.Context, keys ...string) (map[string]string,
|
||||
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[k]
|
||||
raw[i] = s.cache[prefix+k]
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
for i, k := range keys {
|
||||
|
||||
Reference in New Issue
Block a user