package config import ( "bufio" "os" "path/filepath" "strconv" "strings" ) const defaultAddr = ":8080" const defaultDataDir = ".platform-data" const defaultStorageBackend = "file" const defaultBuilderDockerBinary = "docker" // defaultBuilderImage names an explicit toolchain version rather than a // floating tag such as latest. Operators who want digest pinning override // PLATFORM_BUILDER_IMAGE with an image@sha256:... reference. const defaultBuilderImage = "browser-platform-distribution-builder:1.0.0" const defaultBuilderTimeoutSeconds = 1800 type Config struct { Addr string StorageBackend string MySQLDSN string DataDir string MetadataPath string LogDir string ArtifactDir string LogBodyBackend string BootstrapAdminEmail string BootstrapAdminPassword string SecretEnvelopeKey string AIProviderMode string BuilderDockerBinary string BuilderImage string BuilderSourceDir string BuilderWorkspaceDir string BuilderTimeoutSeconds int } func Load() Config { loadLocalEnvFiles() addr := os.Getenv("PLATFORM_ADDR") if addr == "" { addr = defaultAddr } dataDir := strings.TrimSpace(os.Getenv("PLATFORM_DATA_DIR")) if dataDir == "" { dataDir = defaultDataDir } metadataPath := strings.TrimSpace(os.Getenv("PLATFORM_METADATA_PATH")) if metadataPath == "" { metadataPath = filepath.Join(dataDir, "metadata.json") } logDir := strings.TrimSpace(os.Getenv("PLATFORM_LOG_DIR")) if logDir == "" { logDir = filepath.Join(dataDir, "logs") } artifactDir := strings.TrimSpace(os.Getenv("PLATFORM_ARTIFACT_DIR")) if artifactDir == "" { artifactDir = filepath.Join(dataDir, "artifacts") } storageBackend := strings.TrimSpace(os.Getenv("PLATFORM_STORAGE_BACKEND")) if storageBackend == "" { storageBackend = defaultStorageBackend } logBodyBackend := strings.TrimSpace(os.Getenv("PLATFORM_LOG_BODY_BACKEND")) builderWorkspaceDir := strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_WORKSPACE_DIR")) if builderWorkspaceDir == "" { builderWorkspaceDir = filepath.Join(dataDir, "distribution-builds") } return Config{ Addr: addr, StorageBackend: storageBackend, MySQLDSN: strings.TrimSpace(os.Getenv("PLATFORM_MYSQL_DSN")), DataDir: dataDir, MetadataPath: metadataPath, LogDir: logDir, ArtifactDir: artifactDir, LogBodyBackend: logBodyBackend, BootstrapAdminEmail: strings.TrimSpace(os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_EMAIL")), BootstrapAdminPassword: os.Getenv("PLATFORM_BOOTSTRAP_ADMIN_PASSWORD"), SecretEnvelopeKey: os.Getenv("PLATFORM_SECRET_ENVELOPE_KEY"), AIProviderMode: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_AI_PROVIDER_MODE")), "live"), BuilderDockerBinary: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_DOCKER_BINARY")), defaultBuilderDockerBinary), BuilderImage: defaultString(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_IMAGE")), defaultBuilderImage), BuilderSourceDir: strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_SOURCE_DIR")), BuilderWorkspaceDir: builderWorkspaceDir, BuilderTimeoutSeconds: defaultPositiveInt(strings.TrimSpace(os.Getenv("PLATFORM_BUILDER_TIMEOUT_SECONDS")), defaultBuilderTimeoutSeconds), } } func defaultPositiveInt(value string, fallback int) int { parsed, err := strconv.Atoi(value) if err != nil || parsed <= 0 { return fallback } return parsed } func defaultString(value, fallback string) string { if value == "" { return fallback } return value } func loadLocalEnvFiles() { candidates := []string{".env", filepath.Join("platform", ".env")} for _, path := range candidates { loadEnvFile(path) } } func loadEnvFile(path string) { file, err := os.Open(path) if err != nil { return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { key, value, ok := parseEnvLine(scanner.Text()) if !ok { continue } if _, exists := os.LookupEnv(key); exists { continue } _ = os.Setenv(key, value) } } func parseEnvLine(line string) (string, string, bool) { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { return "", "", false } line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) key, value, found := strings.Cut(line, "=") if !found { return "", "", false } key = strings.TrimSpace(key) if key == "" || strings.ContainsAny(key, " \t") { return "", "", false } value = strings.TrimSpace(stripInlineComment(strings.TrimSpace(value))) if len(value) >= 2 { if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { value = value[1 : len(value)-1] } } return key, value, true } func stripInlineComment(value string) string { inSingleQuote := false inDoubleQuote := false for index, char := range value { switch char { case '\'': if !inDoubleQuote { inSingleQuote = !inSingleQuote } case '"': if !inSingleQuote { inDoubleQuote = !inDoubleQuote } case '#': if !inSingleQuote && !inDoubleQuote && index > 0 && value[index-1] == ' ' { return strings.TrimSpace(value[:index]) } } } return value }