package config import ( "bufio" "crypto/sha256" "fmt" "os" "strconv" "strings" "time" ) // DefaultClassifyModel is the model used for receipt classification unless // CLASSIFY_MODEL overrides it. Deliberately a cheap model so routine uploads // don't burn API credits; override with a stronger model only when needed. const DefaultClassifyModel = "claude-haiku-4-5-20251001" // Config holds all runtime configuration, loaded from environment variables. type Config struct { IssuerURL string // OIDC issuer, e.g. https://auth.maisym.com ClientID string // OIDC client_id ClientSecret string // OIDC client secret (plaintext) RedirectURL string // e.g. http://localhost:8080/callback RequiredGroup string // group that gates access, e.g. hsa-users SessionKey [32]byte // AES-256 key derived from SESSION_SECRET ListenAddr string // e.g. :8080 SecureCookies bool // true when serving over https DBPath string // SQLite file path StorageDir string // directory for receipt files on disk MaxUploadBytes int64 // reject uploads larger than this ConfigPath string // path to config.json (people + categories) ClassifyAPIKey string // Anthropic API key; empty disables auto-classification ClassifyModel string // model id for classification BackupDir string // directory for scheduled metadata-only DB backups BackupEvery time.Duration // backup interval; <= 0 disables scheduled backups BackupKeep int // number of most-recent backups to retain } // Load reads configuration from the environment, validating required values. func Load() (Config, error) { var c Config var missing []string get := func(key string) string { v := strings.TrimSpace(os.Getenv(key)) if v == "" { missing = append(missing, key) } return v } c.IssuerURL = get("ISSUER_URL") c.ClientID = get("OIDC_CLIENT_ID") c.ClientSecret = get("OIDC_CLIENT_SECRET") c.RedirectURL = get("REDIRECT_URL") c.RequiredGroup = get("REQUIRED_GROUP") sessionSecret := get("SESSION_SECRET") if len(missing) > 0 { return Config{}, fmt.Errorf("missing required env vars: %s", strings.Join(missing, ", ")) } // Derive a fixed 32-byte AES key from whatever secret string was provided. c.SessionKey = sha256.Sum256([]byte(sessionSecret)) c.ListenAddr = strings.TrimSpace(os.Getenv("LISTEN_ADDR")) if c.ListenAddr == "" { c.ListenAddr = ":8080" } c.SecureCookies = strings.HasPrefix(c.RedirectURL, "https://") c.DBPath = envOr("DB_PATH", "./data/hsa.db") // STORAGE_DIR is the storage ROOT; receipts and attachments are stored under // /receipts// and /attachments//. c.StorageDir = envOr("STORAGE_DIR", "./data") maxMB := 32 if v := strings.TrimSpace(os.Getenv("MAX_UPLOAD_MB")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { maxMB = n } } c.MaxUploadBytes = int64(maxMB) * 1024 * 1024 // Domain config + classification (all optional; classification is best-effort). c.ConfigPath = envOr("CONFIG_PATH", "./config.json") c.ClassifyAPIKey = strings.TrimSpace(os.Getenv("CLAUDE_API_KEY")) c.ClassifyModel = envOr("CLASSIFY_MODEL", DefaultClassifyModel) // Scheduled metadata-only DB backups (on by default, weekly). Set // BACKUP_INTERVAL=0 to disable. c.BackupDir = envOr("BACKUP_DIR", "./data/backups") c.BackupEvery = 7 * 24 * time.Hour if v := strings.TrimSpace(os.Getenv("BACKUP_INTERVAL")); v != "" { if d, err := time.ParseDuration(v); err == nil { c.BackupEvery = d } } c.BackupKeep = 8 if v := strings.TrimSpace(os.Getenv("BACKUP_KEEP")); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { c.BackupKeep = n } } return c, nil } func envOr(key, def string) string { if v := strings.TrimSpace(os.Getenv(key)); v != "" { return v } return def } // LoadDotEnv is a best-effort loader that reads KEY=VALUE lines from path into // the process environment if the file exists. Existing env vars are not overwritten. // Intended for local development convenience only. func LoadDotEnv(path string) error { f, err := os.Open(path) if err != nil { if os.IsNotExist(err) { return nil } return err } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.Trim(strings.TrimSpace(val), `"'`) if _, exists := os.LookupEnv(key); !exists { os.Setenv(key, val) } } return scanner.Err() }