feat(store): open encrypted SQLite, schema v1, settings
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package store
|
||||
|
||||
const schemaVersion = 1
|
||||
|
||||
// schemaSQL is the full v1 schema. All statements are idempotent via IF NOT EXISTS.
|
||||
const schemaSQL = `
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('RO','RW')),
|
||||
imap_host TEXT NOT NULL,
|
||||
imap_port INTEGER NOT NULL,
|
||||
imap_security TEXT NOT NULL CHECK (imap_security IN ('tls','starttls')),
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_security TEXT,
|
||||
auth_type TEXT NOT NULL CHECK (auth_type IN ('password','oauth2')),
|
||||
username TEXT NOT NULL,
|
||||
enc_password BLOB,
|
||||
enc_oauth_client_id BLOB,
|
||||
enc_oauth_client_secret BLOB,
|
||||
enc_oauth_refresh_token BLOB,
|
||||
whitelist_in_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
whitelist_out_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
subject_regex TEXT,
|
||||
process_backlog INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whitelist_in (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, address)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS whitelist_out (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, address)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folder_state (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
folder TEXT NOT NULL,
|
||||
uidvalidity INTEGER NOT NULL,
|
||||
floor_uid INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, folder)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS acked (
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
folder TEXT NOT NULL,
|
||||
uidvalidity INTEGER NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, folder, uid)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
account TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
result TEXT NOT NULL,
|
||||
reason TEXT
|
||||
);
|
||||
`
|
||||
@@ -0,0 +1,21 @@
|
||||
package store
|
||||
|
||||
import "database/sql"
|
||||
|
||||
// GetSetting returns a setting value or sql.ErrNoRows if absent.
|
||||
func (s *Store) GetSetting(name string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRow("SELECT value FROM settings WHERE key = ?", name).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetSetting upserts a setting.
|
||||
func (s *Store) SetSetting(name, value string) error {
|
||||
_, err := s.db.Exec(
|
||||
"INSERT INTO settings(key,value) VALUES(?,?) "+
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
name, value)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ = sql.ErrNoRows
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package store owns the encrypted SQLite config and read state.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// Store wraps the database and the field-encryption key.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
key []byte
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the DB at path and applies the schema.
|
||||
func Open(path string, key []byte) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, fmt.Errorf("create db dir: %w", err)
|
||||
}
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON;"); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schemaSQL); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("apply schema: %w", err)
|
||||
}
|
||||
s := &Store{db: db, key: key}
|
||||
if _, err := s.GetSetting("schema_version"); err != nil {
|
||||
if err := s.SetSetting("schema_version", strconv.Itoa(schemaVersion)); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// DefaultDBPath resolves EMCLI_DB or the per-OS default location.
|
||||
func DefaultDBPath() (string, error) {
|
||||
if p := os.Getenv("EMCLI_DB"); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
if dir := os.Getenv("AppData"); dir != "" {
|
||||
return filepath.Join(dir, "emcli", "emcli.db"), nil
|
||||
}
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".config", "emcli", "emcli.db"), nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testKey() []byte {
|
||||
k := make([]byte, 32)
|
||||
for i := range k {
|
||||
k[i] = byte(i)
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// openTemp opens a fresh store in a temp dir.
|
||||
func openTemp(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||
s, err := Open(p, testKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func TestOpenCreatesSchemaAndIsIdempotent(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "emcli.db")
|
||||
s, err := Open(p, testKey())
|
||||
if err != nil {
|
||||
t.Fatalf("first Open: %v", err)
|
||||
}
|
||||
v, err := s.GetSetting("schema_version")
|
||||
if err != nil || v != "1" {
|
||||
t.Fatalf("schema_version: %q err=%v", v, err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
// Re-open: must not error or duplicate.
|
||||
s2, err := Open(p, testKey())
|
||||
if err != nil {
|
||||
t.Fatalf("second Open: %v", err)
|
||||
}
|
||||
defer s2.Close()
|
||||
if v, _ := s2.GetSetting("schema_version"); v != "1" {
|
||||
t.Fatalf("schema_version after reopen: %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRoundTrip(t *testing.T) {
|
||||
s := openTemp(t)
|
||||
if err := s.SetSetting("audit_retention_days", "30"); err != nil {
|
||||
t.Fatalf("SetSetting: %v", err)
|
||||
}
|
||||
got, err := s.GetSetting("audit_retention_days")
|
||||
if err != nil || got != "30" {
|
||||
t.Fatalf("got %q err=%v", got, err)
|
||||
}
|
||||
// Upsert overwrites.
|
||||
_ = s.SetSetting("audit_retention_days", "7")
|
||||
if got, _ := s.GetSetting("audit_retention_days"); got != "7" {
|
||||
t.Fatalf("upsert failed: %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user