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