2026-06-18 01:40:12 +00:00
|
|
|
package config
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bufio"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
2026-06-19 11:12:50 +00:00
|
|
|
"time"
|
2026-06-18 01:40:12 +00:00
|
|
|
)
|
|
|
|
|
|
2026-06-19 02:04:42 +00:00
|
|
|
// 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"
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
// 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
|
2026-06-19 11:12:50 +00:00
|
|
|
|
|
|
|
|
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
|
2026-06-18 01:40:12 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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")
|
2026-06-19 11:12:50 +00:00
|
|
|
// STORAGE_DIR is the storage ROOT; receipts and attachments are stored under
|
|
|
|
|
// <STORAGE_DIR>/receipts/<year>/ and <STORAGE_DIR>/attachments/<year>/.
|
|
|
|
|
c.StorageDir = envOr("STORAGE_DIR", "./data")
|
2026-06-18 01:40:12 +00:00
|
|
|
|
|
|
|
|
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"))
|
2026-06-19 02:04:42 +00:00
|
|
|
c.ClassifyModel = envOr("CLASSIFY_MODEL", DefaultClassifyModel)
|
2026-06-18 01:40:12 +00:00
|
|
|
|
2026-06-19 11:12:50 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 01:40:12 +00:00
|
|
|
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()
|
|
|
|
|
}
|