config.go

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