package beach import ( "crypto/rand" "crypto/sha256" "database/sql" "encoding/hex" "encoding/json" "errors" "time" _ "modernc.org/sqlite" ) // Store is Beach's one SQLite file (pure Go, WAL mode). Settings are a single // JSON document, so everything is edited in the web UI and there is no config file. type Store struct{ db *sql.DB } const schema = ` CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS users (name TEXT PRIMARY KEY, hash TEXT NOT NULL); CREATE TABLE IF NOT EXISTS sessions (hash TEXT PRIMARY KEY, user TEXT NOT NULL, expires INTEGER NOT NULL); ` // Settings is everything configurable. Empty sections are simply not collected. type Settings struct { DockerHost string `json:"dockerHost"` // unix socket path, or tcp://host:2375; empty disables Docker TrueNAS struct { URL string `json:"url"` APIKey string `json:"apiKey"` Insecure bool `json:"insecure"` // accept a self-signed certificate } `json:"truenas"` Smartctl bool `json:"smartctl"` // also read SMART from local smartctl Hammerhead struct { URL string `json:"url"` APIKey string `json:"apiKey"` Org string `json:"org"` } `json:"hammerhead"` RefreshSeconds int `json:"refreshSeconds"` } func OpenStore(path string) (*Store, error) { db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") if err != nil { return nil, err } db.SetMaxOpenConns(1) if _, err := db.Exec(schema); err != nil { return nil, err } return &Store{db}, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) Settings() Settings { var set Settings var body string if s.db.QueryRow(`SELECT body FROM settings WHERE id=1`).Scan(&body) == nil { _ = json.Unmarshal([]byte(body), &set) } else { set.DockerHost = defaultDockerHost } if set.RefreshSeconds < 2 { set.RefreshSeconds = 10 } return set } func (s *Store) SaveSettings(set Settings) error { b, _ := json.Marshal(set) _, err := s.db.Exec(`INSERT INTO settings (id, body) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body`, string(b)) return err } func (s *Store) HasUsers() bool { var n int s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n) return n > 0 } func (s *Store) AddUser(name, hash string) error { _, err := s.db.Exec(`INSERT INTO users (name, hash) VALUES (?, ?)`, name, hash) return err } func (s *Store) UserHash(name string) (string, bool) { var h string err := s.db.QueryRow(`SELECT hash FROM users WHERE name=?`, name).Scan(&h) return h, err == nil } func hashToken(t string) string { h := sha256.Sum256([]byte(t)) return hex.EncodeToString(h[:]) } // NewSession returns a random token; only its hash is stored. func (s *Store) NewSession(user string, ttl time.Duration) (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } tok := hex.EncodeToString(b) _, err := s.db.Exec(`INSERT INTO sessions (hash, user, expires) VALUES (?, ?, ?)`, hashToken(tok), user, time.Now().Add(ttl).Unix()) return tok, err } func (s *Store) SessionUser(tok string) (string, error) { var u string var exp int64 if err := s.db.QueryRow(`SELECT user, expires FROM sessions WHERE hash=?`, hashToken(tok)).Scan(&u, &exp); err != nil { return "", err } if time.Now().Unix() > exp { s.DeleteSession(tok) return "", errors.New("expired") } return u, nil } func (s *Store) DeleteSession(tok string) { s.db.Exec(`DELETE FROM sessions WHERE hash=?`, hashToken(tok)) }