// 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 // MetricsPort serves Prometheus metrics on a port of its own, so /metrics is never // reachable through the public port. 0 means metrics are not served. MetricsPort int DBHost string DBPort string DBUser string DBPassword string DBName string MusicPath string CharactersPath string SoundCheckPath string FrontendPath string // UploadMaxFileSize and UploadMaxTotalSize limit uploaded songs, in bytes: one song and // all songs of one uploaded soundtrack. UploadMaxFileSize int64 UploadMaxTotalSize int64 // CoversPath is the folder for soundtrack covers. Empty turns covers off. CoversPath string // SteamGridDBAPIKey turns on the background search for covers. SteamGridDBAPIKey string // SteamGridDBURL and AniListURL replace the real cover APIs; only tests set them. SteamGridDBURL string AniListURL 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"), CoversPath: getenv("COVERS_PATH"), SteamGridDBAPIKey: getenv("STEAMGRIDDB_API_KEY"), 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 cfg.UploadMaxFileSize, err = parseMegabytes(getenv("UPLOAD_MAX_FILE_MB"), DefaultUploadMaxFileMB) if err != nil { errs = append(errs, fmt.Errorf("UPLOAD_MAX_FILE_MB: %w", err)) } cfg.UploadMaxTotalSize, err = parseMegabytes(getenv("UPLOAD_MAX_TOTAL_MB"), DefaultUploadMaxTotalMB) if err != nil { errs = append(errs, fmt.Errorf("UPLOAD_MAX_TOTAL_MB: %w", err)) } cfg.MetricsPort, err = parseMetricsPort(getenv("METRICS_PORT")) if err != nil { errs = append(errs, fmt.Errorf("METRICS_PORT: %w", err)) } 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 } // Upload size limits in MB, used when UPLOAD_MAX_FILE_MB and UPLOAD_MAX_TOTAL_MB are not set. const ( DefaultUploadMaxFileMB = 50 DefaultUploadMaxTotalMB = 2048 ) // parseMegabytes reads a positive number of megabytes and returns it in bytes. func parseMegabytes(value string, defaultMB int64) (int64, error) { if value == "" { return defaultMB << 20, nil } mb, err := strconv.ParseInt(value, 10, 64) if err != nil || mb < 1 || mb > 1<<20 { return 0, fmt.Errorf("%q is not a number of megabytes", value) } return mb << 20, nil } // DefaultMetricsPort is used when METRICS_PORT is not set. const DefaultMetricsPort = 2112 // parseMetricsPort reads METRICS_PORT: empty for the default, "off" for no metrics, or a port. func parseMetricsPort(value string) (int, error) { switch value { case "": return DefaultMetricsPort, nil case "off": return 0, nil } port, err := strconv.Atoi(value) if err != nil || port < 1 || port > 65535 { return 0, fmt.Errorf("%q is not a port or \"off\"", value) } return port, 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 }