diff --git a/cmd/potok/main.go b/cmd/potok/main.go index 9183718..2003a59 100644 --- a/cmd/potok/main.go +++ b/cmd/potok/main.go @@ -1,8 +1,24 @@ -// Command potok is the Potok command line client. package main -import "fmt" +import ( + "fmt" + "log" + "os" + + "github.com/michaeltukdev/Potok/internal/client/cli" + "github.com/michaeltukdev/Potok/internal/client/config" +) func main() { - fmt.Println("potok: not implemented yet") + cfg, err := config.Load() + if err != nil { + log.Fatal(err) + } + + if err := cli.Execute(&cli.Env{ + Config: cfg, + }); err != nil { + fmt.Fprintln(os.Stderr, "potok:", err) + os.Exit(1) + } } diff --git a/cmd/potokd/main.go b/cmd/potokd/main.go index db67fce..1d3ccdc 100644 --- a/cmd/potokd/main.go +++ b/cmd/potokd/main.go @@ -1,8 +1,33 @@ -// Command potokd is the Potok server daemon. package main -import "fmt" +import ( + "context" + "fmt" + "log" + "os" + + nethttp "net/http" + + "github.com/michaeltukdev/Potok/internal/server/config" + httpapi "github.com/michaeltukdev/Potok/internal/server/http" + "github.com/michaeltukdev/Potok/internal/server/store" +) func main() { - fmt.Println("potokd: not implemented yet") + cfg, err := config.LoadConfig() + if err != nil { + fmt.Fprintf(os.Stderr, "LoadConfig() error: %v\n", err) + os.Exit(1) + } + + conn, err := store.Open(context.Background(), cfg.DatabaseURL) + if err != nil { + fmt.Fprintf(os.Stderr, "Open() error: %v\n", err) + os.Exit(1) + } + defer conn.Close() + + handler := httpapi.NewHandler(conn) + nethttp.HandleFunc("GET /health", handler.Health) + log.Fatal(nethttp.ListenAndServe(":3000", nil)) } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..91167e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + postgres: + image: postgres:17-alpine + container_name: mydb-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-pass} + POSTGRES_DB: ${POSTGRES_DB:-mydb} + ports: + - "${POSTGRES_PORT:-5442}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-user} -d ${POSTGRES_DB:-mydb}"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + +volumes: + pgdata: diff --git a/go.mod b/go.mod index 627be37..1981fb6 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,11 @@ module github.com/michaeltukdev/Potok -go 1.24.0 - -toolchain go1.24.13 +go 1.25.0 require ( github.com/golang-migrate/migrate/v4 v4.18.3 github.com/gorilla/mux v1.8.1 - github.com/spf13/cobra v1.9.1 + github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.40.0 modernc.org/sqlite v1.38.1 @@ -26,6 +24,10 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/josephspurrier/goversioninfo v1.4.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -35,12 +37,14 @@ require ( github.com/ncruces/zenity v0.10.14 // indirect github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.10 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/image v0.20.0 // indirect + golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.29.0 // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index dade5c1..bac9491 100644 --- a/go.sum +++ b/go.sum @@ -17,7 +17,17 @@ github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWS github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josephspurrier/goversioninfo v1.4.1/go.mod h1:JWzv5rKQr+MmW+LvM412ToT/IkYDZjaclF2pKDss8IY= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -31,21 +41,33 @@ github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSf 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.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +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.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/image v0.20.0/go.mod h1:0a88To4CYVBAHp5FXJm8o7QbUl37Vd85ply1vyD8auM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/client/cli/doc.go b/internal/client/cli/doc.go deleted file mode 100644 index 03a24cc..0000000 --- a/internal/client/cli/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package cli implements the potok command tree. -package cli diff --git a/internal/client/cli/root.go b/internal/client/cli/root.go new file mode 100644 index 0000000..7b55cc9 --- /dev/null +++ b/internal/client/cli/root.go @@ -0,0 +1,37 @@ +package cli + +import ( + "github.com/michaeltukdev/Potok/internal/client/config" + "github.com/spf13/cobra" +) + +type Env struct { + Config *config.Config +} + +func newRootCmd(env *Env) *cobra.Command { + root := &cobra.Command{ + Use: "potok", + Short: "Encrypted backup and sync for Obsidian vaults", + Long: "Potok backs up and syncs Obsidian vaults to a server you control.\n" + + "Vaults are encrypted on this device before upload, so the server\n" + + "never sees your passphrase or your notes.", + + SilenceUsage: true, + SilenceErrors: true, + + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + root.AddCommand( + runVaultAdd(env), + ) + + return root +} + +func Execute(env *Env) error { + return newRootCmd(env).Execute() +} diff --git a/internal/client/cli/vault.go b/internal/client/cli/vault.go new file mode 100644 index 0000000..9b11f8e --- /dev/null +++ b/internal/client/cli/vault.go @@ -0,0 +1,44 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func runVaultAdd(env *Env) *cobra.Command { + return &cobra.Command{ + Use: "vault-add ", + Short: "Register a local folder as a vault", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // // 1. Validate the name. config.ValidateVaultName already does this. + // if err := config.ValidateVaultName(name); err != nil { + // return err + // } + + // // 2. Resolve the path to absolute and check it is a readable directory. + // // Warn (don't fail) if there is no .obsidian folder. + + // // 3. Load the config. config.ErrNotFound means run `potok init` first. + // cfg, err := env.Config.Load() + // if errors.Is(err, config.ErrNotFound) { + // return errors.New("not initialised, run `potok init` first") + // } + // if err != nil { + // return err + // } + + // // 4. Reject a name that is already registered, before prompting for + // // anything — nobody wants to type a passphrase twice and then be told. + + // // 5. Prompt for the passphrase, twice, no echo. Reject empty; reject + // // mismatched. + + // // 6. Store the passphrase in the keyring under secrets.VaultKey(name). + + // // 7. Add the vault to the config and save. If this fails, delete the + // // keyring entry so a retry isn't blocked by an orphaned passphrase. + + return nil + }, + } +} diff --git a/internal/client/cli/vault_test.go b/internal/client/cli/vault_test.go new file mode 100644 index 0000000..10d1b51 --- /dev/null +++ b/internal/client/cli/vault_test.go @@ -0,0 +1,29 @@ +package cli + +// func obsidianVault(t *testing.T) string { +// t.Helper() +// dir := t.TempDir() +// if err := os.Mkdir(filepath.Join(dir, ".obsidian"), 0o755); err != nil { +// t.Fatalf("Mkdir(.obsidian) = %v", err) +// } +// return dir +// } + +// func TestVaultAdd(t *testing.T) { +// tests := []struct { +// name string +// path string +// passphrase string +// wantErr bool +// }{ +// {"key length empty", "", true}, +// } + +// // Take path +// // Resolve it to absolute +// // Check directory exists and is readable +// // If does not contain .obsidian warn +// // Prompt for a passphrase twice +// // Store under vault: +// // Append to config +// } diff --git a/internal/client/config/config.go b/internal/client/config/config.go index 7eb6161..1e5eb91 100644 --- a/internal/client/config/config.go +++ b/internal/client/config/config.go @@ -14,8 +14,10 @@ import ( var ErrNotFound = errors.New("config: not initialised, run `potok init`") -const maxVaultNameLen = 64 -const invalidNameChars = `/\:*?"<>|` + " " +const ( + maxVaultNameLen = 64 + invalidNameChars = `/\:*?"<>|` + " " +) type Config struct { ServerURL string `json:"server_url"` @@ -29,18 +31,6 @@ type Vault struct { LastSyncedAt *time.Time `json:"last_synced_at,omitempty"` } -type Store interface { - Load() (*Config, error) - Save(cfg *Config) error - Path() string -} - -type FileStore struct { - Dir string -} - -var _ Store = (*FileStore)(nil) - func Dir() (string, error) { if dir := os.Getenv("POTOK_CONFIG_DIR"); dir != "" { return dir, nil @@ -55,20 +45,20 @@ func Dir() (string, error) { return filepath.Join(home, ".potok"), nil } -func (s *FileStore) Path() string { - dir := s.Dir - if dir == "" { - resolved, err := Dir() - if err != nil { - return "config.json" - } - dir = resolved +func Path() (string, error) { + dir, err := Dir() + if err != nil { + return "", err } - return filepath.Join(dir, "config.json") + return filepath.Join(dir, "config.json"), nil } -func (s *FileStore) Load() (*Config, error) { - path := s.Path() +func Load() (*Config, error) { + path, err := Path() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return nil, ErrNotFound @@ -84,10 +74,14 @@ func (s *FileStore) Load() (*Config, error) { return &cfg, nil } -func (s *FileStore) Save(cfg *Config) error { +func Save(cfg *Config) error { if err := cfg.Validate(); err != nil { return err } + path, err := Path() + if err != nil { + return err + } data, err := json.MarshalIndent(cfg, "", " ") if err != nil { @@ -95,7 +89,6 @@ func (s *FileStore) Save(cfg *Config) error { } data = append(data, '\n') - path := s.Path() dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("config: create %s: %w", dir, err) @@ -107,7 +100,6 @@ func (s *FileStore) Save(cfg *Config) error { } tmpName := tmp.Name() defer func() { - _ = tmp.Close() _ = os.Remove(tmpName) }() @@ -118,7 +110,6 @@ func (s *FileStore) Save(cfg *Config) error { if _, err := tmp.Write(data); err != nil { return fmt.Errorf("config: write temp file: %w", err) } - if err := tmp.Sync(); err != nil { return fmt.Errorf("config: sync temp file: %w", err) } diff --git a/internal/client/config/config_test.go b/internal/client/config/config_test.go index 9800f92..21db7ce 100644 --- a/internal/client/config/config_test.go +++ b/internal/client/config/config_test.go @@ -6,87 +6,73 @@ import ( "path/filepath" "runtime" "testing" - "time" ) -func newStore(t *testing.T) *FileStore { +func tempConfig(t *testing.T) string { t.Helper() - return &FileStore{Dir: t.TempDir()} + dir := t.TempDir() + t.Setenv("POTOK_CONFIG_DIR", dir) + return dir } func TestLoadReturnsNotFoundWhenMissing(t *testing.T) { - store := newStore(t) + tempConfig(t) - _, err := store.Load() - - if !errors.Is(err, ErrNotFound) { + if _, err := Load(); !errors.Is(err, ErrNotFound) { t.Fatalf("Load() = %v, want ErrNotFound", err) } } -func TestSaveThenLoadRoundTrip(t *testing.T) { - store := newStore(t) - synced := time.Date(2026, time.March, 4, 12, 0, 0, 0, time.UTC) +func TestSaveThenLoad(t *testing.T) { + dir := tempConfig(t) want := &Config{ ServerURL: "https://potok.example.com", - Vaults: []Vault{ - {Name: "notes", Path: filepath.Join(store.Dir, "notes"), LastSyncedAt: &synced}, - {Name: "work", Path: filepath.Join(store.Dir, "work")}, - }, + Vaults: []Vault{{Name: "notes", Path: filepath.Join(dir, "notes")}}, } - if err := store.Save(want); err != nil { + if err := Save(want); err != nil { t.Fatalf("Save() = %v", err) } - got, err := store.Load() + got, err := Load() if err != nil { t.Fatalf("Load() = %v", err) } if got.ServerURL != want.ServerURL { t.Errorf("ServerURL = %q, want %q", got.ServerURL, want.ServerURL) } - if len(got.Vaults) != len(want.Vaults) { - t.Fatalf("len(Vaults) = %d, want %d", len(got.Vaults), len(want.Vaults)) - } - if got.Vaults[0].LastSyncedAt == nil || !got.Vaults[0].LastSyncedAt.Equal(synced) { - t.Errorf("Vaults[0].LastSyncedAt = %v, want %v", got.Vaults[0].LastSyncedAt, synced) + if len(got.Vaults) != 1 || got.Vaults[0].Name != "notes" { + t.Errorf("Vaults = %+v, want one vault called notes", got.Vaults) } - if got.Vaults[1].LastSyncedAt != nil { - t.Errorf("Vaults[1].LastSyncedAt = %v, want nil for a never-synced vault", got.Vaults[1].LastSyncedAt) + if got.Vaults[0].LastSyncedAt != nil { + t.Error("LastSyncedAt should be nil for a vault that has never synced") } } -func TestSaveOverwritesExistingConfig(t *testing.T) { - store := newStore(t) - first := &Config{ServerURL: "https://one.example.com"} - if err := store.Save(first); err != nil { - t.Fatalf("Save(first) = %v", err) - } +func TestSaveOverwritesAndLeavesNoTempFiles(t *testing.T) { + dir := tempConfig(t) - second := &Config{ServerURL: "https://two.example.com"} - if err := store.Save(second); err != nil { - t.Fatalf("Save(second) = %v", err) + if err := Save(&Config{ServerURL: "https://one.example.com"}); err != nil { + t.Fatalf("Save() = %v", err) + } + if err := Save(&Config{ServerURL: "https://two.example.com"}); err != nil { + t.Fatalf("Save() = %v", err) } - got, err := store.Load() + got, err := Load() if err != nil { t.Fatalf("Load() = %v", err) } - if got.ServerURL != second.ServerURL { - t.Errorf("ServerURL = %q, want %q", got.ServerURL, second.ServerURL) + if got.ServerURL != "https://two.example.com" { + t.Errorf("ServerURL = %q, want the second save to win", got.ServerURL) } - entries, err := os.ReadDir(store.Dir) + entries, err := os.ReadDir(dir) if err != nil { t.Fatalf("ReadDir() = %v", err) } if len(entries) != 1 { - names := make([]string, 0, len(entries)) - for _, e := range entries { - names = append(names, e.Name()) - } - t.Errorf("config directory holds %v, want config.json only", names) + t.Errorf("directory holds %d files, want config.json only", len(entries)) } } @@ -94,12 +80,13 @@ func TestSaveUsesRestrictivePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("Unix file modes are not meaningful on Windows") } - store := newStore(t) - if err := store.Save(&Config{ServerURL: "https://potok.example.com"}); err != nil { + tempConfig(t) + if err := Save(&Config{ServerURL: "https://potok.example.com"}); err != nil { t.Fatalf("Save() = %v", err) } - info, err := os.Stat(store.Path()) + path, _ := Path() + info, err := os.Stat(path) if err != nil { t.Fatalf("Stat() = %v", err) } @@ -109,26 +96,27 @@ func TestSaveUsesRestrictivePermissions(t *testing.T) { } func TestSaveRejectsInvalidConfig(t *testing.T) { - store := newStore(t) + tempConfig(t) - err := store.Save(&Config{ServerURL: "ftp://example.com"}) - - if err == nil { - t.Fatal("Save() = nil, want an error for an invalid config") + if err := Save(&Config{ServerURL: "ftp://example.com"}); err == nil { + t.Fatal("Save() = nil, want an error") } - if _, statErr := os.Stat(store.Path()); statErr == nil { + + path, _ := Path() + if _, err := os.Stat(path); err == nil { t.Error("Save() wrote a file despite failing validation") } } func TestLoadRejectsMalformedJSON(t *testing.T) { - store := newStore(t) - if err := os.WriteFile(store.Path(), []byte("{not json"), 0o600); err != nil { + tempConfig(t) + path, _ := Path() + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { t.Fatalf("WriteFile() = %v", err) } - if _, err := store.Load(); err == nil { - t.Fatal("Load() = nil, want an error for malformed JSON") + if _, err := Load(); err == nil { + t.Fatal("Load() = nil, want a parse error") } } @@ -137,56 +125,20 @@ func TestValidate(t *testing.T) { cfg Config wantErr bool }{ - "zero value": { - cfg: Config{}, - }, - "valid": { - cfg: Config{ - ServerURL: "http://localhost:8080", - Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}, - }, - }, - "unsupported url scheme": { - cfg: Config{ServerURL: "ftp://example.com"}, - wantErr: true, - }, - "url without a host": { - cfg: Config{ServerURL: "https://"}, - wantErr: true, - }, - "relative vault path": { - cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, - wantErr: true, - }, - "duplicate vault names": { - cfg: Config{Vaults: []Vault{ - {Name: "notes", Path: "/a"}, - {Name: "notes", Path: "/b"}, - }}, - wantErr: true, - }, - "empty vault name": { - cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, - wantErr: true, - }, - "vault name with a space": { - cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, - wantErr: true, - }, - "vault name with a path separator": { - cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, - wantErr: true, - }, - "vault name starting with a dash": { - cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, - wantErr: true, - }, + "zero value": {cfg: Config{}}, + "valid": {cfg: Config{ServerURL: "http://localhost:8080", Vaults: []Vault{{Name: "notes", Path: "/home/user/notes"}}}}, + "bad scheme": {cfg: Config{ServerURL: "ftp://example.com"}, wantErr: true}, + "no host": {cfg: Config{ServerURL: "https://"}, wantErr: true}, + "relative path": {cfg: Config{Vaults: []Vault{{Name: "notes", Path: "notes"}}}, wantErr: true}, + "duplicate names": {cfg: Config{Vaults: []Vault{{Name: "a", Path: "/a"}, {Name: "a", Path: "/b"}}}, wantErr: true}, + "empty name": {cfg: Config{Vaults: []Vault{{Name: "", Path: "/a"}}}, wantErr: true}, + "name with space": {cfg: Config{Vaults: []Vault{{Name: "my notes", Path: "/a"}}}, wantErr: true}, + "name with separator": {cfg: Config{Vaults: []Vault{{Name: "work/notes", Path: "/a"}}}, wantErr: true}, + "name starts with -": {cfg: Config{Vaults: []Vault{{Name: "-force", Path: "/a"}}}, wantErr: true}, } - for name, tc := range tests { t.Run(name, func(t *testing.T) { - err := tc.cfg.Validate() - if (err != nil) != tc.wantErr { + if err := tc.cfg.Validate(); (err != nil) != tc.wantErr { t.Fatalf("Validate() = %v, wantErr %v", err, tc.wantErr) } }) @@ -199,28 +151,24 @@ func TestAddVault(t *testing.T) { if err := cfg.AddVault(Vault{Name: "notes", Path: "/home/user/notes"}); err != nil { t.Fatalf("AddVault() = %v", err) } - if len(cfg.Vaults) != 1 { - t.Fatalf("len(Vaults) = %d, want 1", len(cfg.Vaults)) - } - - if err := cfg.AddVault(Vault{Name: "notes", Path: "/somewhere/else"}); err == nil { + if err := cfg.AddVault(Vault{Name: "notes", Path: "/elsewhere"}); err == nil { t.Error("AddVault() = nil, want an error for a duplicate name") } + if err := cfg.AddVault(Vault{Name: "work", Path: "relative"}); err == nil { + t.Error("AddVault() = nil, want an error for a relative path") + } if len(cfg.Vaults) != 1 { - t.Errorf("len(Vaults) = %d after a rejected add, want 1", len(cfg.Vaults)) + t.Errorf("len(Vaults) = %d, want 1", len(cfg.Vaults)) } } func TestRemoveVault(t *testing.T) { - cfg := &Config{Vaults: []Vault{ - {Name: "notes", Path: "/a"}, - {Name: "work", Path: "/b"}, - }} + cfg := &Config{Vaults: []Vault{{Name: "notes", Path: "/a"}, {Name: "work", Path: "/b"}}} - if removed := cfg.RemoveVault("notes"); !removed { + if !cfg.RemoveVault("notes") { t.Error("RemoveVault(notes) = false, want true") } - if removed := cfg.RemoveVault("notes"); removed { + if cfg.RemoveVault("notes") { t.Error("RemoveVault(notes) = true on the second call, want false") } if len(cfg.Vaults) != 1 || cfg.Vaults[0].Name != "work" { @@ -228,16 +176,16 @@ func TestRemoveVault(t *testing.T) { } } -func TestVaultLookup(t *testing.T) { +func TestVaultReturnsPointer(t *testing.T) { cfg := &Config{Vaults: []Vault{{Name: "notes", Path: "/a"}}} - got, ok := cfg.Vault("notes") + v, ok := cfg.Vault("notes") if !ok { t.Fatal("Vault(notes) not found") } - got.RemoteID = "vault_123" + v.RemoteID = "vault_123" if cfg.Vaults[0].RemoteID != "vault_123" { - t.Error("Vault() returned a copy, want a pointer into the slice") + t.Error("Vault() returned a copy; updates would be lost") } if _, ok := cfg.Vault("missing"); ok { @@ -245,28 +193,15 @@ func TestVaultLookup(t *testing.T) { } } -func TestDirPrefersPotokConfigDir(t *testing.T) { +func TestDirPrecedence(t *testing.T) { t.Setenv("POTOK_CONFIG_DIR", "/custom/potok") t.Setenv("XDG_CONFIG_HOME", "/xdg") - - got, err := Dir() - if err != nil { - t.Fatalf("Dir() = %v", err) - } - if got != "/custom/potok" { - t.Errorf("Dir() = %q, want /custom/potok", got) + if got, _ := Dir(); got != "/custom/potok" { + t.Errorf("Dir() = %q, want POTOK_CONFIG_DIR to win", got) } -} -func TestDirFallsBackToXDG(t *testing.T) { t.Setenv("POTOK_CONFIG_DIR", "") - t.Setenv("XDG_CONFIG_HOME", "/xdg") - - got, err := Dir() - if err != nil { - t.Fatalf("Dir() = %v", err) - } - if want := filepath.Join("/xdg", "potok"); got != want { - t.Errorf("Dir() = %q, want %q", got, want) + if got, _ := Dir(); got != filepath.Join("/xdg", "potok") { + t.Errorf("Dir() = %q, want $XDG_CONFIG_HOME/potok", got) } } diff --git a/internal/server/config/config.go b/internal/server/config/config.go new file mode 100644 index 0000000..a37aef8 --- /dev/null +++ b/internal/server/config/config.go @@ -0,0 +1,45 @@ +package config + +import ( + "errors" + "os" + + "github.com/joho/godotenv" +) + +type Config struct { + Addr string + DataDir string + DatabaseURL string +} + +func LoadConfig() (Config, error) { + if err := godotenv.Load(); err != nil && !errors.Is(err, os.ErrNotExist) { + return Config{}, err + } + + cfg := Config{ + Addr: os.Getenv("ADDR"), + DataDir: os.Getenv("DATA_DIR"), + DatabaseURL: os.Getenv("DATABASE_URL"), + } + + if err := cfg.ValidateConfig(); err != nil { + return Config{}, err + } + + return cfg, nil +} + +func (cfg Config) ValidateConfig() error { + if cfg.Addr == "" { + return errors.New("ADDR is required") + } + if cfg.DataDir == "" { + return errors.New("DATA_DIR is required") + } + if cfg.DatabaseURL == "" { + return errors.New("DATABASE_URL is required") + } + return nil +} diff --git a/internal/server/config/config_test.go b/internal/server/config/config_test.go new file mode 100644 index 0000000..1f46bc8 --- /dev/null +++ b/internal/server/config/config_test.go @@ -0,0 +1,56 @@ +package config + +import ( + "strings" + "testing" +) + +func setAllEnv(t *testing.T) { + t.Helper() + t.Setenv("ADDR", "8080") + t.Setenv("DATA_DIR", "/home/") + t.Setenv("DATABASE_URL", "postgres://user:pass@localhost:5432/mydb?sslmode=disable") +} + +func TestLoadConfigMissingRequiredEnv(t *testing.T) { + requiredVars := []string{"ADDR", "DATA_DIR", "DATABASE_URL"} + for _, v := range requiredVars { + t.Setenv(v, "") + } + + for _, missing := range requiredVars { + t.Run("missing_"+missing, func(t *testing.T) { + setAllEnv(t) + t.Setenv(missing, "") + + _, err := LoadConfig() + if err == nil { + t.Fatalf("LoadConfig() expected error when %s is missing, got nil", missing) + } + if !strings.Contains(err.Error(), missing) { + t.Errorf("error %q should mention the missing variable %s", err, missing) + } + }) + } +} + +func TestLoadConfig(t *testing.T) { + setAllEnv(t) + + cfg, err := LoadConfig() + if err != nil { + t.Fatalf("LoadConfig() unexpected error: %v", err) + } + + if cfg.Addr != "8080" { + t.Errorf("Addr = %q, want %q", cfg.Addr, "8080") + } + + if cfg.DataDir != "/home/" { + t.Errorf("DataDir = %q, want %q", cfg.DataDir, "/home/") + } + + if cfg.DatabaseURL != "postgres://user:pass@localhost:5432/mydb?sslmode=disable" { + t.Errorf("DatabaseURL = %q, want %q", cfg.DatabaseURL, "postgres://user:pass@localhost:5432/mydb?sslmode=disable") + } +} diff --git a/internal/server/http/doc.go b/internal/server/http/doc.go deleted file mode 100644 index 2da735d..0000000 --- a/internal/server/http/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package httpapi serves the Potok HTTP API. -package httpapi diff --git a/internal/server/http/http.go b/internal/server/http/http.go new file mode 100644 index 0000000..b7f8594 --- /dev/null +++ b/internal/server/http/http.go @@ -0,0 +1,20 @@ +package http + +import ( + "net/http" + + "github.com/michaeltukdev/Potok/internal/server/store" +) + +type Handler struct { + store *store.Store +} + +func NewHandler(s *store.Store) *Handler { + return &Handler{store: s} +} + +func (h *Handler) Health(w http.ResponseWriter, r *http.Request) { + http.ResponseWriter.WriteHeader(w, http.StatusOK) + _, _ = http.ResponseWriter.Write(w, []byte("OK")) +} diff --git a/internal/server/http/http_test.go b/internal/server/http/http_test.go new file mode 100644 index 0000000..cab9a17 --- /dev/null +++ b/internal/server/http/http_test.go @@ -0,0 +1,23 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthEndpoint(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + + response := httptest.NewRecorder() + handler := NewHandler(nil) + handler.Health(response, req) + + if response.Code != http.StatusOK { + t.Errorf("expected status code %d, got %d", http.StatusOK, response.Code) + } + + if response.Body.String() != "OK" { + t.Errorf("expected body %s, got %s", "OK", response.Body.String()) + } +} diff --git a/internal/server/store/doc.go b/internal/server/store/doc.go deleted file mode 100644 index 1ba742b..0000000 --- a/internal/server/store/doc.go +++ /dev/null @@ -1,2 +0,0 @@ -// Package store persists vault metadata. -package store diff --git a/internal/server/store/store.go b/internal/server/store/store.go new file mode 100644 index 0000000..ce67cb6 --- /dev/null +++ b/internal/server/store/store.go @@ -0,0 +1,54 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Store struct { + pool *pgxpool.Pool +} + +type Vault struct{} + +func Open(ctx context.Context, dsn string) (*Store, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, fmt.Errorf("store: parse database url: %w", err) + } + cfg.MaxConns = 10 + cfg.MaxConnLifetime = time.Hour + + pool, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + return nil, fmt.Errorf("store: connect: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("store: ping: %w", err) + } + + return &Store{pool: pool}, nil +} + +func (s *Store) Close() { s.pool.Close() } + +func (s *Store) CreateVault(ctx context.Context, name string, wrappedKey []byte) (Vault, error) { + return Vault{}, nil +} + +func (s *Store) VaultByName(ctx context.Context, name string) (Vault, error) { + return Vault{}, nil +} + +func (s *Store) ListVaults(ctx context.Context) ([]Vault, error) { + return []Vault{}, nil +} + +func (s *Store) DeleteVault(ctx context.Context, name string) error { + return nil +}