aboutsummaryrefslogblamecommitdiffstats
path: root/bridge/github/config.go
blob: c0e176d3bcc60c523246d69b4fef4c8670fa09fe (plain) (tree)
1
2
3
4
5
6
7
8
9


              
                 

                       


                   
                 
                
              
                 

              
                               
 
                                                    

                                                         
                                              
                                                   

 
     
                                                        
                                                 
 
 







                                                       
         
 
 
                                                                                                         
                     

                          
                   
 
                                         

                                                        
                                                                                    

                                        
                              
                                                                        
                                                          


                                       
                
                                  
                                                     


                                       

         

                                                                    


                               

                                                                            

         
                        




                                                                        

                                       
                 




                                                                                 
                                   
                                                               





                                                           
                






                                                                                                   

                         


                                       
                                                                           


                                       




                                                                                         

         
                                                     
                                                        



                               
                                                                                                              

         
                                        
                                           

                                      
                                         
 




                                    







                                                          
                                                                       

 
                                                              

                                                                         

                                                                  
         

                                                                 
         

                                                                   
         


                                                                        



                  




                                                                                                
         


                                                       
                       
                              
         

                                              
                       
                              
         


                                                   
         


                                                           
 



                                                                        


                                        









                                                                                                  

 






                                                                                                          
 






                                                                                                     
         











































                                                                                                                                
         
                         












                                                                                        
                                                                                                             







                                                        
 







                                                                                    
                                
                        
                                    
                        
                                            
                               
                                       
                 



                                                           
                                    


         
                                         




                                                                                                          
                                                                                
                               
                                                                                 

                     
                                                     
 

                        
                                                                                    


                                                                
                                                                            

                                                                            
                 





                                                                                        
         
 
                                                


                                                   

 
                                                                    
                                                           



                                  

                                                               
                               
                                               
                 

                              
 


                                                                                                                   
         

                            

 



                                                                                          
                                                   
 
                                                                                       

                                              
                       
                                               

         

                        
              

 





                                                                             


                                                         
                                                    
                               
                                                                                         

                                                     

         

                          
                        

 























                                                                                  

                                                                




                                        
                       









                                              



                               
                                     

         












                                                                                               

 
                                                                              






                                                                         
                                                  
                                                                             
















                                                    


















                                                                                
package github

import (
	"context"
	"encoding/json"
	"fmt"
	"io/ioutil"
	"math/rand"
	"net/http"
	"net/url"
	"regexp"
	"sort"
	"strings"
	"time"

	"github.com/pkg/errors"

	"github.com/MichaelMure/git-bug/bridge/core"
	"github.com/MichaelMure/git-bug/bridge/core/auth"
	"github.com/MichaelMure/git-bug/cache"
	"github.com/MichaelMure/git-bug/input"
	"github.com/MichaelMure/git-bug/repository"
)

var (
	ErrBadProjectURL = errors.New("bad project url")
	GithubClientID   = "ce3600aa56c2e69f18a5"
)

func (g *Github) ValidParams() map[string]interface{} {
	return map[string]interface{}{
		"URL":        nil,
		"Login":      nil,
		"CredPrefix": nil,
		"TokenRaw":   nil,
		"Owner":      nil,
		"Project":    nil,
	}
}

func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
	var err error
	var owner string
	var project string
	var ok bool

	// getting owner and project name
	switch {
	case params.Owner != "" && params.Project != "":
		// first try to use params if both or project and owner are provided
		owner = params.Owner
		project = params.Project
	case params.URL != "":
		// try to parse params URL and extract owner and project
		owner, project, err = splitURL(params.URL)
		if err != nil {
			return nil, err
		}
	default:
		// terminal prompt
		owner, project, err = promptURL(repo)
		if err != nil {
			return nil, err
		}
	}

	// validate project owner and override with the correct case
	ok, owner, err = validateUsername(owner)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
	}

	var login string
	var cred auth.Credential

	switch {
	case params.CredPrefix != "":
		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
		if err != nil {
			return nil, err
		}
		l, ok := cred.GetMetadata(auth.MetaKeyLogin)
		if !ok {
			return nil, fmt.Errorf("credential doesn't have a login")
		}
		login = l
	case params.TokenRaw != "":
		token := auth.NewToken(target, params.TokenRaw)
		login, err = getLoginFromToken(token)
		if err != nil {
			return nil, err
		}
		token.SetMetadata(auth.MetaKeyLogin, login)
		cred = token
	default:
		if params.Login == "" {
			login, err = promptLogin()
		} else {
			// validate login and override with the correct case
			ok, login, err = validateUsername(params.Login)
			if !ok {
				return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
			}
		}
		if err != nil {
			return nil, err
		}
		cred, err = promptTokenOptions(repo, login, owner, project)
		if err != nil {
			return nil, err
		}
	}

	token, ok := cred.(*auth.Token)
	if !ok {
		return nil, fmt.Errorf("the Github bridge only handle token credentials")
	}

	// verify access to the repository with token
	ok, err = validateProject(owner, project, token)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
	}

	conf := make(core.Configuration)
	conf[core.ConfigKeyTarget] = target
	conf[confKeyOwner] = owner
	conf[confKeyProject] = project
	conf[confKeyDefaultLogin] = login

	err = g.ValidateConfig(conf)
	if err != nil {
		return nil, err
	}

	// don't forget to store the now known valid token
	if !auth.IdExist(repo, cred.ID()) {
		err = auth.Store(repo, cred)
		if err != nil {
			return nil, err
		}
	}

	return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
}

