// Package config reads all settings from environment variables. // Names and defaults match the table in the README. package config import ( "fmt" "log" "net/url" "os" "path/filepath" "strconv" "strings" ) type Config struct { Port int SSHPort int DataDir string OwnerDisplayName string BaseURL string PublicHTTPS bool PublicOrigin string PublicHost string // host[:port] of BaseURL, what image names start with RegistrationType string // enabled | disabled | queue RegisterQuestion string MaxUploadBytes int64 MaxUserUploadBytes int64 InlineMaxBytes int64 MaxRenderBytes int64 MaxRawDownloadBytes int64 SSHDisabled bool SSHHostKeyPath string ScannedRepoPrivate bool TrustedProxy bool RateLimitDisabled bool CommitterName string CommitterEmail string ExtraAllowedSigners string MaxTitleBytes int MaxTextBodyBytes int MaxUsernameBytes int MaxPasswordBytes int CIDockerSocket string CIMaxHistory int CIDefaultTimeout int CIMaxConcurrent int CIMaxArtifactBytes int64 CIEngineSocket bool CINetwork string // engine network for CI containers, empty = engine default RegistryPull string // admin | users | public MaxConcurrentArchives int } // intEnv returns def when unset or unparsable. min clamps the result; // pass it where 0 would break the feature instead of disabling it. func intEnv(key string, def, min int64) int64 { v := os.Getenv(key) if v == "" { return def } n, err := strconv.ParseInt(v, 10, 64) if err != nil { log.Printf("config: %s=%q is not a number, using %d", key, v, def) return def } if n < min { return min } return n } func strEnv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // boolEnv treats anything but "", "0" and "false" as true. A value that looks // like neither is almost always a typo, so it is logged. func boolEnv(key string) bool { v := os.Getenv(key) switch v { case "", "0", "false", "1", "true": default: log.Printf("config: %s=%q is not a boolean, reading it as true", key, v) } return v != "" && v != "0" && v != "false" } func Load() (*Config, error) { port := int(intEnv("PORT", 3000, 1)) owner := strEnv("OWNER_DISPLAY_NAME", "Admin") dataDir, err := filepath.Abs(strEnv("DATA_DIR", "./data")) if err != nil { return nil, err } c := &Config{ Port: port, SSHPort: int(intEnv("SSH_PORT", 2222, 1)), DataDir: dataDir, OwnerDisplayName: owner, BaseURL: strEnv("BASE_URL", fmt.Sprintf("http://localhost:%d", port)), RegistrationType: strEnv("REGISTRATION_TYPE", "enabled"), RegisterQuestion: os.Getenv("REGISTER_QUESTION"), MaxUploadBytes: intEnv("MAX_UPLOAD_BYTES", 10<<20, 0), MaxUserUploadBytes: intEnv("MAX_USER_UPLOAD_BYTES", 2<<20, 0), InlineMaxBytes: intEnv("INLINE_MAX_BYTES", 512<<10, 0), MaxRenderBytes: intEnv("MAX_RENDER_BYTES", 10<<20, 0), MaxRawDownloadBytes: intEnv("MAX_RAW_DOWNLOAD_BYTES", 0, 0), SSHDisabled: boolEnv("SSH_DISABLED"), SSHHostKeyPath: strEnv("SSH_HOST_KEY_PATH", filepath.Join(dataDir, "ssh_host_key")), ScannedRepoPrivate: os.Getenv("SCANNED_REPO_PRIVATE") != "0" && os.Getenv("SCANNED_REPO_PRIVATE") != "false", TrustedProxy: boolEnv("TRUSTED_PROXY"), RateLimitDisabled: boolEnv("RATE_LIMIT_DISABLED"), CommitterName: strEnv("COMMITTER_NAME", owner), ExtraAllowedSigners: os.Getenv("EXTRA_ALLOWED_SIGNERS_PATH"), MaxTitleBytes: int(intEnv("MAX_TITLE_BYTES", 500, 0)), MaxTextBodyBytes: int(intEnv("MAX_TEXT_BODY_BYTES", 100_000, 0)), MaxUsernameBytes: int(intEnv("MAX_USERNAME_BYTES", 64, 0)), MaxPasswordBytes: int(intEnv("MAX_PASSWORD_BYTES", 1024, 0)), CIDockerSocket: os.Getenv("CI_DOCKER_SOCKET"), CIMaxHistory: int(intEnv("CI_MAX_HISTORY", 50, 1)), CIDefaultTimeout: int(intEnv("CI_DEFAULT_TIMEOUT", 3600, 1)), CIMaxConcurrent: int(intEnv("CI_MAX_CONCURRENT", 2, 1)), CIMaxArtifactBytes: intEnv("CI_MAX_ARTIFACT_BYTES", 512<<20, 1), CIEngineSocket: boolEnv("CI_ENGINE_SOCKET"), CINetwork: os.Getenv("CI_NETWORK"), RegistryPull: strEnv("REGISTRY_PULL", "admin"), MaxConcurrentArchives: int(intEnv("MAX_CONCURRENT_ARCHIVE_JOBS", 2, 1)), } switch c.RegistrationType { case "enabled", "disabled", "queue": default: return nil, fmt.Errorf("REGISTRATION_TYPE %q must be enabled, disabled or queue", c.RegistrationType) } switch c.RegistryPull { case "admin", "users", "public": default: return nil, fmt.Errorf("REGISTRY_PULL %q must be admin, users or public", c.RegistryPull) } u, err := url.Parse(c.BaseURL) if err != nil || u.Host == "" { return nil, fmt.Errorf("BASE_URL %q is not a valid URL", c.BaseURL) } c.PublicHTTPS = u.Scheme == "https" // Browsers send Origin in this canonical form. The CSRF check and // WebAuthn compare it verbatim. host := strings.ToLower(u.Host) switch u.Scheme { case "https": host = strings.TrimSuffix(host, ":443") case "http": host = strings.TrimSuffix(host, ":80") } c.PublicOrigin = u.Scheme + "://" + host c.PublicHost = host c.CommitterEmail = strEnv("COMMITTER_EMAIL", owner+"@"+u.Hostname()) return c, nil } // CanPullImages applies REGISTRY_PULL. Images of private repositories are // admin-only whatever the setting says. func (c *Config) CanPullImages(isPrivate, authed, isAdmin bool) bool { if isAdmin { return true } if isPrivate { return false } return c.RegistryPull == "public" || (c.RegistryPull == "users" && authed) } // Derived paths under DataDir. func (c *Config) DBPath() string { return filepath.Join(c.DataDir, "hearthforge.db") } func (c *Config) ReposDir() string { return filepath.Join(c.DataDir, "repos") } func (c *Config) AvatarsDir() string { return filepath.Join(c.DataDir, "avatars") } func (c *Config) ReleasesDir() string { return filepath.Join(c.DataDir, "releases") } func (c *Config) AllowedSignersPath() string { return filepath.Join(c.DataDir, "allowed_signers") } func (c *Config) CIArtifactsDir() string { return filepath.Join(c.DataDir, "ci", "artifacts") } func (c *Config) RegistryDir() string { return filepath.Join(c.DataDir, "registry") }