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