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