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