package utils //nolint:revive //TODO: figure out a better name for this package import ( "context" "fmt" "net/http" "net/url" "strings" "time" ) type APIHostResolver interface { BaseRESTURL(ctx context.Context) (*url.URL, error) GraphqlURL(ctx context.Context) (*url.URL, error) UploadURL(ctx context.Context) (*url.URL, error) RawURL(ctx context.Context) (*url.URL, error) AuthorizationServerURL(ctx context.Context) (*url.URL, error) } type APIHost struct { restURL *url.URL gqlURL *url.URL uploadURL *url.URL rawURL *url.URL authorizationServerURL *url.URL } var _ APIHostResolver = APIHost{} func NewAPIHost(s string) (APIHostResolver, error) { a, err := parseAPIHost(s) if err != nil { return nil, err } return a, nil } // APIHostResolver implementation func (a APIHost) BaseRESTURL(_ context.Context) (*url.URL, error) { return a.restURL, nil } func (a APIHost) GraphqlURL(_ context.Context) (*url.URL, error) { return a.gqlURL, nil } func (a APIHost) UploadURL(_ context.Context) (*url.URL, error) { return a.uploadURL, nil } func (a APIHost) RawURL(_ context.Context) (*url.URL, error) { return a.rawURL, nil } func (a APIHost) AuthorizationServerURL(_ context.Context) (*url.URL, error) { return a.authorizationServerURL, nil } func newDotcomHost() (APIHost, error) { baseRestURL, err := url.Parse("https://api.github.com/") if err != nil { return APIHost{}, fmt.Errorf("failed to parse dotcom REST URL: %w", err) } gqlURL, err := url.Parse("https://api.github.com/graphql") if err != nil { return APIHost{}, fmt.Errorf("failed to parse dotcom GraphQL URL: %w", err) } uploadURL, err := url.Parse("https://uploads.github.com") if err != nil { return APIHost{}, fmt.Errorf("failed to parse dotcom Upload URL: %w", err) } rawURL, err := url.Parse("https://raw.githubusercontent.com/") if err != nil { return APIHost{}, fmt.Errorf("failed to parse dotcom Raw URL: %w", err) } // The authorization server for GitHub.com is at github.com/login/oauth, not api.github.com authorizationServerURL, err := url.Parse("https://github.com/login/oauth") if err != nil { return APIHost{}, fmt.Errorf("failed to parse dotcom Authorization Server URL: %w", err) } return APIHost{ restURL: baseRestURL, gqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, authorizationServerURL: authorizationServerURL, }, nil } func newGHECHost(hostname string) (APIHost, error) { u, err := url.Parse(hostname) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC URL: %w", err) } // Unsecured GHEC would be an error if u.Scheme == "http" { return APIHost{}, fmt.Errorf("GHEC URL must be HTTPS") } restURL, err := url.Parse(fmt.Sprintf("https://api.%s/", u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC REST URL: %w", err) } gqlURL, err := url.Parse(fmt.Sprintf("https://api.%s/graphql", u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC GraphQL URL: %w", err) } uploadURL, err := url.Parse(fmt.Sprintf("https://uploads.%s/", u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC Upload URL: %w", err) } rawURL, err := url.Parse(fmt.Sprintf("https://raw.%s/", u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC Raw URL: %w", err) } authorizationServerURL, err := url.Parse(fmt.Sprintf("https://%s/login/oauth", u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHEC Authorization Server URL: %w", err) } return APIHost{ restURL: restURL, gqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, authorizationServerURL: authorizationServerURL, }, nil } func newGHESHost(hostname string) (APIHost, error) { u, err := url.Parse(hostname) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES URL: %w", err) } restURL, err := url.Parse(fmt.Sprintf("%s://%s/api/v3/", u.Scheme, u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES REST URL: %w", err) } gqlURL, err := url.Parse(fmt.Sprintf("%s://%s/api/graphql", u.Scheme, u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES GraphQL URL: %w", err) } // Check if subdomain isolation is enabled // See https://docs.github.com/en/enterprise-server@3.17/admin/configuring-settings/hardening-security-for-your-enterprise/enabling-subdomain-isolation#about-subdomain-isolation hasSubdomainIsolation := checkSubdomainIsolation(u.Scheme, u.Hostname()) var uploadURL *url.URL if hasSubdomainIsolation { // With subdomain isolation: https://uploads.hostname/ uploadURL, err = url.Parse(fmt.Sprintf("%s://uploads.%s/", u.Scheme, u.Hostname())) } else { // Without subdomain isolation: https://hostname/api/uploads/ uploadURL, err = url.Parse(fmt.Sprintf("%s://%s/api/uploads/", u.Scheme, u.Hostname())) } if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES Upload URL: %w", err) } var rawURL *url.URL if hasSubdomainIsolation { // With subdomain isolation: https://raw.hostname/ rawURL, err = url.Parse(fmt.Sprintf("%s://raw.%s/", u.Scheme, u.Hostname())) } else { // Without subdomain isolation: https://hostname/raw/ rawURL, err = url.Parse(fmt.Sprintf("%s://%s/raw/", u.Scheme, u.Hostname())) } if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES Raw URL: %w", err) } authorizationServerURL, err := url.Parse(fmt.Sprintf("%s://%s/login/oauth", u.Scheme, u.Hostname())) if err != nil { return APIHost{}, fmt.Errorf("failed to parse GHES Authorization Server URL: %w", err) } return APIHost{ restURL: restURL, gqlURL: gqlURL, uploadURL: uploadURL, rawURL: rawURL, authorizationServerURL: authorizationServerURL, }, nil } // checkSubdomainIsolation detects if GitHub Enterprise Server has subdomain isolation enabled // by attempting to ping the raw./_ping endpoint on the subdomain. The raw subdomain must always exist for subdomain isolation. func checkSubdomainIsolation(scheme, hostname string) bool { subdomainURL := fmt.Sprintf("%s://raw.%s/_ping", scheme, hostname) client := &http.Client{ Timeout: 5 * time.Second, // Don't follow redirects - we just want to check if the endpoint exists //nolint:revive // parameters are required by http.Client.CheckRedirect signature CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } resp, err := client.Get(subdomainURL) if err != nil { return false } defer resp.Body.Close() return resp.StatusCode == http.StatusOK } // Note that this does not handle ports yet, so development environments are out. func parseAPIHost(s string) (APIHost, error) { if s == "" { return newDotcomHost() } u, err := url.Parse(s) if err != nil { return APIHost{}, fmt.Errorf("could not parse host as URL: %s", s) } if u.Scheme == "" { return APIHost{}, fmt.Errorf("host must have a scheme (http or https): %s", s) } if u.Hostname() == "github.com" || strings.HasSuffix(u.Hostname(), ".github.com") { return newDotcomHost() } if u.Hostname() == "ghe.com" || strings.HasSuffix(u.Hostname(), ".ghe.com") { return newGHECHost(s) } return newGHESHost(s) }