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