// Package config loads server configuration from environment variables, // optionally seeded from a .env file in the working directory. The PocketBase // connection is also editable at runtime from the panel, which persists the // change back into the same .env via UpdateEnvFile. package config import ( "os" "strings" ) // Config holds all runtime configuration for the API Server. type Config struct { Addr string PocketBaseURL string WebAppURL string AllowOrigins []string // UsersCollection is the PocketBase auth collection holding app users. UsersCollection string // PluginsFile is the local JSON store for plugin enable-state + config. PluginsFile string // Superuser service account. Every privileged flow (user/organization // management, all car-domain database access) runs through it. Optional at // startup: when unset those endpoints return 503 and a superadmin can still // log in to the panel to configure it. PocketBaseAdminEmail string PocketBaseAdminPassword string } // EnvFile is the .env path (relative to the working directory) that Load reads // and that runtime settings changes persist back into. const EnvFile = ".env" // AdminConfigured reports whether a service account has been supplied. func (c Config) AdminConfigured() bool { return c.PocketBaseAdminEmail != "" && c.PocketBaseAdminPassword != "" } // Load reads configuration from environment variables, applying sensible // defaults. A .env file, if present in the working directory, is loaded first. func Load() Config { loadDotEnv(EnvFile) return Config{ Addr: normalizeAddr(firstEnv("API_ADDR", "PORT"), ":8080"), PocketBaseURL: strings.TrimRight(firstEnvOr("http://10.2.1.10:8027", "POCKETBASE_URL", "PB_URL"), "/"), WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:5173"), "/"), AllowOrigins: splitCSV(firstEnvOr("*", "CORS_ALLOW_ORIGINS", "CORS_ORIGINS")), UsersCollection: getenv("AUTH_USERS_COLLECTION", "users"), PluginsFile: getenv("PLUGINS_FILE", "plugins.json"), PocketBaseAdminEmail: firstEnv("POCKETBASE_ADMIN_EMAIL", "PB_ADMIN_EMAIL"), PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"), } } // normalizeAddr accepts either a full listen address (":8080") or a bare port // ("8080", which is what the legacy PORT variable held) and returns a listen // address. func normalizeAddr(v, def string) string { if v == "" { return def } if strings.Contains(v, ":") { return v } return ":" + v } // UpdateEnvFile persists the given KEY=VALUE pairs into the .env file at path, // replacing existing keys in place and appending new ones, while preserving all // other lines (comments, ordering, unrelated keys). The file is created if it // does not exist. Written with 0600 perms since it holds secrets. func UpdateEnvFile(path string, updates map[string]string) error { existing, _ := os.ReadFile(path) // missing file → start empty remaining := make(map[string]string, len(updates)) for k, v := range updates { remaining[k] = v } var out []string for _, line := range strings.Split(string(existing), "\n") { trimmed := strings.TrimSpace(line) if trimmed == "" || strings.HasPrefix(trimmed, "#") { out = append(out, line) continue } key, _, ok := strings.Cut(trimmed, "=") key = strings.TrimSpace(key) if ok { if v, found := remaining[key]; found { out = append(out, key+"="+v) delete(remaining, key) continue } } out = append(out, line) } // Append any keys that weren't already present. for k, v := range remaining { out = append(out, k+"="+v) } content := strings.Join(out, "\n") if !strings.HasSuffix(content, "\n") { content += "\n" } return os.WriteFile(path, []byte(content), 0o600) } func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // firstEnv returns the first of keys that is set to a non-empty value. It lets // the modern POCKETBASE_* names take precedence while the legacy PB_* names from // older deployments keep working. func firstEnv(keys ...string) string { for _, k := range keys { if v := os.Getenv(k); v != "" { return v } } return "" } // firstEnvOr is firstEnv with a fallback when none of the keys are set. func firstEnvOr(def string, keys ...string) string { if v := firstEnv(keys...); v != "" { return v } return def } func splitCSV(s string) []string { parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out } // loadDotEnv loads KEY=VALUE pairs from a .env file into the process env if they // are not already set. It is intentionally minimal (no quoting rules beyond // trimming surrounding quotes). func loadDotEnv(path string) { data, err := os.ReadFile(path) if err != nil { return // .env is optional } for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) 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) } } }