package cfg import ( "errors" "fmt" "regexp" "strconv" "strings" "sync" "time" ) const ( // defaultBaseUrl = "http://127.0.0.1" defaultListenPort = 4110 defaultMusicBrainzUrl = "https://musicbrainz.org" ) const ( // BASE_URL_ENV = "KOITO_BASE_URL" DATABASE_URL_ENV = "KOITO_DATABASE_URL" BIND_ADDR_ENV = "KOITO_BIND_ADDR" LISTEN_PORT_ENV = "KOITO_LISTEN_PORT" ENABLE_STRUCTURED_LOGGING_ENV = "KOITO_ENABLE_STRUCTURED_LOGGING" ENABLE_FULL_IMAGE_CACHE_ENV = "KOITO_ENABLE_FULL_IMAGE_CACHE" LOG_LEVEL_ENV = "KOITO_LOG_LEVEL" MUSICBRAINZ_URL_ENV = "KOITO_MUSICBRAINZ_URL" MUSICBRAINZ_RATE_LIMIT_ENV = "KOITO_MUSICBRAINZ_RATE_LIMIT" ENABLE_LBZ_RELAY_ENV = "KOITO_ENABLE_LBZ_RELAY" LBZ_RELAY_URL_ENV = "KOITO_LBZ_RELAY_URL" LBZ_RELAY_TOKEN_ENV = "KOITO_LBZ_RELAY_TOKEN" CONFIG_DIR_ENV = "KOITO_CONFIG_DIR" DEFAULT_USERNAME_ENV = "KOITO_DEFAULT_USERNAME" DEFAULT_PASSWORD_ENV = "KOITO_DEFAULT_PASSWORD" DEFAULT_THEME_ENV = "KOITO_DEFAULT_THEME" DISABLE_DEEZER_ENV = "KOITO_DISABLE_DEEZER" DISABLE_COVER_ART_ARCHIVE_ENV = "KOITO_DISABLE_COVER_ART_ARCHIVE" DISABLE_MUSICBRAINZ_ENV = "KOITO_DISABLE_MUSICBRAINZ" SUBSONIC_URL_ENV = "KOITO_SUBSONIC_URL" SUBSONIC_PARAMS_ENV = "KOITO_SUBSONIC_PARAMS" LASTFM_API_KEY_ENV = "KOITO_LASTFM_API_KEY" SKIP_IMPORT_ENV = "KOITO_SKIP_IMPORT" ALLOWED_HOSTS_ENV = "KOITO_ALLOWED_HOSTS" CORS_ORIGINS_ENV = "KOITO_CORS_ALLOWED_ORIGINS" DISABLE_RATE_LIMIT_ENV = "KOITO_DISABLE_RATE_LIMIT" THROTTLE_IMPORTS_MS = "KOITO_THROTTLE_IMPORTS_MS" IMPORT_BEFORE_UNIX_ENV = "KOITO_IMPORT_BEFORE_UNIX" IMPORT_AFTER_UNIX_ENV = "KOITO_IMPORT_AFTER_UNIX" FETCH_IMAGES_DURING_IMPORT_ENV = "KOITO_FETCH_IMAGES_DURING_IMPORT" ARTIST_SEPARATORS_ENV = "KOITO_ARTIST_SEPARATORS_REGEX" LOGIN_GATE_ENV = "KOITO_LOGIN_GATE" FORCE_TZ = "KOITO_FORCE_TZ" ) type config struct { bindAddr string listenPort int configDir string // baseUrl string databaseUrl string musicBrainzUrl string musicBrainzRateLimit int logLevel int structuredLogging bool enableFullImageCache bool lbzRelayEnabled bool lbzRelayUrl string lbzRelayToken string defaultPw string defaultUsername string defaultTheme string disableDeezer bool disableCAA bool disableMusicBrainz bool subsonicUrl string subsonicParams string lastfmApiKey string subsonicEnabled bool skipImport bool fetchImageDuringImport bool allowedHosts []string allowAllHosts bool allowedOrigins []string disableRateLimit bool importThrottleMs int userAgent string importBefore time.Time importAfter time.Time artistSeparators []*regexp.Regexp loginGate bool forceTZ *time.Location } var ( globalConfig *config once sync.Once lock sync.RWMutex ) // Initialize initializes the global configuration using the provided getenv function. func Load(getenv func(string) string, version string) error { var err error once.Do(func() { globalConfig, err = loadConfig(getenv, version) }) if err != nil { return fmt.Errorf("cfg.Load: %w", err) } return nil } // loadConfig loads the configuration from environment variables. func loadConfig(getenv func(string) string, version string) (*config, error) { cfg := new(config) cfg.databaseUrl = getenv(DATABASE_URL_ENV) if cfg.databaseUrl == "" { return nil, errors.New("loadConfig: required parameter " + DATABASE_URL_ENV + " not provided") } cfg.bindAddr = getenv(BIND_ADDR_ENV) var err error cfg.listenPort, err = strconv.Atoi(getenv(LISTEN_PORT_ENV)) if err != nil { cfg.listenPort = defaultListenPort } cfg.musicBrainzRateLimit, err = strconv.Atoi(getenv(MUSICBRAINZ_RATE_LIMIT_ENV)) if err != nil { cfg.musicBrainzRateLimit = 1 } cfg.musicBrainzUrl = getenv(MUSICBRAINZ_URL_ENV) if cfg.musicBrainzUrl == "" { cfg.musicBrainzUrl = defaultMusicBrainzUrl } if cfg.musicBrainzUrl == defaultMusicBrainzUrl && cfg.musicBrainzRateLimit != 1 { return nil, fmt.Errorf("loadConfig: invalid configuration: %s cannot be altered when %s is default", MUSICBRAINZ_RATE_LIMIT_ENV, MUSICBRAINZ_URL_ENV) } if parseBool(getenv(ENABLE_LBZ_RELAY_ENV)) { cfg.lbzRelayEnabled = true cfg.lbzRelayToken = getenv(LBZ_RELAY_TOKEN_ENV) cfg.lbzRelayUrl = getenv(LBZ_RELAY_URL_ENV) } beforeutx, _ := strconv.ParseInt(getenv(IMPORT_BEFORE_UNIX_ENV), 10, 64) afterutx, _ := strconv.ParseInt(getenv(IMPORT_AFTER_UNIX_ENV), 10, 64) if beforeutx > 0 { cfg.importBefore = time.Unix(beforeutx, 0) } if afterutx > 0 { cfg.importAfter = time.Unix(afterutx, 0) } cfg.importThrottleMs, _ = strconv.Atoi(getenv(THROTTLE_IMPORTS_MS)) cfg.disableRateLimit = parseBool(getenv(DISABLE_RATE_LIMIT_ENV)) cfg.structuredLogging = parseBool(getenv(ENABLE_STRUCTURED_LOGGING_ENV)) cfg.fetchImageDuringImport = parseBool(getenv(FETCH_IMAGES_DURING_IMPORT_ENV)) cfg.enableFullImageCache = parseBool(getenv(ENABLE_FULL_IMAGE_CACHE_ENV)) cfg.disableDeezer = parseBool(getenv(DISABLE_DEEZER_ENV)) cfg.disableCAA = parseBool(getenv(DISABLE_COVER_ART_ARCHIVE_ENV)) cfg.disableMusicBrainz = parseBool(getenv(DISABLE_MUSICBRAINZ_ENV)) cfg.subsonicUrl = getenv(SUBSONIC_URL_ENV) cfg.subsonicParams = getenv(SUBSONIC_PARAMS_ENV) cfg.subsonicEnabled = cfg.subsonicUrl != "" && cfg.subsonicParams != "" if cfg.subsonicEnabled && (cfg.subsonicUrl == "" || cfg.subsonicParams == "") { return nil, fmt.Errorf("loadConfig: invalid configuration: both %s and %s must be set in order to use subsonic image fetching", SUBSONIC_URL_ENV, SUBSONIC_PARAMS_ENV) } cfg.lastfmApiKey = getenv(LASTFM_API_KEY_ENV) cfg.skipImport = parseBool(getenv(SKIP_IMPORT_ENV)) cfg.userAgent = fmt.Sprintf("Koito %s (contact@koito.io)", version) if getenv(DEFAULT_USERNAME_ENV) == "" { cfg.defaultUsername = "admin" } else { cfg.defaultUsername = getenv(DEFAULT_USERNAME_ENV) } if getenv(DEFAULT_PASSWORD_ENV) == "" { cfg.defaultPw = "changeme" } else { cfg.defaultPw = getenv(DEFAULT_PASSWORD_ENV) } cfg.defaultTheme = getenv(DEFAULT_THEME_ENV) cfg.configDir = getenv(CONFIG_DIR_ENV) if cfg.configDir == "" { cfg.configDir = "/etc/koito" } rawHosts := getenv(ALLOWED_HOSTS_ENV) cfg.allowedHosts = strings.Split(rawHosts, ",") cfg.allowAllHosts = cfg.allowedHosts[0] == "*" rawCors := getenv(CORS_ORIGINS_ENV) cfg.allowedOrigins = strings.Split(rawCors, ",") if getenv(ARTIST_SEPARATORS_ENV) != "" { for pattern := range strings.SplitSeq(getenv(ARTIST_SEPARATORS_ENV), ";;") { regex, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("failed to compile regex pattern %s", pattern) } cfg.artistSeparators = append(cfg.artistSeparators, regex) } } else { cfg.artistSeparators = []*regexp.Regexp{regexp.MustCompile(`\s+ยท\s+`)} } if strings.ToLower(getenv(LOGIN_GATE_ENV)) == "true" { cfg.loginGate = true } if getenv(FORCE_TZ) != "" { cfg.forceTZ, err = time.LoadLocation(getenv(FORCE_TZ)) if err != nil { return nil, fmt.Errorf("forced timezone '%s' is not a valid timezone", getenv(FORCE_TZ)) } } switch strings.ToLower(getenv(LOG_LEVEL_ENV)) { case "debug": cfg.logLevel = 0 case "warn": cfg.logLevel = 2 case "error": cfg.logLevel = 3 case "fatal": cfg.logLevel = 4 default: cfg.logLevel = 1 } return cfg, nil } func parseBool(s string) bool { if strings.ToLower(s) == "true" { return true } else { return false } }