config/config.go
Rama main · réplica de lectura del repositorio original.
package config
import (
"flag"
"fmt"
"log"
"math"
"net"
"net/url"
"os"
"strconv"
"strings"
"xprem/internal/helpers"
"github.com/joho/godotenv"
)
func validateStorageMode(storageMode string) bool {
return storageMode == "local" || storageMode == "s3" || storageMode == "gcs" || storageMode == "azure"
}
func GetPort() string {
port := GetEnv("PORT")
if port == "" {
port = "3000"
}
return port
}
// GetBindAddress returns the address the API server listens on
// (BIND_TO_ADDRESS). The default 0.0.0.0 accepts traffic on every interface.
// Set it to 127.0.0.1 to keep the server on the
// loopback interface, for example behind a reverse proxy on the same host.
func GetBindAddress() string {
bind_to_address := GetEnv("BIND_TO_ADDRESS")
bind_address := net.ParseIP(bind_to_address)
if bind_address == nil {
log.Fatalf("Invalid BIND_TO_ADDRESS set; must be a valid IP")
}
return bind_address.String()
}
func GetDBURL() string {
return GetEnv("DB_URL")
}
func IsDBMode() bool {
return GetDBURL() != ""
}
// GetClickHouseURL returns the ClickHouse DSN the Observe feature persists
// telemetry through (e.g. clickhouse://user:password@host:9000/database).
// Empty means Observe (identity included) is not enabled.
func GetClickHouseURL() string {
return GetEnv("CLICKHOUSE_URL")
}
// IsDeviceTelemetryDisabled reports whether the operator turned off every
// recording made about a device: the manifest check-ins that populate the
// Postgres registry (device_identity and the update-health tables it feeds),
// the identity ops and the telemetry rows the Observe SDK dispatches, and the
// ClickHouse connection those rows would land in. It is the privacy switch a
// deployment flips when it wants to serve updates and collect nothing about
// who receives them.
//
// It disables COLLECTION, not the schema: the tables stay, and whatever was
// recorded before the switch stays with them. Deleting it is the operator's
// call, not a boot side effect.
func IsDeviceTelemetryDisabled() bool {
disabled, _ := strconv.ParseBool(GetEnv("DISABLE_DEVICE_TELEMETRY"))
return disabled
}
func IsServerTelemetryDisabled() bool {
disabled, _ := strconv.ParseBool(GetEnv("DISABLE_TELEMETRY"))
return disabled
}
func ValidateMasterKey() error {
awsKeyId := GetEnv("AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID")
localKey := GetEnv("DB_KEYS_MASTER_KEY_B64")
if awsKeyId == "" && localKey == "" {
return fmt.Errorf("Neither AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID nor DB_KEYS_MASTER_KEY_B64 is set: DB mode requires a master key to seal the per-app Expo signing keys stored in Postgres. Generate one with `openssl rand -base64 32`")
}
if awsKeyId != "" && localKey != "" {
log.Printf("Both AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID and DB_KEYS_MASTER_KEY_B64 are set; please set only one")
return fmt.Errorf("Both AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID and DB_KEYS_MASTER_KEY_B64 are set; please set only one")
}
return nil
}
func validateBucketParams(storageMode string) bool {
switch storageMode {
case "s3":
bucketName := GetEnv("S3_BUCKET_NAME")
if bucketName == "" {
log.Printf("S3_BUCKET_NAME not set")
return false
}
region := GetEnv("AWS_REGION")
if region == "" {
log.Printf("AWS_REGION not set")
return false
}
case "gcs":
bucketName := GetEnv("GCS_BUCKET_NAME")
if bucketName == "" {
log.Printf("GCS_BUCKET_NAME not set")
return false
}
case "azure":
if GetEnv("AZURE_BLOB_CONTAINER_NAME") == "" {
log.Printf("AZURE_BLOB_CONTAINER_NAME not set")
return false
}
if GetEnv("AZURE_STORAGE_ACCOUNT_NAME") == "" {
log.Printf("AZURE_STORAGE_ACCOUNT_NAME not set")
return false
}
// The account key is required in every case: shared key auth is the
// only supported mode and SAS URLs cannot be signed without it.
if GetEnv("AZURE_STORAGE_ACCOUNT_KEY") == "" {
log.Printf("AZURE_STORAGE_ACCOUNT_KEY not set")
return false
}
case "local":
// Already handled by default values
return true
default:
return false
}
return true
}
func validateBaseUrl(baseUrl string) bool {
if baseUrl == "" || !helpers.IsValidURL(baseUrl) {
return false
}
parsed, err := url.Parse(baseUrl)
if err != nil {
return false
}
// Paths are appended to BASE_URL, so a query or fragment would end up
// before them.
return parsed.RawQuery == "" && !parsed.ForceQuery && parsed.Fragment == ""
}
// BaseURL is BASE_URL without any trailing slash.
func BaseURL() string {
return strings.TrimRight(GetEnv("BASE_URL"), "/")
}
// ServeFromSubPath reports whether the server itself serves under BASE_URL's
// path; when false the reverse proxy is expected to strip the prefix.
func ServeFromSubPath() bool {
return GetEnv("SERVE_FROM_SUB_PATH") == "true"
}
// PublicPath is the path component of BASE_URL, empty when BASE_URL has none.
func PublicPath() string {
parsed, err := url.Parse(BaseURL())
if err != nil {
return ""
}
p := strings.TrimRight(parsed.Path, "/")
if p == "" || p == "/" {
return ""
}
if !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}
// PublicHref is path as a root-relative URL under BASE_URL.
func PublicHref(path string) string {
if path == "" {
path = "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return PublicPath() + path
}
func IsTestMode() bool {
return flag.Lookup("test.v") != nil
}
func resolveDefaultBaseUrl() string {
port := os.Getenv("PORT")
if port == "" {
return "http://localhost:3000"
}
return "http://localhost:" + port
}
func LoadConfig() {
err := godotenv.Load()
if err != nil {
log.Printf("No .env file found, continuing with runtime environment variables.")
}
storageMode := GetEnv("STORAGE_MODE")
if !validateStorageMode(storageMode) {
log.Fatalf("Invalid STORAGE_MODE: %s", storageMode)
}
bucketParamsValid := validateBucketParams(storageMode)
if !bucketParamsValid {
log.Fatalf("Invalid bucket parameters")
}
baseUrl := GetEnv("BASE_URL")
if !validateBaseUrl(baseUrl) {
log.Fatalf("Invalid BASE_URL: %s", baseUrl)
}
jwtSecret := GetEnv("JWT_SECRET")
if jwtSecret == "" {
log.Fatalf("JWT_SECRET not set")
}
if _, err := parseBundleDiffingMaxBundleSize(); err != nil {
log.Fatalf("Invalid BUNDLE_DIFFING_MAX_BUNDLE_SIZE_MB: %v", err)
}
if _, err := parseBundleDiffingPatchMaxRatio(); err != nil {
log.Fatalf("Invalid BUNDLE_DIFFING_PATCH_MAX_RATIO: %v", err)
}
for _, key := range []string{"OBSERVE_INGEST_LIMIT_PER_IP", "OBSERVE_INGEST_LIMIT_PER_APP"} {
if _, err := parseLimit(GetEnv(key)); err != nil {
log.Fatalf("Invalid %s: %v", key, err)
}
}
}
// IsSourcemapUploadEnabled reports whether publishes store the bundle's source
// map alongside the update (UPLOAD_SOURCEMAPS=true, off by default).
func IsSourcemapUploadEnabled() bool {
enabled, _ := strconv.ParseBool(GetEnv("UPLOAD_SOURCEMAPS"))
return enabled && IsDBMode()
}
// IsBundleDiffingEnabled reports whether bundle patches are computed at
// publish and served to devices (BUNDLE_DIFFING=true, off by default).
func IsBundleDiffingEnabled() bool {
enabled, _ := strconv.ParseBool(GetEnv("BUNDLE_DIFFING"))
return enabled && IsDBMode()
}
// IsBundleDiffingCDNRedirect reports whether patch requests are redirected to
// the CDN instead of served by this server (BUNDLE_DIFFING_CDN_REDIRECT=true).
// The operator asserts that the CDN edge adds the im and expo-base-update-id
// response headers on the bsdiff objects; nothing here can check it.
func IsBundleDiffingCDNRedirect() bool {
enabled, _ := strconv.ParseBool(GetEnv("BUNDLE_DIFFING_CDN_REDIRECT"))
return enabled
}
// IsPreventCDNRedirectionHeaderEnabled reports whether clients may force asset
// delivery through the server with prevent-cdn-redirection: true. It is off by
// default because enabling it lets any asset client bypass the configured CDN.
func IsPreventCDNRedirectionHeaderEnabled() bool {
enabled, _ := strconv.ParseBool(GetEnv("ENABLE_PREVENT_CDN_REDIRECTION_HEADER"))
return enabled
}
// BundleDiffingMaxBundleSize is the largest launch asset, in bytes, a patch
// job loads in memory (BUNDLE_DIFFING_MAX_BUNDLE_SIZE_MB, default 64). A job
// peaks at about six times this size, and two jobs run at once.
func BundleDiffingMaxBundleSize() int64 {
size, err := parseBundleDiffingMaxBundleSize()
if err != nil {
size, _ = parseMB(DefaultEnvValues["BUNDLE_DIFFING_MAX_BUNDLE_SIZE_MB"])
}
return size
}
// BundleDiffingPatchMaxRatio is the largest patch worth storing, as a fraction
// of the gzipped bundle a full download would cost
// (BUNDLE_DIFFING_PATCH_MAX_RATIO in (0, 1], default 0.3).
func BundleDiffingPatchMaxRatio() float64 {
ratio, err := parseBundleDiffingPatchMaxRatio()
if err != nil {
ratio, _ = parseRatio(DefaultEnvValues["BUNDLE_DIFFING_PATCH_MAX_RATIO"])
}
return ratio
}
func parseBundleDiffingMaxBundleSize() (int64, error) {
return parseMB(GetEnv("BUNDLE_DIFFING_MAX_BUNDLE_SIZE_MB"))
}
func parseBundleDiffingPatchMaxRatio() (float64, error) {
return parseRatio(GetEnv("BUNDLE_DIFFING_PATCH_MAX_RATIO"))
}
// ObserveIngestLimitPerIP is how many telemetry batches one address may send
// to one app per minute (OBSERVE_INGEST_LIMIT_PER_IP, default 120, 0 disables).
func ObserveIngestLimitPerIP() int {
return limitOrDefault("OBSERVE_INGEST_LIMIT_PER_IP")
}
// ObserveIngestLimitPerApp is how many telemetry batches one app may receive
// per minute (OBSERVE_INGEST_LIMIT_PER_APP, default 6000, 0 disables).
func ObserveIngestLimitPerApp() int {
return limitOrDefault("OBSERVE_INGEST_LIMIT_PER_APP")
}
func limitOrDefault(key string) int {
limit, err := parseLimit(GetEnv(key))
if err != nil {
limit, _ = parseLimit(DefaultEnvValues[key])
}
return limit
}
func parseLimit(value string) (int, error) {
limit, err := strconv.Atoi(value)
if err != nil {
return 0, err
}
if limit < 0 {
return 0, fmt.Errorf("%d is negative", limit)
}
return limit, nil
}
func parseMB(value string) (int64, error) {
mb, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
if mb <= 0 || mb > math.MaxInt64>>20 {
return 0, fmt.Errorf("%d is not a usable number of megabytes", mb)
}
return mb << 20, nil
}
func parseRatio(value string) (float64, error) {
ratio, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0, err
}
if math.IsNaN(ratio) || ratio <= 0 || ratio > 1 {
return 0, fmt.Errorf("%v is not in (0, 1]", ratio)
}
return ratio, nil
}
var DefaultEnvValues = map[string]string{
"LOCAL_BUCKET_BASE_PATH": "./updates",
"STORAGE_MODE": "local",
"BASE_URL": resolveDefaultBaseUrl(),
"JWT_SECRET": "",
"AWS_REGION": "eu-west-3",
"AWS_BASE_ENDPOINT": "",
// Audit archive (ee/audit): opt-in periodic NDJSON export of the audit
// log to a DEDICATED bucket/container/directory (per-provider name
// variables, see ee/audit). Off by default:
// writing to the operator's storage must be a choice.
"ARCHIVE_AUDIT_LOGS": "false",
"AUDIT_LOGS_EXPORT_INTERVAL_SECONDS": "300",
"LOCAL_AUDIT_LOGS_BASE_PATH": "./audit-logs",
// Audit log retention (ee/audit): about 1.5 years, matching EAS and the
// 1-3 year industry norm. Purged rows are gone from Postgres; anything
// longer lived belongs to the operator's own pipeline (database backups,
// the audit stream once enabled).
"AUDIT_LOG_RETENTION_DAYS": "550",
// Bundle diffing (bsdiff patches between updates): off by default. Patches
// are served by this server, not the CDN, so a patch is only kept when it
// beats the gzipped bundle by a wide margin.
"BUNDLE_DIFFING": "false",
"BUNDLE_DIFFING_CDN_REDIRECT": "false",
"BUNDLE_DIFFING_MAX_BUNDLE_SIZE_MB": "64",
"BUNDLE_DIFFING_PATCH_MAX_RATIO": "0.3",
// Source map uploads: off by default. The maps land in their own store,
// which may be the updates one when it is not publicly readable: a map
// embeds the app's source code.
"UPLOAD_SOURCEMAPS": "false",
"LOCAL_SOURCEMAPS_BASE_PATH": "./sourcemaps",
// Client-controlled CDN bypass: opt-in because every asset client can use
// the header once it is enabled.
"ENABLE_PREVENT_CDN_REDIRECTION_HEADER": "false",
// Telemetry ingestion budgets, in batches per minute. The SDK sends a few
// batches each time the app goes to the background, so the per-app default
// leaves room for about 100K monthly active users.
"OBSERVE_INGEST_LIMIT_PER_IP": "120",
"OBSERVE_INGEST_LIMIT_PER_APP": "6000",
// Default address to bind to
"BIND_TO_ADDRESS": "0.0.0.0",
// Database connection defaults
"DB_URL": "",
"DB_MAX_CONNS": "25",
"DB_MIN_CONNS": "5",
"DB_MAX_CONN_LIFETIME": "30m",
"DB_MAX_CONN_IDLE_TIME": "5m",
}
func GetEnv(key string) string {
value := os.Getenv(key)
if value == "" {
defaultValue := DefaultEnvValues[key]
if defaultValue != "" {
return defaultValue
}
return ""
}
return value
}