package sandbar import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/base64" "encoding/hex" "encoding/json" "errors" "os" "path/filepath" "time" "git.veillax.com/Veillax/reef/secretbox" _ "modernc.org/sqlite" ) // Store is Sandbar's one SQLite file (pure Go, WAL mode). Configuration that is // edited by hand and rarely queried (channels, monitors, schedules, policies, // incidents) is kept as JSON documents in one table; things that grow or are // searched (messages, checks, alerts, timeline events) have proper indexed tables. type Store struct { db *sql.DB box *secretbox.Box } var errNotFound = errors.New("not found") const schema = ` CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE COLLATE NOCASE, hash TEXT NOT NULL, role TEXT NOT NULL, phone TEXT NOT NULL DEFAULT '', created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS sessions ( hash TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS apikeys ( id TEXT PRIMARY KEY, name TEXT NOT NULL, hash TEXT NOT NULL UNIQUE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS docs ( kind TEXT NOT NULL, id TEXT NOT NULL, body TEXT NOT NULL, PRIMARY KEY (kind, id)); CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, topic TEXT NOT NULL, time INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL, priority INTEGER NOT NULL, tags TEXT NOT NULL); CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time); CREATE TABLE IF NOT EXISTS checks ( monitor_id TEXT NOT NULL, time INTEGER NOT NULL, ok INTEGER NOT NULL, ms INTEGER NOT NULL, detail TEXT NOT NULL); CREATE INDEX IF NOT EXISTS checks_monitor ON checks(monitor_id, time); CREATE TABLE IF NOT EXISTS mstate ( monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL, last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL); CREATE TABLE IF NOT EXISTS alerts ( id TEXT PRIMARY KEY, key TEXT NOT NULL, source TEXT NOT NULL, summary TEXT NOT NULL, detail TEXT NOT NULL, severity TEXT NOT NULL, status TEXT NOT NULL, policy_id TEXT NOT NULL, step INTEGER NOT NULL, next_at INTEGER NOT NULL, created INTEGER NOT NULL, updated INTEGER NOT NULL, count INTEGER NOT NULL, acked_by TEXT NOT NULL DEFAULT '', acked_at INTEGER NOT NULL DEFAULT 0, snooze_until INTEGER NOT NULL DEFAULT 0, resolved_at INTEGER NOT NULL DEFAULT 0, incident_id TEXT NOT NULL DEFAULT ''); CREATE INDEX IF NOT EXISTS alerts_key ON alerts(key, status); CREATE INDEX IF NOT EXISTS alerts_due ON alerts(status, next_at); CREATE TABLE IF NOT EXISTS events ( id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL, kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL); CREATE INDEX IF NOT EXISTS events_ref ON events(scope, ref, time); ` // OpenStore opens (creating if needed) the database in dir. func OpenStore(dir string, box *secretbox.Box) (*Store, error) { if err := os.MkdirAll(dir, 0o700); err != nil { return nil, err } dsn := "file:" + filepath.ToSlash(filepath.Join(dir, "sandbar.db")) + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)" db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(4) if _, err := db.Exec(schema); err != nil { db.Close() return nil, err } return &Store{db: db, box: box}, nil } func (s *Store) Close() error { return s.db.Close() } // ---------- ids and tokens ---------- func newID() string { b := make([]byte, 9) rand.Read(b) return hex.EncodeToString(b) } func newToken() string { b := make([]byte, 32) rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func hashToken(t string) string { sum := sha256.Sum256([]byte(t)) return hex.EncodeToString(sum[:]) } // ---------- JSON documents ---------- func (s *Store) put(kind, id string, v any) error { b, err := json.Marshal(v) if err != nil { return err } _, err = s.db.Exec(`INSERT INTO docs(kind,id,body) VALUES(?,?,?) ON CONFLICT(kind,id) DO UPDATE SET body=excluded.body`, kind, id, string(b)) return err } func (s *Store) get(kind, id string, v any) error { var body string err := s.db.QueryRow(`SELECT body FROM docs WHERE kind=? AND id=?`, kind, id).Scan(&body) if errors.Is(err, sql.ErrNoRows) { return errNotFound } if err != nil { return err } return json.Unmarshal([]byte(body), v) } // list decodes every document of a kind through add, which is called with a // fresh pointer-to-value from newItem and then appended by the caller. func (s *Store) list(kind string, each func(raw []byte) error) error { rows, err := s.db.Query(`SELECT body FROM docs WHERE kind=? ORDER BY id`, kind) if err != nil { return err } var bodies []string for rows.Next() { var b string if err := rows.Scan(&b); err != nil { rows.Close() return err } bodies = append(bodies, b) } rows.Close() for _, b := range bodies { if err := each([]byte(b)); err != nil { return err } } return nil } func (s *Store) del(kind, id string) error { res, err := s.db.Exec(`DELETE FROM docs WHERE kind=? AND id=?`, kind, id) if err != nil { return err } if n, _ := res.RowsAffected(); n == 0 { return errNotFound } return nil } func listOf[T any](s *Store, kind string) ([]T, error) { out := []T{} err := s.list(kind, func(raw []byte) error { var v T if err := json.Unmarshal(raw, &v); err != nil { return err } out = append(out, v) return nil }) return out, err } func getOf[T any](s *Store, kind, id string) (*T, error) { var v T if err := s.get(kind, id, &v); err != nil { return nil, err } return &v, nil } // seal and open wrap secretbox so callers don't repeat the nil check. func (s *Store) seal(v string) string { if s.box == nil || v == "" { return v } return s.box.Seal(v) } func (s *Store) open(v string) string { if s.box == nil { return v } out, err := s.box.Open(v) if err != nil { return "" } return out } // ---------- timeline events ---------- func (s *Store) addEvent(scope, ref, kind, actor, text string, at time.Time) { s.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text) VALUES(?,?,?,?,?,?)`, scope, ref, at.Unix(), kind, actor, text) } type Event struct { Time int64 `json:"time"` Kind string `json:"kind"` Actor string `json:"actor"` Text string `json:"text"` } func (s *Store) events(scope, ref string) []Event { out := []Event{} rows, err := s.db.Query(`SELECT time,kind,actor,text FROM events WHERE scope=? AND ref=? ORDER BY time, id`, scope, ref) if err != nil { return out } defer rows.Close() for rows.Next() { var e Event if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text) == nil { out = append(out, e) } } return out } // prune deletes data past its retention. func (s *Store) prune(now time.Time, messageDays, checkDays int) { s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix()) s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix()) s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, now.Unix()) s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix()) }