diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ef17d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +eng-pad-server +/srv/ +*.db +*.db-wal +*.db-shm +.idea/ +.vscode/ diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ee3c00f --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,32 @@ +version: "2" + +linters: + enable: + - errcheck + - govet + - ineffassign + - unused + - errorlint + - gosec + - staticcheck + - revive + - gofmt + - goimports + + settings: + errcheck: + check-type-assertions: true + govet: + disable: + - shadow + gosec: + excludes: + - G104 + +issues: + max-issues-per-linter: 0 + +formatters: + enable: + - gofmt + - goimports diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..688537b --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build test vet lint proto proto-lint clean all + +LDFLAGS := -trimpath -ldflags="-s -w -X main.version=$(shell git describe --tags --always --dirty 2>/dev/null || echo dev)" + +eng-pad-server: + CGO_ENABLED=0 go build $(LDFLAGS) -o eng-pad-server ./cmd/eng-pad-server + +build: + go build ./... + +test: + go test ./... + +vet: + go vet ./... + +lint: + golangci-lint run ./... + +proto: + protoc --go_out=. --go_opt=module=git.wntrmute.dev/kyle/eng-pad-server \ + --go-grpc_out=. --go-grpc_opt=module=git.wntrmute.dev/kyle/eng-pad-server \ + proto/engpad/v1/*.proto + +proto-lint: + buf lint + buf breaking --against '.git#branch=master,subdir=proto' + +clean: + rm -f eng-pad-server + +all: vet lint test eng-pad-server diff --git a/deploy/examples/eng-pad-server.toml b/deploy/examples/eng-pad-server.toml new file mode 100644 index 0000000..0bada29 --- /dev/null +++ b/deploy/examples/eng-pad-server.toml @@ -0,0 +1,26 @@ +[server] +listen_addr = ":8443" +grpc_addr = ":9443" +tls_cert = "/srv/eng-pad-server/certs/cert.pem" +tls_key = "/srv/eng-pad-server/certs/key.pem" + +[web] +listen_addr = ":8080" +base_url = "https://pad.metacircular.net" + +[database] +path = "/srv/eng-pad-server/eng-pad-server.db" + +[auth] +token_ttl = "24h" +argon2_memory = 65536 +argon2_time = 3 +argon2_threads = 4 + +[webauthn] +rp_display_name = "Engineering Pad" +rp_id = "pad.metacircular.net" +rp_origins = ["https://pad.metacircular.net"] + +[log] +level = "info" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1f6e4da --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module git.wntrmute.dev/kyle/eng-pad-server + +go 1.25.0 + +require ( + github.com/pelletier/go-toml/v2 v2.3.0 + github.com/spf13/cobra v1.10.2 + modernc.org/sqlite v1.47.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.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 + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..3d67762 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +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/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk= +modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cc5c778 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,80 @@ +package config + +import ( + "fmt" + "os" + "time" + + "github.com/pelletier/go-toml/v2" +) + +type Config struct { + Server ServerConfig `toml:"server"` + Web WebConfig `toml:"web"` + Database DatabaseConfig `toml:"database"` + Auth AuthConfig `toml:"auth"` + WebAuthn WebAuthnConfig `toml:"webauthn"` + Log LogConfig `toml:"log"` +} + +type ServerConfig struct { + ListenAddr string `toml:"listen_addr"` + GRPCAddr string `toml:"grpc_addr"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` +} + +type WebConfig struct { + ListenAddr string `toml:"listen_addr"` + BaseURL string `toml:"base_url"` +} + +type DatabaseConfig struct { + Path string `toml:"path"` +} + +type AuthConfig struct { + TokenTTL string `toml:"token_ttl"` + Argon2Memory uint32 `toml:"argon2_memory"` + Argon2Time uint32 `toml:"argon2_time"` + Argon2Threads uint8 `toml:"argon2_threads"` +} + +func (a AuthConfig) TokenDuration() (time.Duration, error) { + return time.ParseDuration(a.TokenTTL) +} + +type WebAuthnConfig struct { + RPDisplayName string `toml:"rp_display_name"` + RPID string `toml:"rp_id"` + RPOrigins []string `toml:"rp_origins"` +} + +type LogConfig struct { + Level string `toml:"level"` +} + +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + if err := cfg.validate(); err != nil { + return nil, fmt.Errorf("config validation: %w", err) + } + return &cfg, nil +} + +func (c *Config) validate() error { + if c.Database.Path == "" { + return fmt.Errorf("database.path is required") + } + if c.Server.TLSCert == "" || c.Server.TLSKey == "" { + return fmt.Errorf("server.tls_cert and server.tls_key are required") + } + return nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..8963cc9 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,29 @@ +package db + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +func Open(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) + } + + pragmas := []string{ + "PRAGMA journal_mode = WAL", + "PRAGMA foreign_keys = ON", + "PRAGMA busy_timeout = 5000", + } + for _, p := range pragmas { + if _, err := db.Exec(p); err != nil { + _ = db.Close() + return nil, fmt.Errorf("exec %q: %w", p, err) + } + } + + return db, nil +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..843833a --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,120 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestOpenAndMigrate(t *testing.T) { + dir := t.TempDir() + database, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = database.Close() }() + + if err := Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + + // Verify tables exist + tables := []string{"users", "notebooks", "pages", "strokes", "share_links", "webauthn_credentials", "schema_migrations"} + for _, table := range tables { + var name string + err := database.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&name) + if err != nil { + t.Errorf("table %s not found: %v", table, err) + } + } +} + +func TestMigrateIdempotent(t *testing.T) { + dir := t.TempDir() + database, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = database.Close() }() + + if err := Migrate(database); err != nil { + t.Fatalf("first migrate: %v", err) + } + if err := Migrate(database); err != nil { + t.Fatalf("second migrate: %v", err) + } +} + +func TestForeignKeys(t *testing.T) { + dir := t.TempDir() + database, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = database.Close() }() + + if err := Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + + // Inserting a notebook with non-existent user_id should fail + _, err = database.Exec("INSERT INTO notebooks (user_id, remote_id, title, page_size, synced_at) VALUES (999, 1, 'test', 'REGULAR', 0)") + if err == nil { + t.Fatal("expected foreign key error, got nil") + } +} + +func TestCascadeDelete(t *testing.T) { + dir := t.TempDir() + database, err := Open(filepath.Join(dir, "test.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + defer func() { _ = database.Close() }() + + if err := Migrate(database); err != nil { + t.Fatalf("migrate: %v", err) + } + + // Create user, notebook, page, stroke + res, err := database.Exec("INSERT INTO users (username, password_hash, created_at, updated_at) VALUES ('test', 'hash', 0, 0)") + if err != nil { + t.Fatalf("insert user: %v", err) + } + userID, _ := res.LastInsertId() + + res, err = database.Exec("INSERT INTO notebooks (user_id, remote_id, title, page_size, synced_at) VALUES (?, 1, 'nb', 'REGULAR', 0)", userID) + if err != nil { + t.Fatalf("insert notebook: %v", err) + } + nbID, _ := res.LastInsertId() + + res, err = database.Exec("INSERT INTO pages (notebook_id, remote_id, page_number) VALUES (?, 1, 1)", nbID) + if err != nil { + t.Fatalf("insert page: %v", err) + } + pageID, _ := res.LastInsertId() + + _, err = database.Exec("INSERT INTO strokes (page_id, pen_size, color, point_data, stroke_order) VALUES (?, 1.0, 0, X'00', 1)", pageID) + if err != nil { + t.Fatalf("insert stroke: %v", err) + } + + // Delete the user — everything should cascade + if _, err := database.Exec("DELETE FROM users WHERE id = ?", userID); err != nil { + t.Fatalf("delete user: %v", err) + } + + var count int + _ = database.QueryRow("SELECT COUNT(*) FROM notebooks").Scan(&count) + if count != 0 { + t.Errorf("expected 0 notebooks, got %d", count) + } + _ = database.QueryRow("SELECT COUNT(*) FROM pages").Scan(&count) + if count != 0 { + t.Errorf("expected 0 pages, got %d", count) + } + _ = database.QueryRow("SELECT COUNT(*) FROM strokes").Scan(&count) + if count != 0 { + t.Errorf("expected 0 strokes, got %d", count) + } +} diff --git a/internal/db/migrations.go b/internal/db/migrations.go new file mode 100644 index 0000000..25aeb1a --- /dev/null +++ b/internal/db/migrations.go @@ -0,0 +1,114 @@ +package db + +import ( + "database/sql" + "fmt" +) + +var migrations = []struct { + name string + sql string +}{ + { + name: "001_initial_schema", + sql: ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS webauthn_credentials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BLOB NOT NULL UNIQUE, + public_key BLOB NOT NULL, + name TEXT NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS notebooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + remote_id INTEGER NOT NULL, + title TEXT NOT NULL, + page_size TEXT NOT NULL, + synced_at INTEGER NOT NULL, + UNIQUE(user_id, remote_id) +); + +CREATE TABLE IF NOT EXISTS pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + remote_id INTEGER NOT NULL, + page_number INTEGER NOT NULL, + UNIQUE(notebook_id, remote_id) +); + +CREATE TABLE IF NOT EXISTS strokes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, + pen_size REAL NOT NULL, + color INTEGER NOT NULL, + style TEXT NOT NULL DEFAULT 'plain', + point_data BLOB NOT NULL, + stroke_order INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS share_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + notebook_id INTEGER NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at INTEGER, + created_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_notebooks_user ON notebooks(user_id); +CREATE INDEX IF NOT EXISTS idx_pages_notebook ON pages(notebook_id); +CREATE INDEX IF NOT EXISTS idx_strokes_page ON strokes(page_id); +CREATE INDEX IF NOT EXISTS idx_share_links_token ON share_links(token); +CREATE INDEX IF NOT EXISTS idx_webauthn_user ON webauthn_credentials(user_id); +`, + }, +} + +func Migrate(database *sql.DB) error { + // Ensure schema_migrations table exists + _, err := database.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL)`) + if err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + for _, m := range migrations { + var count int + err := database.QueryRow("SELECT COUNT(*) FROM schema_migrations WHERE name = ?", m.name).Scan(&count) + if err != nil { + return fmt.Errorf("check migration %s: %w", m.name, err) + } + if count > 0 { + continue + } + + if _, err := database.Exec(m.sql); err != nil { + return fmt.Errorf("apply migration %s: %w", m.name, err) + } + + if _, err := database.Exec( + "INSERT INTO schema_migrations (name, applied_at) VALUES (?, strftime('%s','now'))", + m.name, + ); err != nil { + return fmt.Errorf("record migration %s: %w", m.name, err) + } + } + + return nil +}