1package github
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io/ioutil"
  8	"net/http"
  9	"net/url"
 10	"regexp"
 11	"sort"
 12	"strconv"
 13	"strings"
 14	"time"
 15
 16	"github.com/pkg/errors"
 17
 18	"github.com/MichaelMure/git-bug/bridge/core"
 19	"github.com/MichaelMure/git-bug/bridge/core/auth"
 20	"github.com/MichaelMure/git-bug/cache"
 21	"github.com/MichaelMure/git-bug/commands/input"
 22	"github.com/MichaelMure/git-bug/repository"
 23)
 24
 25const githubClientID = "ce3600aa56c2e69f18a5" // git-bug org
 26
 27var (
 28	ErrBadProjectURL = errors.New("bad project url")
 29)
 30
 31func (g *Github) ValidParams() map[string]interface{} {
 32	return map[string]interface{}{
 33		"URL":        nil,
 34		"Login":      nil,
 35		"CredPrefix": nil,
 36		"TokenRaw":   nil,
 37		"Owner":      nil,
 38		"Project":    nil,
 39	}
 40}
 41
 42func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams, interactive bool) (core.Configuration, error) {
 43	var err error
 44	var owner string
 45	var project string
 46	var ok bool
 47
 48	// getting owner and project name
 49	switch {
 50	case params.Owner != "" && params.Project != "":
 51		// first try to use params if both or project and owner are provided
 52		owner = params.Owner
 53		project = params.Project
 54	case params.URL != "":
 55		// try to parse params URL and extract owner and project
 56		owner, project, err = splitURL(params.URL)
 57		if err != nil {
 58			return nil, err
 59		}
 60	default:
 61		// terminal prompt
 62		if !interactive {
 63			return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the remote repository with --owner and --project, or via --url option.")
 64		}
 65		owner, project, err = promptURL(repo)
 66		if err != nil {
 67			return nil, err
 68		}
 69	}
 70
 71	// validate project owner and override with the correct case
 72	ok, owner, err = validateUsername(owner)
 73	if err != nil {
 74		return nil, err
 75	}
 76	if !ok {
 77		return nil, fmt.Errorf("invalid parameter owner: %v", owner)
 78	}
 79
 80	var login string
 81	var cred auth.Credential
 82
 83	switch {
 84	case params.CredPrefix != "":
 85		cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
 86		if err != nil {
 87			return nil, err
 88		}
 89		l, ok := cred.GetMetadata(auth.MetaKeyLogin)
 90		if !ok {
 91			return nil, fmt.Errorf("credential doesn't have a login")
 92		}
 93		login = l
 94	case params.TokenRaw != "":
 95		token := auth.NewToken(target, params.TokenRaw)
 96		login, err = getLoginFromToken(token)
 97		if err != nil {
 98			return nil, err
 99		}
100		token.SetMetadata(auth.MetaKeyLogin, login)
101		cred = token
102	default:
103		if params.Login == "" {
104			if !interactive {
105				return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a login via the --login option.")
106			}
107			login, err = promptLogin()
108		} else {
109			// validate login and override with the correct case
110			ok, login, err = validateUsername(params.Login)
111			if !ok {
112				return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
113			}
114		}
115		if err != nil {
116			return nil, err
117		}
118
119		if !interactive {
120			return nil, fmt.Errorf("Non-interactive-mode is active. Please specify a access token via the --token option.")
121		}
122		cred, err = promptTokenOptions(repo, login, owner, project)
123		if err != nil {
124			return nil, err
125		}
126	}
127
128	token, ok := cred.(*auth.Token)
129	if !ok {
130		return nil, fmt.Errorf("the Github bridge only handle token credentials")
131	}
132
133	// verify access to the repository with token
134	ok, err = validateProject(owner, project, token)
135	if err != nil {
136		return nil, err
137	}
138	if !ok {
139		return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
140	}
141
142	conf := make(core.Configuration)
143	conf[core.ConfigKeyTarget] = target
144	conf[confKeyOwner] = owner
145	conf[confKeyProject] = project
146	conf[confKeyDefaultLogin] = login
147
148	err = g.ValidateConfig(conf)
149	if err != nil {
150		return nil, err
151	}
152
153	// don't forget to store the now known valid token
154	if !auth.IdExist(repo, cred.ID()) {
155		err = auth.Store(repo, cred)
156		if err != nil {
157			return nil, err
158		}
159	}
160
161	return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
162}
163
164func (*Github) ValidateConfig(conf core.Configuration) error {
165	if v, ok := conf[core.ConfigKeyTarget]; !ok {
166		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
167	} else if v != target {
168		return fmt.Errorf("unexpected target name: %v", v)
169	}
170	if _, ok := conf[confKeyOwner]; !ok {
171		return fmt.Errorf("missing %s key", confKeyOwner)
172	}
173	if _, ok := conf[confKeyProject]; !ok {
174		return fmt.Errorf("missing %s key", confKeyProject)
175	}
176	if _, ok := conf[confKeyDefaultLogin]; !ok {
177		return fmt.Errorf("missing %s key", confKeyDefaultLogin)
178	}
179
180	return nil
181}
182
183func requestToken() (string, error) {
184	scope, err := promptUserForProjectVisibility()
185	if err != nil {
186		return "", errors.WithStack(err)
187	}
188	resp, err := requestUserVerificationCode(scope)
189	if err != nil {
190		return "", err
191	}
192	promptUserToGoToBrowser(resp.uri, resp.userCode)
193	return pollGithubForAuthorization(resp.deviceCode, resp.interval)
194}
195
196func promptUserForProjectVisibility() (string, error) {
197	fmt.Println("git-bug will now generate an access token in your Github profile. The token is stored in the global git config.")
198	fmt.Println()
199	fmt.Println("The access scope depend on the type of repository.")
200	fmt.Println("Public:")
201	fmt.Println("  - 'public_repo': to be able to read public repositories")
202	fmt.Println("Private:")
203	fmt.Println("  - 'repo'       : to be able to read private repositories")
204	fmt.Println()
205	index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
206	if err != nil {
207		return "", err
208	}
209	return []string{"public_repo", "repo"}[index], nil
210}
211
212type githRespT struct {
213	uri        string
214	userCode   string
215	deviceCode string
216	interval   int64
217}
218
219func requestUserVerificationCode(scope string) (*githRespT, error) {
220	params := url.Values{}
221	params.Set("client_id", githubClientID)
222	params.Set("scope", scope)
223	client := &http.Client{
224		Timeout: defaultTimeout,
225	}
226
227	resp, err := client.PostForm("https://github.com/login/device/code", params)
228	if err != nil {
229		return nil, errors.Wrap(err, "error requesting user verification code")
230	}
231	defer resp.Body.Close()
232	if resp.StatusCode != http.StatusOK {
233		return nil, fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
234	}
235
236	data, err := ioutil.ReadAll(resp.Body)
237	if err != nil {
238		return nil, errors.Wrap(err, "error requesting user verification code")
239	}
240
241	vals, err := url.ParseQuery(string(data))
242	if err != nil {
243		return nil, errors.Wrap(err, "error decoding Github API response")
244	}
245
246	interval, err := strconv.ParseInt(vals.Get("interval"), 10, 64) // base 10, bitSize 64
247	if err != nil {
248		return nil, errors.Wrap(err, "Error parsing integer received from Github API")
249	}
250
251	return &githRespT{
252		uri:        vals.Get("verification_uri"),
253		userCode:   vals.Get("user_code"),
254		deviceCode: vals.Get("device_code"),
255		interval:   interval,
256	}, nil
257}
258
259func promptUserToGoToBrowser(url, userCode string) {
260	fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.")
261	fmt.Println()
262	fmt.Println("  URL:", url)
263	fmt.Println("  user authentication code:", userCode)
264	fmt.Println()
265}
266
267func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, error) {
268	params := url.Values{}
269	params.Set("client_id", githubClientID)
270	params.Set("device_code", deviceCode)
271	params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628
272	client := &http.Client{
273		Timeout: defaultTimeout,
274	}
275	interval := time.Duration(intervalSec * 1100) // milliseconds, add 10% margin
276
277	for {
278		resp, err := client.PostForm("https://github.com/login/oauth/access_token", params)
279		if err != nil {
280			return "", errors.Wrap(err, "error polling the Github API")
281		}
282		if resp.StatusCode != http.StatusOK {
283			_ = resp.Body.Close()
284			return "", fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
285		}
286
287		data, err := ioutil.ReadAll(resp.Body)
288		if err != nil {
289			_ = resp.Body.Close()
290			return "", errors.Wrap(err, "error polling the Github API")
291		}
292		_ = resp.Body.Close()
293
294		values, err := url.ParseQuery(string(data))
295		if err != nil {
296			return "", errors.Wrap(err, "error decoding Github API response")
297		}
298
299		if token := values.Get("access_token"); token != "" {
300			return token, nil
301		}
302
303		switch apiError := values.Get("error"); apiError {
304		case "slow_down":
305			interval += 5500 // add 5 seconds (RFC 8628), plus some margin
306			time.Sleep(interval * time.Millisecond)
307			continue
308		case "authorization_pending":
309			time.Sleep(interval * time.Millisecond)
310			continue
311		case "":
312			return "", errors.New("unexpected response from Github API")
313		default:
314			// apiError should equal one of: "expired_token", "unsupported_grant_type",
315			// "incorrect_client_credentials", "incorrect_device_code", or "access_denied"
316			return "", fmt.Errorf("error creating token: %v, %v", apiError, values.Get("error_description"))
317		}
318	}
319}
320
321func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
322	creds, err := auth.List(repo,
323		auth.WithTarget(target),
324		auth.WithKind(auth.KindToken),
325		auth.WithMeta(auth.MetaKeyLogin, login),
326	)
327	if err != nil {
328		return nil, err
329	}
330
331	cred, index, err := input.PromptCredential(target, "token", creds, []string{
332		"enter my token",
333		"interactive token creation",
334	})
335	switch {
336	case err != nil:
337		return nil, err
338	case cred != nil:
339		return cred, nil
340	case index == 0:
341		return promptToken()
342	case index == 1:
343		value, err := requestToken()
344		if err != nil {
345			return nil, err
346		}
347		token := auth.NewToken(target, value)
348		token.SetMetadata(auth.MetaKeyLogin, login)
349		return token, nil
350	default:
351		panic("missed case")
352	}
353}
354
355func promptToken() (*auth.Token, error) {
356	fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
357	fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
358	fmt.Println()
359	fmt.Println("The access scope depend on the type of repository.")
360	fmt.Println("Public:")
361	fmt.Println("  - 'public_repo': to be able to read public repositories")
362	fmt.Println("Private:")
363	fmt.Println("  - 'repo'       : to be able to read private repositories")
364	fmt.Println()
365
366	legacyRe := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
367	re := regexp.MustCompile(`^(?:ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,255}$`)
368
369	var login string
370
371	validator := func(name string, value string) (complaint string, err error) {
372		if !re.MatchString(value) && !legacyRe.MatchString(value) {
373			return "token has incorrect format", nil
374		}
375		login, err = getLoginFromToken(auth.NewToken(target, value))
376		if err != nil {
377			return fmt.Sprintf("token is invalid: %v", err), nil
378		}
379		return "", nil
380	}
381
382	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
383	if err != nil {
384		return nil, err
385	}
386
387	token := auth.NewToken(target, rawToken)
388	token.SetMetadata(auth.MetaKeyLogin, login)
389
390	return token, nil
391}
392
393func promptURL(repo repository.RepoCommon) (string, string, error) {
394	validRemotes, err := getValidGithubRemoteURLs(repo)
395	if err != nil {
396		return "", "", err
397	}
398
399	validator := func(name, value string) (string, error) {
400		_, _, err := splitURL(value)
401		if err != nil {
402			return err.Error(), nil
403		}
404		return "", nil
405	}
406
407	url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
408	if err != nil {
409		return "", "", err
410	}
411
412	return splitURL(url)
413}
414
415// splitURL extract the owner and project from a github repository URL. It will remove the
416// '.git' extension from the URL before parsing it.
417// Note that Github removes the '.git' extension from projects names at their creation
418func splitURL(url string) (owner string, project string, err error) {
419	cleanURL := strings.TrimSuffix(url, ".git")
420
421	re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
422
423	res := re.FindStringSubmatch(cleanURL)
424	if res == nil {
425		return "", "", ErrBadProjectURL
426	}
427
428	owner = res[1]
429	project = res[2]
430	return
431}
432
433func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
434	remotes, err := repo.GetRemotes()
435	if err != nil {
436		return nil, err
437	}
438
439	urls := make([]string, 0, len(remotes))
440	for _, url := range remotes {
441		// split url can work again with shortURL
442		owner, project, err := splitURL(url)
443		if err == nil {
444			shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
445			urls = append(urls, shortURL)
446		}
447	}
448
449	sort.Strings(urls)
450
451	return urls, nil
452}
453
454func promptLogin() (string, error) {
455	var login string
456
457	validator := func(_ string, value string) (string, error) {
458		ok, fixed, err := validateUsername(value)
459		if err != nil {
460			return "", err
461		}
462		if !ok {
463			return "invalid login", nil
464		}
465		login = fixed
466		return "", nil
467	}
468
469	_, err := input.Prompt("Github login", "login", input.Required, validator)
470	if err != nil {
471		return "", err
472	}
473
474	return login, nil
475}
476
477func validateUsername(username string) (bool, string, error) {
478	url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
479
480	client := &http.Client{
481		Timeout: defaultTimeout,
482	}
483
484	resp, err := client.Get(url)
485	if err != nil {
486		return false, "", err
487	}
488
489	if resp.StatusCode != http.StatusOK {
490		return false, "", nil
491	}
492
493	data, err := ioutil.ReadAll(resp.Body)
494	if err != nil {
495		return false, "", err
496	}
497
498	err = resp.Body.Close()
499	if err != nil {
500		return false, "", err
501	}
502
503	var decoded struct {
504		Login string `json:"login"`
505	}
506	err = json.Unmarshal(data, &decoded)
507	if err != nil {
508		return false, "", err
509	}
510
511	if decoded.Login == "" {
512		return false, "", fmt.Errorf("validateUsername: missing login in the response")
513	}
514
515	return true, decoded.Login, nil
516}
517
518func validateProject(owner, project string, token *auth.Token) (bool, error) {
519	url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
520
521	req, err := http.NewRequest("GET", url, nil)
522	if err != nil {
523		return false, err
524	}
525
526	// need the token for private repositories
527	req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
528
529	client := &http.Client{
530		Timeout: defaultTimeout,
531	}
532
533	resp, err := client.Do(req)
534	if err != nil {
535		return false, err
536	}
537
538	err = resp.Body.Close()
539	if err != nil {
540		return false, err
541	}
542
543	return resp.StatusCode == http.StatusOK, nil
544}
545
546func getLoginFromToken(token *auth.Token) (string, error) {
547	ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
548	defer cancel()
549
550	client := buildClient(token)
551
552	var q loginQuery
553
554	err := client.queryPrintMsgs(ctx, &q, nil)
555	if err != nil {
556		return "", err
557	}
558	if q.Viewer.Login == "" {
559		return "", fmt.Errorf("github say username is empty")
560	}
561
562	return q.Viewer.Login, nil
563}