func (*Github) ValidateConfig(conf core.Configuration) error {
	if v, ok := conf[core.ConfigKeyTarget]; !ok {
		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
	} else if v != target {
		return fmt.Errorf("unexpected target name: %v", v)
	}
	if _, ok := conf[confKeyOwner]; !ok {
		return fmt.Errorf("missing %s key", confKeyOwner)
	}
	if _, ok := conf[confKeyProject]; !ok {
		return fmt.Errorf("missing %s key", confKeyProject)
	}
	if _, ok := conf[confKeyDefaultLogin]; !ok {
		return fmt.Errorf("missing %s key", confKeyDefaultLogin)
	}

	return nil
}

func requestToken() (string, error) {
	// prompt project visibility to know the token scope needed for the repository
	index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
	if err != nil {
		return "", err
	}
	scope := []string{"public_repo", "repo"}[index]
	//
	resp, err := requestUserVerificationCode(scope)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}
	values, err := url.ParseQuery(string(data))
	if err != nil {
		return "", err
	}
	promptUserToGoToBrowser(values.Get("user_code"))
	return pollGithubUntilUserAuthorizedGitbug(&values)
}

func requestUserVerificationCode(scope string) (*http.Response, error) {
	params := url.Values{}
	params.Set("client_id", GithubClientID)
	params.Set("scope", scope)
	client := &http.Client{
		Timeout: defaultTimeout,
	}
	resp, err := client.PostForm("https://github.com/login/device/code", params)
	if err != nil {
		return nil, err
	}
	if resp.StatusCode != http.StatusOK {
		defer resp.Body.Close()
		bb, _ := ioutil.ReadAll(resp.Body)
		return nil, fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(bb))
	}
	return resp, nil
}

func promptUserToGoToBrowser(code string) {
	fmt.Println("Please visit the following URL in a browser and enter the user authentication code.")
	fmt.Println()
	fmt.Println("  URL: https://github.com/login/device")
	fmt.Println("  user authentiation code: ", code)
	fmt.Println()
}

func pollGithubUntilUserAuthorizedGitbug(values1 *url.Values) (string, error) {
	params := url.Values{}
	params.Set("client_id", GithubClientID)
	params.Set("device_code", values1.Get("device_code"))
	params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628
	client := &http.Client{
		Timeout: defaultTimeout,
	}
	// there exists a minimum interval required by the github API
	var initialInterval time.Duration = 6 // seconds
	var interval time.Duration = initialInterval
	token := ""
	for {
		resp, err := client.PostForm("https://github.com/login/oauth/access_token", params)
		if err != nil {
			return "", err
		}
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			bb, _ := ioutil.ReadAll(resp.Body)
			return "", fmt.Errorf("error creating token %v, %v", resp.StatusCode, string(bb))
		}
		data2, err := ioutil.ReadAll(resp.Body)
		if err != nil {
			return "", err
		}
		values2, err := url.ParseQuery(string(data2))
		if err != nil {
			return "", err
		}
		apiError := values2.Get("error")
		if apiError != "" {
			if apiError == "slow_down" {
				interval *= 2
			} else {
				interval = initialInterval
			}
			if apiError == "authorization_pending" || apiError == "slow_down" {
				// no-op
			} else {
				// apiError equals on of: "expired_token", "unsupported_grant_type",
				// "incorrect_client_credentials", "incorrect_device_code", or "access_denied"
				return "", fmt.Errorf("error creating token %v, %v", apiError, values2.Get("error_description"))
			}
			time.Sleep(interval * time.Second)
			continue
		}
		token = values2.Get("access_token")
		if token == "" {
			panic("invalid Github API response")
		}
		break
	}
	return token, nil
}

func randomFingerprint() string {
	// Doesn't have to be crypto secure, it's just to avoid token collision
	rand.Seed(time.Now().UnixNano())
	var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
	b := make([]rune, 32)
	for i := range b {
		b[i] = letterRunes[rand.Intn(len(letterRunes))]
	}
	return string(b)
}

