// Package config loads photo-api configuration from the environment, // following the backend's convention of a per-service .env file with // ad-hoc defaults (backend/src/index.ts uses dotenv the same way). package config import ( "bufio" "fmt" "os" "strconv" "strings" ) type Config struct { Port int DBType string // "postgres" | "sqlite", same convention as backend DB_TYPE DatabaseURL string JWTSecret string StoragePath string S3Endpoint string S3Region string S3Bucket string S3AccessKeyID string S3SecretKey string S3ForcePathStyle bool MaxUploadMB int WorkerConcurrency int FrontendURL string HeicConverter string // optional explicit converter command; autodetected when empty } // S3Enabled mirrors backend/src/lib/storage.ts: S3 is active when both // S3_ENDPOINT and S3_BUCKET are set. func (c Config) S3Enabled() bool { return c.S3Endpoint != "" && c.S3Bucket != "" } func Load() (Config, error) { loadDotenv(".env") cfg := Config{ Port: envInt("PORT", 3003), DBType: strings.ToLower(env("DB_TYPE", "sqlite")), DatabaseURL: env("DATABASE_URL", ""), JWTSecret: env("JWT_SECRET", ""), StoragePath: env("STORAGE_PATH", "./data/photos"), S3Endpoint: env("S3_ENDPOINT", ""), S3Region: env("S3_REGION", "auto"), S3Bucket: env("S3_BUCKET", ""), S3AccessKeyID: env("S3_ACCESS_KEY_ID", ""), S3SecretKey: env("S3_SECRET_ACCESS_KEY", ""), S3ForcePathStyle: envBool("S3_FORCE_PATH_STYLE", true), MaxUploadMB: envInt("MAX_UPLOAD_MB", 50), WorkerConcurrency: envInt("WORKER_CONCURRENCY", 2), FrontendURL: strings.TrimRight(env("FRONTEND_URL", "http://localhost:3000"), "/"), HeicConverter: env("HEIC_CONVERTER", ""), } if cfg.DBType != "postgres" && cfg.DBType != "sqlite" { return cfg, fmt.Errorf("DB_TYPE must be postgres or sqlite, got %q", cfg.DBType) } if cfg.DatabaseURL == "" { return cfg, fmt.Errorf("DATABASE_URL is required") } if cfg.JWTSecret == "" { return cfg, fmt.Errorf("JWT_SECRET is required (must match backend/.env)") } return cfg, nil } func env(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func envInt(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil { return n } } return def } func envBool(key string, def bool) bool { if v := os.Getenv(key); v != "" { if b, err := strconv.ParseBool(v); err == nil { return b } } return def } // loadDotenv is a minimal KEY=VALUE loader (comments and blank lines // ignored, optional surrounding quotes stripped, existing env wins). func loadDotenv(path string) { f, err := os.Open(path) if err != nil { return } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } key, val, ok := strings.Cut(line, "=") if !ok { continue } key = strings.TrimSpace(key) val = strings.TrimSpace(val) if len(val) >= 2 && (val[0] == '"' || val[0] == '\'') && val[len(val)-1] == val[0] { val = val[1 : len(val)-1] } if _, exists := os.LookupEnv(key); !exists { os.Setenv(key, val) } } }