feat(store): open encrypted SQLite, schema v1, settings
This commit is contained in:
@@ -1,3 +1,16 @@
|
|||||||
module git.dcglab.co.uk/steve/emcli
|
module git.dcglab.co.uk/steve/emcli
|
||||||
|
|
||||||
go 1.22
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
|
modernc.org/libc v1.73.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.53.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
|
||||||
|
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||||
|
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||||
@@ -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