// Package config loads the application configuration from environment variables. package config import ( "errors" "fmt" "net" "net/url" "os" "strconv" "strings" ) // DefaultReleasesURL is the Gitea endpoint used to look up the latest client release. const DefaultReleasesURL = "https://gitea.sanplex.xyz/api/v1/repos/sansan/MusicPlayer/releases/latest" // Config holds all settings read from the environment. type Config struct { Port int DBHost string DBPort string DBUser string DBPassword string DBName string MusicPath string CharactersPath string SoundCheckPath string FrontendPath string LogLevel string LogJSON bool // APIPassword is required to create tokens. Empty means any password is accepted. APIPassword string DisableSessionAuth bool // TrustedProxies are CIDR ranges whose X-Forwarded-For header is trusted when resolving client IPs. TrustedProxies []*net.IPNet // CORSAllowedOrigins are the browser origins, like https://music.example.com, that may call the API // from another site. Empty means only same-origin requests, which is enough for the bundled frontend. CORSAllowedOrigins []string ReleasesURL string } // Load reads the configuration from the environment and validates it. func Load() (Config, error) { return load(os.Getenv) } func load(getenv func(string) string) (Config, error) { cfg := Config{ DBHost: getenv("DB_HOST"), DBPort: getenv("DB_PORT"), DBUser: getenv("DB_USERNAME"), DBPassword: getenv("DB_PASSWORD"), DBName: getenv("DB_NAME"), MusicPath: getenv("MUSIC_PATH"), CharactersPath: getenv("CHARACTERS_PATH"), SoundCheckPath: withDefault(getenv("SOUND_CHECK_PATH"), "songs"), FrontendPath: withDefault(getenv("FRONTEND_PATH"), "/frontend"), LogLevel: withDefault(getenv("LOG_LEVEL"), "info"), LogJSON: getenv("LOG_JSON") == "true", APIPassword: getenv("API_PASSWORD"), DisableSessionAuth: getenv("DISABLE_SESSION_AUTH") == "true", ReleasesURL: withDefault(getenv("RELEASES_URL"), DefaultReleasesURL), } var errs []error port, err := strconv.Atoi(withDefault(getenv("PORT"), "8080")) if err != nil { errs = append(errs, fmt.Errorf("PORT: %w", err)) } cfg.Port = port for name, value := range map[string]string{ "DB_HOST": cfg.DBHost, "DB_PORT": cfg.DBPort, "DB_USERNAME": cfg.DBUser, "DB_PASSWORD": cfg.DBPassword, "DB_NAME": cfg.DBName, "MUSIC_PATH": cfg.MusicPath, "CHARACTERS_PATH": cfg.CharactersPath, } { if value == "" { errs = append(errs, fmt.Errorf("%s is required", name)) } } cfg.TrustedProxies, err = parseCIDRs(getenv("TRUSTED_PROXIES")) if err != nil { errs = append(errs, fmt.Errorf("TRUSTED_PROXIES: %w", err)) } cfg.CORSAllowedOrigins, err = parseOrigins(getenv("CORS_ALLOWED_ORIGINS")) if err != nil { errs = append(errs, fmt.Errorf("CORS_ALLOWED_ORIGINS: %w", err)) } return cfg, errors.Join(errs...) } func parseCIDRs(value string) ([]*net.IPNet, error) { var nets []*net.IPNet for part := range strings.SplitSeq(value, ",") { part = strings.TrimSpace(part) if part == "" { continue } _, ipNet, err := net.ParseCIDR(part) if err != nil { return nil, err } nets = append(nets, ipNet) } return nets, nil } // parseOrigins parses a comma-separated list of origins (scheme://host[:port], nothing else). func parseOrigins(value string) ([]string, error) { var origins []string for part := range strings.SplitSeq(value, ",") { part = strings.TrimSpace(part) if part == "" { continue } u, err := url.Parse(part) if err != nil { return nil, err } if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil || strings.Contains(u.Host, "*") { return nil, fmt.Errorf("%q is not an origin like https://music.example.com", part) } origins = append(origins, u.Scheme+"://"+u.Host) } return origins, nil } func withDefault(value, fallback string) string { if value == "" { return fallback } return value }