func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
	creds, err := auth.List(repo,
		auth.WithTarget(target),
		auth.WithKind(auth.KindToken),
		auth.WithMeta(auth.MetaKeyLogin, login),
	)
	if err != nil {
		return nil, err
	}

	cred, index, err := input.PromptCredential(target, "token", creds, []string{
		"enter my token",
		"interactive token creation",
	})
	switch {
	case err != nil:
		return nil, err
	case cred != nil:
		return cred, nil
	case index == 0:
		return promptToken()
	case index == 1:
		value, err := requestToken()
		if err != nil {
			return nil, err
		}
		token := auth.NewToken(target, value)
		token.SetMetadata(auth.MetaKeyLogin, login)
		return token, nil
	default:
		panic("missed case")
	}
}

func promptToken() (*auth.Token, error) {
	fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
	fmt.Println()
	fmt.Println("The access scope depend on the type of repository.")
	fmt.Println("Public:")
	fmt.Println("  - 'public_repo': to be able to read public repositories")
	fmt.Println("Private:")
	fmt.Println("  - 'repo'       : to be able to read private repositories")
	fmt.Println()

	re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)

	var login string

	validator := func(name string, value string) (complaint string, err error) {
		if !re.MatchString(value) {
			return "token has incorrect format", nil
		}
		login, err = getLoginFromToken(auth.NewToken(target, value))
		if err != nil {
			return fmt.Sprintf("token is invalid: %v", err), nil
		}
		return "", nil
	}

	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
	if err != nil {
		return nil, err
	}

	token := auth.NewToken(target, rawToken)
	token.SetMetadata(auth.MetaKeyLogin, login)

	return token, nil
}

func promptURL(repo repository.RepoCommon) (string, string, error) {
	validRemotes, err := getValidGithubRemoteURLs(repo)
	if err != nil {
		return "", "", err
	}

	validator := func(name, value string) (string, error) {
		_, _, err := splitURL(value)
		if err != nil {
			return err.Error(), nil
		}
		return "", nil
	}

	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
	if err != nil {
		return "", "", err
	}

	return splitURL(url)
}

// splitURL extract the owner and project from a github repository URL. It will remove the
// '.git' extension from the URL before parsing it.
// Note that Github removes the '.git' extension from projects names at their creation
func splitURL(url string) (owner string, project string, err error) {
	cleanURL := strings.TrimSuffix(url, ".git")

	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)

	res := re.FindStringSubmatch(cleanURL)
	if res == nil {
		return "", "", ErrBadProjectURL
	}

	owner = res[1]
	project = res[2]
	return
}

func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
	remotes, err := repo.GetRemotes()
	if err != nil {
		return nil, err
	}

	urls := make([]string, 0, len(remotes))
	for _, url := range remotes {
		// split url can work again with shortURL
		owner, project, err := splitURL(url)
		if err == nil {
			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
			urls = append(urls, shortURL)
		}
	}

	sort.Strings(urls)

	return urls, nil
}

func promptLogin() (string, error) {
	var login string

	validator := func(_ string, value string) (string, error) {
		ok, fixed, err := validateUsername(value)
		if err != nil {
			return "", err
		}
		if !ok {
			return "invalid login", nil
		}
		login = fixed
		return "", nil
	}

	_, err := input.Prompt("Github login", "login", input.Required, validator)
	if err != nil {
		return "", err
	}

	return login, nil
}

func validateUsername(username string) (bool, string, error) {
	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)

	client := &http.Client{
		Timeout: defaultTimeout,
	}

	resp, err := client.Get(url)
	if err != nil {
		return false, "", err
	}

	if resp.StatusCode != http.StatusOK {
		return false, "", nil
	}

	data, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return false, "", err
	}

	err = resp.Body.Close()
	if err != nil {
		return false, "", err
	}

	var decoded struct {
		Login string `json:"login"`
	}
	err = json.Unmarshal(data, &decoded)
	if err != nil {
		return false, "", err
	}

	if decoded.Login == "" {
		return false, "", fmt.Errorf("validateUsername: missing login in the response")
	}

	return true, decoded.Login, nil
}

func validateProject(owner, project string, token *auth.Token) (bool, error) {
	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)

	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return false, err
	}

	// need the token for private repositories
	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))

	client := &http.Client{
		Timeout: defaultTimeout,
	}

	resp, err := client.Do(req)
	if err != nil {
		return false, err
	}

	err = resp.Body.Close()
	if err != nil {
		return false, err
	}

	return resp.StatusCode == http.StatusOK, nil
}

func getLoginFromToken(token *auth.Token) (string, error) {
	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
	defer cancel()

	client := buildClient(token)

	var q loginQuery

	err := client.Query(ctx, &q, nil)
	if err != nil {
		return "", err
	}
	if q.Viewer.Login == "" {
		return "", fmt.Errorf("github say username is empty")
	}

	return q.Viewer.Login, nil
}