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