package app import ( "fmt" "net/url" "os" "strconv" "strings" "time" _ "time/tzdata" ) type Config struct { ListenAddr string PublicURL *url.URL DatabaseDSN string GiteaURL *url.URL GiteaInternalURL *url.URL GiteaClientID string GiteaClientSecret string GiteaPackageOwner string GiteaPackageUser string GiteaPackageToken string GiteaWriteUser string GiteaWriteToken string AllowedGiteaUsers map[string]struct{} CookieSecret []byte CookieSecure bool DefaultDuration time.Duration DefaultHostLimit int MaxHostLimit int MaxUploadBytes int64 TrustProxyHeaders bool DisplayTimeZone *time.Location } func LoadConfig() (Config, error) { var cfg Config var err error cfg.ListenAddr = envDefault("TAPM_LISTEN_ADDR", ":8080") cfg.DatabaseDSN = envDefault( "TAPM_DATABASE_DSN", "file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)", ) cfg.GiteaClientID = os.Getenv("TAPM_GITEA_CLIENT_ID") cfg.GiteaClientSecret = os.Getenv("TAPM_GITEA_CLIENT_SECRET") cfg.GiteaPackageOwner = envDefault("TAPM_GITEA_PACKAGE_OWNER", "TAI") cfg.GiteaPackageUser = os.Getenv("TAPM_GITEA_PACKAGE_USERNAME") cfg.GiteaPackageToken = os.Getenv("TAPM_GITEA_PACKAGE_TOKEN") cfg.GiteaWriteUser = os.Getenv("TAPM_GITEA_PACKAGE_WRITE_USERNAME") cfg.GiteaWriteToken = os.Getenv("TAPM_GITEA_PACKAGE_WRITE_TOKEN") cfg.CookieSecret = []byte(os.Getenv("TAPM_COOKIE_SECRET")) allowInsecureHTTP, err := strconv.ParseBool( envDefault("TAPM_ALLOW_INSECURE_HTTP", "false"), ) if err != nil { return cfg, fmt.Errorf("TAPM_ALLOW_INSECURE_HTTP: %w", err) } cfg.PublicURL, err = parseAbsoluteURL("TAPM_PUBLIC_URL", allowInsecureHTTP) if err != nil { return cfg, err } cfg.GiteaURL, err = parseAbsoluteURL("TAPM_GITEA_URL", allowInsecureHTTP) if err != nil { return cfg, err } cfg.GiteaInternalURL, err = parseURLValue( "TAPM_GITEA_INTERNAL_URL", envDefault("TAPM_GITEA_INTERNAL_URL", "http://gitea:3000"), true, ) if err != nil { return cfg, err } cfg.CookieSecure = cfg.PublicURL.Scheme == "https" cfg.DefaultDuration, err = parseAuthorizationDuration( envDefault("TAPM_DEFAULT_DURATION", "7d"), ) if err != nil || cfg.DefaultDuration <= 0 { return cfg, fmt.Errorf("TAPM_DEFAULT_DURATION must be positive") } cfg.DefaultHostLimit, err = envInt("TAPM_DEFAULT_HOST_LIMIT", 3) if err != nil { return cfg, err } cfg.MaxHostLimit, err = envInt("TAPM_MAX_HOST_LIMIT", 25) if err != nil { return cfg, err } if cfg.DefaultHostLimit < 1 || cfg.MaxHostLimit < cfg.DefaultHostLimit { return cfg, fmt.Errorf("invalid default or maximum host limit") } cfg.MaxUploadBytes, err = envInt64("TAPM_MAX_UPLOAD_BYTES", 1<<30) if err != nil || cfg.MaxUploadBytes < 1<<20 { return cfg, fmt.Errorf("TAPM_MAX_UPLOAD_BYTES must be at least 1048576") } cfg.TrustProxyHeaders, err = strconv.ParseBool( envDefault("TAPM_TRUST_PROXY_HEADERS", "false"), ) if err != nil { return cfg, fmt.Errorf("TAPM_TRUST_PROXY_HEADERS: %w", err) } cfg.DisplayTimeZone, err = time.LoadLocation( envDefault("TAPM_DISPLAY_TIME_ZONE", "America/Chicago"), ) if err != nil { return cfg, fmt.Errorf("TAPM_DISPLAY_TIME_ZONE: %w", err) } cfg.AllowedGiteaUsers = make(map[string]struct{}) for _, user := range strings.Split( envDefault("TAPM_ALLOWED_GITEA_USERS", "taiadmin"), ",", ) { user = strings.ToLower(strings.TrimSpace(user)) if user != "" { cfg.AllowedGiteaUsers[user] = struct{}{} } } required := map[string]string{ "TAPM_GITEA_CLIENT_ID": cfg.GiteaClientID, "TAPM_GITEA_CLIENT_SECRET": cfg.GiteaClientSecret, "TAPM_GITEA_PACKAGE_USERNAME": cfg.GiteaPackageUser, "TAPM_GITEA_PACKAGE_TOKEN": cfg.GiteaPackageToken, "TAPM_GITEA_PACKAGE_WRITE_USERNAME": cfg.GiteaWriteUser, "TAPM_GITEA_PACKAGE_WRITE_TOKEN": cfg.GiteaWriteToken, } for name, value := range required { if strings.TrimSpace(value) == "" { return cfg, fmt.Errorf("%s is required", name) } } if len(cfg.CookieSecret) < 32 { return cfg, fmt.Errorf("TAPM_COOKIE_SECRET must contain at least 32 bytes") } if len(cfg.AllowedGiteaUsers) == 0 { return cfg, fmt.Errorf("at least one Gitea user must be allowed") } return cfg, nil } func parseAuthorizationDuration(value string) (time.Duration, error) { value = strings.TrimSpace(value) if strings.HasSuffix(value, "d") { days, err := strconv.Atoi(strings.TrimSuffix(value, "d")) if err != nil || days < 1 { return 0, fmt.Errorf("invalid day duration") } return time.Duration(days) * 24 * time.Hour, nil } return time.ParseDuration(value) } func parseAbsoluteURL(name string, allowInsecureHTTP bool) (*url.URL, error) { return parseURLValue(name, os.Getenv(name), allowInsecureHTTP) } func parseURLValue(name string, raw string, allowInsecureHTTP bool) (*url.URL, error) { parsed, err := url.Parse(raw) validScheme := parsed != nil && parsed.Scheme == "https" if allowInsecureHTTP && parsed != nil && parsed.Scheme == "http" { validScheme = true } if err != nil || !validScheme || parsed.Host == "" { if allowInsecureHTTP { return nil, fmt.Errorf("%s must be an absolute HTTP or HTTPS URL", name) } return nil, fmt.Errorf("%s must be an absolute HTTPS URL", name) } return parsed, nil } func envDefault(name, fallback string) string { if value := os.Getenv(name); value != "" { return value } return fallback } func envInt(name string, fallback int) (int, error) { raw := envDefault(name, strconv.Itoa(fallback)) value, err := strconv.Atoi(raw) if err != nil { return 0, fmt.Errorf("%s must be an integer", name) } return value, nil } func envInt64(name string, fallback int64) (int64, error) { raw := envDefault(name, strconv.FormatInt(fallback, 10)) value, err := strconv.ParseInt(raw, 10, 64) if err != nil { return 0, fmt.Errorf("%s must be an integer", name) } return value, nil }