// 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 // ViewTokenSecret signs the hour-bucketed gallery view tokens // (viewtoken.go). PHOTO_VIEW_SECRET, falling back to the legacy // JWT_SECRET during the Better Auth migration. ViewTokenSecret string // StorageBackend selects the active backend: "auto" (S3 when it is // configured, local otherwise), "local" or "s3". Explicit values let both // backends stay configured — required to run `photo-api sync`, and the // one-line switch after a sync. StorageBackend 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 } // S3Configured reports whether the S3 credentials are present at all, // mirroring backend/src/lib/storage.ts: both S3_ENDPOINT and S3_BUCKET set. // Kept separate from S3Enabled so a configured-but-inactive S3 backend can // still be reached by `photo-api sync`. func (c Config) S3Configured() bool { return c.S3Endpoint != "" && c.S3Bucket != "" } // S3Enabled reports whether S3 is the backend serving requests. func (c Config) S3Enabled() bool { switch c.StorageBackend { case "s3": return true case "local": return false default: // auto return c.S3Configured() } } func Load() (Config, error) { loadDotenv(".env") cfg := Config{ Port: envInt("PORT", 3003), DBType: strings.ToLower(env("DB_TYPE", "sqlite")), DatabaseURL: env("DATABASE_URL", ""), ViewTokenSecret: env("PHOTO_VIEW_SECRET", env("JWT_SECRET", "")), StorageBackend: strings.ToLower(env("STORAGE_BACKEND", "auto")), 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.ViewTokenSecret == "" { return cfg, fmt.Errorf("PHOTO_VIEW_SECRET is required (or legacy JWT_SECRET as fallback)") } switch cfg.StorageBackend { case "auto", "local", "s3": default: return cfg, fmt.Errorf("STORAGE_BACKEND must be auto, local or s3, got %q", cfg.StorageBackend) } if cfg.StorageBackend == "s3" && !cfg.S3Configured() { return cfg, fmt.Errorf("STORAGE_BACKEND=s3 requires S3_ENDPOINT and S3_BUCKET") } 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) } } }