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 // PluginsFile is the local JSON store for plugin enable-state + config. PluginsFile string // Superuser service account used ONLY for admin user-management // (list/create/delete users). Optional: when unset, those endpoints return // 503 and the rest of the server is unaffected. 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" // 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) cfg := Config{ Addr: getenv("API_ADDR", ":8080"), PocketBaseURL: strings.TrimRight(pocketBaseURL(), "/"), WebAppURL: strings.TrimRight(getenv("WEBAPP_URL", "http://localhost:8090"), "/"), AllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")), PluginsFile: getenv("PLUGINS_FILE", "plugins.json"), PocketBaseAdminEmail: getenv("POCKETBASE_ADMIN_EMAIL", os.Getenv("PB_ADMIN_EMAIL")), PocketBaseAdminPassword: getenv("POCKETBASE_ADMIN_PASSWORD", os.Getenv("PB_ADMIN_PASSWORD")), } return cfg } // pocketBaseURL resolves the PocketBase base URL, honouring the legacy PB_URL // variable for backward compatibility with older deployments. func pocketBaseURL() string { if v := os.Getenv("POCKETBASE_URL"); v != "" { return v } if v := os.Getenv("PB_URL"); v != "" { return v } return "http://10.2.1.10:8026" } // 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 } 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 } 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) } } }