From 0d42eb2db01ade9342716ac34d5d60c7a77445fb Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 1 Jul 2026 16:27:31 +0700 Subject: [PATCH] feat(config): env-based configuration with validation --- go.mod | 3 ++ internal/config/config.go | 60 ++++++++++++++++++++++++++++++++++ internal/config/config_test.go | 37 +++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..760c62b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/vasyansk/imap-copier + +go 1.26.4 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..cb91069 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,60 @@ +package config + +import ( + "encoding/base64" + "fmt" + "os" + "strconv" +) + +type Config struct { + HTTPAddr string + DatabaseURL string + AuthUser string + AuthPass string + EncKey []byte + SessionSecret []byte + WorkerConcurrency int +} + +func Load() (Config, error) { + c := Config{ + HTTPAddr: getenv("HTTP_ADDR", ":8080"), + DatabaseURL: os.Getenv("DATABASE_URL"), + AuthUser: os.Getenv("AUTH_USER"), + AuthPass: os.Getenv("AUTH_PASS"), + SessionSecret: []byte(os.Getenv("SESSION_SECRET")), + WorkerConcurrency: 4, + } + if v := os.Getenv("WORKER_CONCURRENCY"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + return Config{}, fmt.Errorf("WORKER_CONCURRENCY invalid: %q", v) + } + c.WorkerConcurrency = n + } + for k, v := range map[string]string{ + "DATABASE_URL": c.DatabaseURL, "AUTH_USER": c.AuthUser, + "AUTH_PASS": c.AuthPass, "SESSION_SECRET": string(c.SessionSecret), + } { + if v == "" { + return Config{}, fmt.Errorf("%s is required", k) + } + } + key, err := base64.StdEncoding.DecodeString(os.Getenv("ENC_KEY")) + if err != nil { + return Config{}, fmt.Errorf("ENC_KEY must be base64: %w", err) + } + if len(key) != 32 { + return Config{}, fmt.Errorf("ENC_KEY must decode to 32 bytes, got %d", len(key)) + } + c.EncKey = key + return c, nil +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0c1945b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "encoding/base64" + "testing" +) + +func TestLoadRequiresEncKey32Bytes(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x") + t.Setenv("AUTH_USER", "admin") + t.Setenv("AUTH_PASS", "pass") + t.Setenv("SESSION_SECRET", "secret") + t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 16))) // wrong size + + if _, err := Load(); err == nil { + t.Fatal("expected error for 16-byte ENC_KEY, got nil") + } +} + +func TestLoadDefaults(t *testing.T) { + t.Setenv("DATABASE_URL", "postgres://x") + t.Setenv("AUTH_USER", "admin") + t.Setenv("AUTH_PASS", "pass") + t.Setenv("SESSION_SECRET", "secret") + t.Setenv("ENC_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32))) + + cfg, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.HTTPAddr != ":8080" { + t.Errorf("HTTPAddr = %q, want :8080", cfg.HTTPAddr) + } + if cfg.WorkerConcurrency != 4 { + t.Errorf("WorkerConcurrency = %d, want 4", cfg.WorkerConcurrency) + } +}