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