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 "syscall"
18 "time"
19
20 "github.com/pkg/errors"
21 "golang.org/x/crypto/ssh/terminal"
22
23 "github.com/MichaelMure/git-bug/bridge/core"
24 "github.com/MichaelMure/git-bug/entity"
25 "github.com/MichaelMure/git-bug/repository"
26 "github.com/MichaelMure/git-bug/util/interrupt"
27)
28
29const (
30 target = "github"
31 githubV3Url = "https://api.github.com"
32 keyOwner = "owner"
33 keyProject = "project"
34 keyToken = "token"
35
36 defaultTimeout = 60 * time.Second
37)
38
39var (
40 ErrBadProjectURL = errors.New("bad project url")
41)
42
43func (g *Github) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) {
44 conf := make(core.Configuration)
45 var err error
46 var token string
47 var tokenId entity.Id
48 var tokenObj *core.Token
49 var owner string
50 var project string
51
52 if (params.Token != "" || params.TokenId != "" || params.TokenStdin) &&
53 (params.URL == "" && (params.Project == "" || params.Owner == "")) {
54 return nil, fmt.Errorf("you must provide a project URL or Owner/Name to configure this bridge with a token")
55 }
56
57 // getting owner and project name
58 if params.Owner != "" && params.Project != "" {
59 // first try to use params if both or project and owner are provided
60 owner = params.Owner
61 project = params.Project
62
63 } else if params.URL != "" {
64 // try to parse params URL and extract owner and project
65 owner, project, err = splitURL(params.URL)
66 if err != nil {
67 return nil, err
68 }
69
70 } else {
71 // remote suggestions
72 remotes, err := repo.GetRemotes()
73 if err != nil {
74 return nil, err
75 }
76
77 // terminal prompt
78 owner, project, err = promptURL(remotes)
79 if err != nil {
80 return nil, err
81 }
82 }
83
84 // validate project owner
85 ok, err := validateUsername(owner)
86 if err != nil {
87 return nil, err
88 }
89 if !ok {
90 return nil, fmt.Errorf("invalid parameter owner: %v", owner)
91 }
92
93 // try to get token from params if provided, else use terminal prompt
94 // to either enter a token or login and generate a new one, or choose
95 // an existing token
96 if params.Token != "" {
97 token = params.Token
98 } else if params.TokenStdin {
99 reader := bufio.NewReader(os.Stdin)
100 token, err = reader.ReadString('\n')
101 if err != nil {
102 return nil, fmt.Errorf("reading from stdin: %v", err)
103 }
104 token = strings.TrimSuffix(token, "\n")
105 } else if params.TokenId != "" {
106 tokenId = entity.Id(params.TokenId)
107 } else {
108 tokenObj, err = promptTokenOptions(repo, owner, project)
109 if err != nil {
110 return nil, err
111 }
112 }
113
114 // at this point, we check if the token already exist or we create a new one
115 if token != "" {
116 tokenObj, err = loadOrCreateToken(repo, token)
117 if err != nil {
118 return nil, err
119 }
120 } else if tokenId != "" {
121 tokenObj, err = core.LoadToken(repo, entity.Id(tokenId))
122 if err != nil {
123 return nil, err
124 }
125 if tokenObj.Target != target {
126 return nil, fmt.Errorf("token target is incompatible %s", tokenObj.Target)
127 }
128 }
129
130 // verify access to the repository with token
131 ok, err = validateProject(owner, project, tokenObj.Value)
132 if err != nil {
133 return nil, err
134 }
135 if !ok {
136 return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
137 }
138
139 conf[core.ConfigKeyTarget] = target
140 conf[core.ConfigKeyTokenId] = tokenObj.ID().String()
141 conf[keyOwner] = owner
142 conf[keyProject] = project
143
144 err = g.ValidateConfig(conf)
145 if err != nil {
146 return nil, err
147 }
148
149 return conf, nil
150}
151
152func loadOrCreateToken(repo repository.RepoCommon, tokenValue string) (*core.Token, error) {
153 tokens, err := core.LoadTokens(repo)
154 if err != nil {
155 return nil, err
156 }
157
158 for _, token := range tokens {
159 if token.Target == target && token.Value == tokenValue {
160 return token, nil
161 }
162 }
163
164 token := core.NewToken(tokenValue, target)
165 err = core.StoreToken(repo, token)
166 if err != nil {
167 return nil, err
168 }
169
170 return token, nil
171}
172
173func (*Github) ValidateConfig(conf core.Configuration) error {
174 if v, ok := conf[core.ConfigKeyTarget]; !ok {
175 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
176 } else if v != target {
177 return fmt.Errorf("unexpected target name: %v", v)
178 }
179
180 if _, ok := conf[core.ConfigKeyTokenId]; !ok {
181 return fmt.Errorf("missing %s key", core.ConfigKeyTokenId)
182 }
183
184 if _, ok := conf[keyOwner]; !ok {
185 return fmt.Errorf("missing %s key", keyOwner)
186 }
187
188 if _, ok := conf[keyProject]; !ok {
189 return fmt.Errorf("missing %s key", keyProject)
190 }
191
192 return nil
193}
194
195func requestToken(note, username, password string, scope string) (*http.Response, error) {
196 return requestTokenWith2FA(note, username, password, "", scope)
197}
198
199func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) {
200 url := fmt.Sprintf("%s/authorizations", githubV3Url)
201 params := struct {
202 Scopes []string `json:"scopes"`
203 Note string `json:"note"`
204 Fingerprint string `json:"fingerprint"`
205 }{
206 Scopes: []string{scope},
207 Note: note,
208 Fingerprint: randomFingerprint(),
209 }
210
211 data, err := json.Marshal(params)
212 if err != nil {
213 return nil, err
214 }
215
216 req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
217 if err != nil {
218 return nil, err
219 }
220
221 req.SetBasicAuth(username, password)
222 req.Header.Set("Content-Type", "application/json")
223
224 if otpCode != "" {
225 req.Header.Set("X-GitHub-OTP", otpCode)
226 }
227
228 client := &http.Client{
229 Timeout: defaultTimeout,
230 }
231
232 return client.Do(req)
233}
234
235func decodeBody(body io.ReadCloser) (string, error) {
236 data, _ := ioutil.ReadAll(body)
237
238 aux := struct {
239 Token string `json:"token"`
240 }{}
241
242 err := json.Unmarshal(data, &aux)
243 if err != nil {
244 return "", err
245 }
246
247 if aux.Token == "" {
248 return "", fmt.Errorf("no token found in response: %s", string(data))
249 }
250
251 return aux.Token, nil
252}
253
254func randomFingerprint() string {
255 // Doesn't have to be crypto secure, it's just to avoid token collision
256 rand.Seed(time.Now().UnixNano())
257 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
258 b := make([]rune, 32)
259 for i := range b {
260 b[i] = letterRunes[rand.Intn(len(letterRunes))]
261 }
262 return string(b)
263}
264
265func promptTokenOptions(repo repository.RepoCommon, owner, project string) (*core.Token, error) {
266 for {
267 tokens, err := core.LoadTokens(repo)
268 if err != nil {
269 return nil, err
270 }
271
272 fmt.Println()
273 fmt.Println("[1]: user provided token")
274 fmt.Println("[2]: interactive token creation")
275 fmt.Println("known tokens for Github:")
276
277 var githubTokens []*core.Token
278 i := 0
279 for _, token := range tokens {
280 if token.Target == target {
281 fmt.Printf("[%d]: %s\n", i+3, token.ID())
282 githubTokens = append(githubTokens, token)
283 i++
284 }
285 }
286 fmt.Print("Select option: ")
287
288 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
289 fmt.Println()
290 if err != nil {
291 return nil, err
292 }
293
294 line = strings.TrimRight(line, "\n")
295
296 index, err := strconv.Atoi(line)
297 if err != nil || index < 1 || index > len(githubTokens)+2 {
298 fmt.Println("invalid input")
299 continue
300 }
301
302 var token string
303 if index == 1 {
304 token, err = promptToken()
305 if err != nil {
306 return nil, err
307 }
308 } else if index == 2 {
309 token, err = loginAndRequestToken(owner, project)
310 if err != nil {
311 return nil, err
312 }
313 } else {
314 return githubTokens[index-2], nil
315 }
316
317 return loadOrCreateToken(repo, token)
318 }
319}
320
321func tokenAlreadyExist(repo repository.RepoCommon, id string) (bool, error) {
322 tokens, err := core.LoadTokens(repo)
323 if err != nil {
324 return false, err
325 }
326
327 for _, token := range tokens {
328 if token.Target == target && token.Value == id {
329 return true, nil
330 }
331 }
332
333 return false, nil
334}
335
336func promptToken() (string, error) {
337 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
338 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
339 fmt.Println()
340 fmt.Println("The access scope depend on the type of repository.")
341 fmt.Println("Public:")
342 fmt.Println(" - 'public_repo': to be able to read public repositories")
343 fmt.Println("Private:")
344 fmt.Println(" - 'repo' : to be able to read private repositories")
345 fmt.Println()
346
347 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
348 if err != nil {
349 panic("regexp compile:" + err.Error())
350 }
351
352 for {
353 fmt.Print("Enter token: ")
354
355 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
356 if err != nil {
357 return "", err
358 }
359
360 token := strings.TrimRight(line, "\n")
361 if re.MatchString(token) {
362 return token, nil
363 }
364
365 fmt.Println("token is invalid")
366 }
367}
368
369func loginAndRequestToken(owner, project string) (string, error) {
370 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 repository git config.")
371 fmt.Println()
372 fmt.Println("The access scope depend on the type of repository.")
373 fmt.Println("Public:")
374 fmt.Println(" - 'public_repo': to be able to read public repositories")
375 fmt.Println("Private:")
376 fmt.Println(" - 'repo' : to be able to read private repositories")
377 fmt.Println()
378
379 // prompt project visibility to know the token scope needed for the repository
380 isPublic, err := promptProjectVisibility()
381 if err != nil {
382 return "", err
383 }
384
385 username, err := promptUsername()
386 if err != nil {
387 return "", err
388 }
389
390 password, err := promptPassword()
391 if err != nil {
392 return "", err
393 }
394
395 var scope string
396 if isPublic {
397 // public_repo is requested to be able to read public repositories
398 scope = "public_repo"
399 } else {
400 // 'repo' is request to be able to read private repositories
401 // /!\ token will have read/write rights on every private repository you have access to
402 scope = "repo"
403 }
404
405 // Attempt to authenticate and create a token
406
407 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
408
409 resp, err := requestToken(note, username, password, scope)
410 if err != nil {
411 return "", err
412 }
413
414 defer resp.Body.Close()
415
416 // Handle 2FA is needed
417 OTPHeader := resp.Header.Get("X-GitHub-OTP")
418 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
419 otpCode, err := prompt2FA()
420 if err != nil {
421 return "", err
422 }
423
424 resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
425 if err != nil {
426 return "", err
427 }
428
429 defer resp.Body.Close()
430 }
431
432 if resp.StatusCode == http.StatusCreated {
433 return decodeBody(resp.Body)
434 }
435
436 b, _ := ioutil.ReadAll(resp.Body)
437 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
438}
439
440func promptUsername() (string, error) {
441 for {
442 fmt.Print("username: ")
443
444 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
445 if err != nil {
446 return "", err
447 }
448
449 line = strings.TrimRight(line, "\n")
450
451 ok, err := validateUsername(line)
452 if err != nil {
453 return "", err
454 }
455 if ok {
456 return line, nil
457 }
458
459 fmt.Println("invalid username")
460 }
461}
462
463func promptURL(remotes map[string]string) (string, string, error) {
464 validRemotes := getValidGithubRemoteURLs(remotes)
465 if len(validRemotes) > 0 {
466 for {
467 fmt.Println("\nDetected projects:")
468
469 // print valid remote github urls
470 for i, remote := range validRemotes {
471 fmt.Printf("[%d]: %v\n", i+1, remote)
472 }
473
474 fmt.Printf("\n[0]: Another project\n\n")
475 fmt.Printf("Select option: ")
476
477 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
478 if err != nil {
479 return "", "", err
480 }
481
482 line = strings.TrimRight(line, "\n")
483
484 index, err := strconv.Atoi(line)
485 if err != nil || index < 0 || index > len(validRemotes) {
486 fmt.Println("invalid input")
487 continue
488 }
489
490 // if user want to enter another project url break this loop
491 if index == 0 {
492 break
493 }
494
495 // get owner and project with index
496 owner, project, _ := splitURL(validRemotes[index-1])
497 return owner, project, nil
498 }
499 }
500
501 // manually enter github url
502 for {
503 fmt.Print("Github project URL: ")
504
505 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
506 if err != nil {
507 return "", "", err
508 }
509
510 line = strings.TrimRight(line, "\n")
511 if line == "" {
512 fmt.Println("URL is empty")
513 continue
514 }
515
516 // get owner and project from url
517 owner, project, err := splitURL(line)
518 if err != nil {
519 fmt.Println(err)
520 continue
521 }
522
523 return owner, project, nil
524 }
525}
526
527// splitURL extract the owner and project from a github repository URL. It will remove the
528// '.git' extension from the URL before parsing it.
529// Note that Github removes the '.git' extension from projects names at their creation
530func splitURL(url string) (owner string, project string, err error) {
531 cleanURL := strings.TrimSuffix(url, ".git")
532
533 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
534 if err != nil {
535 panic("regexp compile:" + err.Error())
536 }
537
538 res := re.FindStringSubmatch(cleanURL)
539 if res == nil {
540 return "", "", ErrBadProjectURL
541 }
542
543 owner = res[1]
544 project = res[2]
545 return
546}
547
548func getValidGithubRemoteURLs(remotes map[string]string) []string {
549 urls := make([]string, 0, len(remotes))
550 for _, url := range remotes {
551 // split url can work again with shortURL
552 owner, project, err := splitURL(url)
553 if err == nil {
554 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
555 urls = append(urls, shortURL)
556 }
557 }
558
559 sort.Strings(urls)
560
561 return urls
562}
563
564func validateUsername(username string) (bool, error) {
565 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
566
567 client := &http.Client{
568 Timeout: defaultTimeout,
569 }
570
571 resp, err := client.Get(url)
572 if err != nil {
573 return false, err
574 }
575
576 err = resp.Body.Close()
577 if err != nil {
578 return false, err
579 }
580
581 return resp.StatusCode == http.StatusOK, nil
582}
583
584func validateProject(owner, project, token string) (bool, error) {
585 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
586
587 req, err := http.NewRequest("GET", url, nil)
588 if err != nil {
589 return false, err
590 }
591
592 // need the token for private repositories
593 req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
594
595 client := &http.Client{
596 Timeout: defaultTimeout,
597 }
598
599 resp, err := client.Do(req)
600 if err != nil {
601 return false, err
602 }
603
604 err = resp.Body.Close()
605 if err != nil {
606 return false, err
607 }
608
609 return resp.StatusCode == http.StatusOK, nil
610}
611
612func promptPassword() (string, error) {
613 termState, err := terminal.GetState(int(syscall.Stdin))
614 if err != nil {
615 return "", err
616 }
617
618 cancel := interrupt.RegisterCleaner(func() error {
619 return terminal.Restore(int(syscall.Stdin), termState)
620 })
621 defer cancel()
622
623 for {
624 fmt.Print("password: ")
625
626 bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
627 // new line for coherent formatting, ReadPassword clip the normal new line
628 // entered by the user
629 fmt.Println()
630
631 if err != nil {
632 return "", err
633 }
634
635 if len(bytePassword) > 0 {
636 return string(bytePassword), nil
637 }
638
639 fmt.Println("password is empty")
640 }
641}
642
643func prompt2FA() (string, error) {
644 termState, err := terminal.GetState(int(syscall.Stdin))
645 if err != nil {
646 return "", err
647 }
648
649 cancel := interrupt.RegisterCleaner(func() error {
650 return terminal.Restore(int(syscall.Stdin), termState)
651 })
652 defer cancel()
653
654 for {
655 fmt.Print("two-factor authentication code: ")
656
657 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
658 fmt.Println()
659 if err != nil {
660 return "", err
661 }
662
663 if len(byte2fa) > 0 {
664 return string(byte2fa), nil
665 }
666
667 fmt.Println("code is empty")
668 }
669}
670
671func promptProjectVisibility() (bool, error) {
672 for {
673 fmt.Println("[1]: public")
674 fmt.Println("[2]: private")
675 fmt.Print("repository visibility: ")
676
677 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
678 fmt.Println()
679 if err != nil {
680 return false, err
681 }
682
683 line = strings.TrimRight(line, "\n")
684
685 index, err := strconv.Atoi(line)
686 if err != nil || (index != 1 && index != 2) {
687 fmt.Println("invalid input")
688 continue
689 }
690
691 // return true for public repositories, false for private
692 return index == 1, nil
693 }
694}