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 promptToken() (string, error) {
322 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
323 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
324 fmt.Println()
325 fmt.Println("The access scope depend on the type of repository.")
326 fmt.Println("Public:")
327 fmt.Println(" - 'public_repo': to be able to read public repositories")
328 fmt.Println("Private:")
329 fmt.Println(" - 'repo' : to be able to read private repositories")
330 fmt.Println()
331
332 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
333 if err != nil {
334 panic("regexp compile:" + err.Error())
335 }
336
337 for {
338 fmt.Print("Enter token: ")
339
340 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
341 if err != nil {
342 return "", err
343 }
344
345 token := strings.TrimRight(line, "\n")
346 if re.MatchString(token) {
347 return token, nil
348 }
349
350 fmt.Println("token is invalid")
351 }
352}
353
354func loginAndRequestToken(owner, project string) (string, error) {
355 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.")
356 fmt.Println()
357 fmt.Println("The access scope depend on the type of repository.")
358 fmt.Println("Public:")
359 fmt.Println(" - 'public_repo': to be able to read public repositories")
360 fmt.Println("Private:")
361 fmt.Println(" - 'repo' : to be able to read private repositories")
362 fmt.Println()
363
364 // prompt project visibility to know the token scope needed for the repository
365 isPublic, err := promptProjectVisibility()
366 if err != nil {
367 return "", err
368 }
369
370 username, err := promptUsername()
371 if err != nil {
372 return "", err
373 }
374
375 password, err := promptPassword()
376 if err != nil {
377 return "", err
378 }
379
380 var scope string
381 if isPublic {
382 // public_repo is requested to be able to read public repositories
383 scope = "public_repo"
384 } else {
385 // 'repo' is request to be able to read private repositories
386 // /!\ token will have read/write rights on every private repository you have access to
387 scope = "repo"
388 }
389
390 // Attempt to authenticate and create a token
391
392 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
393
394 resp, err := requestToken(note, username, password, scope)
395 if err != nil {
396 return "", err
397 }
398
399 defer resp.Body.Close()
400
401 // Handle 2FA is needed
402 OTPHeader := resp.Header.Get("X-GitHub-OTP")
403 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
404 otpCode, err := prompt2FA()
405 if err != nil {
406 return "", err
407 }
408
409 resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
410 if err != nil {
411 return "", err
412 }
413
414 defer resp.Body.Close()
415 }
416
417 if resp.StatusCode == http.StatusCreated {
418 return decodeBody(resp.Body)
419 }
420
421 b, _ := ioutil.ReadAll(resp.Body)
422 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
423}
424
425func promptUsername() (string, error) {
426 for {
427 fmt.Print("username: ")
428
429 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
430 if err != nil {
431 return "", err
432 }
433
434 line = strings.TrimRight(line, "\n")
435
436 ok, err := validateUsername(line)
437 if err != nil {
438 return "", err
439 }
440 if ok {
441 return line, nil
442 }
443
444 fmt.Println("invalid username")
445 }
446}
447
448func promptURL(remotes map[string]string) (string, string, error) {
449 validRemotes := getValidGithubRemoteURLs(remotes)
450 if len(validRemotes) > 0 {
451 for {
452 fmt.Println("\nDetected projects:")
453
454 // print valid remote github urls
455 for i, remote := range validRemotes {
456 fmt.Printf("[%d]: %v\n", i+1, remote)
457 }
458
459 fmt.Printf("\n[0]: Another project\n\n")
460 fmt.Printf("Select option: ")
461
462 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
463 if err != nil {
464 return "", "", err
465 }
466
467 line = strings.TrimRight(line, "\n")
468
469 index, err := strconv.Atoi(line)
470 if err != nil || index < 0 || index > len(validRemotes) {
471 fmt.Println("invalid input")
472 continue
473 }
474
475 // if user want to enter another project url break this loop
476 if index == 0 {
477 break
478 }
479
480 // get owner and project with index
481 owner, project, _ := splitURL(validRemotes[index-1])
482 return owner, project, nil
483 }
484 }
485
486 // manually enter github url
487 for {
488 fmt.Print("Github project URL: ")
489
490 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
491 if err != nil {
492 return "", "", err
493 }
494
495 line = strings.TrimRight(line, "\n")
496 if line == "" {
497 fmt.Println("URL is empty")
498 continue
499 }
500
501 // get owner and project from url
502 owner, project, err := splitURL(line)
503 if err != nil {
504 fmt.Println(err)
505 continue
506 }
507
508 return owner, project, nil
509 }
510}
511
512// splitURL extract the owner and project from a github repository URL. It will remove the
513// '.git' extension from the URL before parsing it.
514// Note that Github removes the '.git' extension from projects names at their creation
515func splitURL(url string) (owner string, project string, err error) {
516 cleanURL := strings.TrimSuffix(url, ".git")
517
518 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
519 if err != nil {
520 panic("regexp compile:" + err.Error())
521 }
522
523 res := re.FindStringSubmatch(cleanURL)
524 if res == nil {
525 return "", "", ErrBadProjectURL
526 }
527
528 owner = res[1]
529 project = res[2]
530 return
531}
532
533func getValidGithubRemoteURLs(remotes map[string]string) []string {
534 urls := make([]string, 0, len(remotes))
535 for _, url := range remotes {
536 // split url can work again with shortURL
537 owner, project, err := splitURL(url)
538 if err == nil {
539 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
540 urls = append(urls, shortURL)
541 }
542 }
543
544 sort.Strings(urls)
545
546 return urls
547}
548
549func validateUsername(username string) (bool, error) {
550 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
551
552 client := &http.Client{
553 Timeout: defaultTimeout,
554 }
555
556 resp, err := client.Get(url)
557 if err != nil {
558 return false, err
559 }
560
561 err = resp.Body.Close()
562 if err != nil {
563 return false, err
564 }
565
566 return resp.StatusCode == http.StatusOK, nil
567}
568
569func validateProject(owner, project, token string) (bool, error) {
570 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
571
572 req, err := http.NewRequest("GET", url, nil)
573 if err != nil {
574 return false, err
575 }
576
577 // need the token for private repositories
578 req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
579
580 client := &http.Client{
581 Timeout: defaultTimeout,
582 }
583
584 resp, err := client.Do(req)
585 if err != nil {
586 return false, err
587 }
588
589 err = resp.Body.Close()
590 if err != nil {
591 return false, err
592 }
593
594 return resp.StatusCode == http.StatusOK, nil
595}
596
597func promptPassword() (string, error) {
598 termState, err := terminal.GetState(int(syscall.Stdin))
599 if err != nil {
600 return "", err
601 }
602
603 cancel := interrupt.RegisterCleaner(func() error {
604 return terminal.Restore(int(syscall.Stdin), termState)
605 })
606 defer cancel()
607
608 for {
609 fmt.Print("password: ")
610
611 bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
612 // new line for coherent formatting, ReadPassword clip the normal new line
613 // entered by the user
614 fmt.Println()
615
616 if err != nil {
617 return "", err
618 }
619
620 if len(bytePassword) > 0 {
621 return string(bytePassword), nil
622 }
623
624 fmt.Println("password is empty")
625 }
626}
627
628func prompt2FA() (string, error) {
629 termState, err := terminal.GetState(int(syscall.Stdin))
630 if err != nil {
631 return "", err
632 }
633
634 cancel := interrupt.RegisterCleaner(func() error {
635 return terminal.Restore(int(syscall.Stdin), termState)
636 })
637 defer cancel()
638
639 for {
640 fmt.Print("two-factor authentication code: ")
641
642 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
643 fmt.Println()
644 if err != nil {
645 return "", err
646 }
647
648 if len(byte2fa) > 0 {
649 return string(byte2fa), nil
650 }
651
652 fmt.Println("code is empty")
653 }
654}
655
656func promptProjectVisibility() (bool, error) {
657 for {
658 fmt.Println("[1]: public")
659 fmt.Println("[2]: private")
660 fmt.Print("repository visibility: ")
661
662 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
663 fmt.Println()
664 if err != nil {
665 return false, err
666 }
667
668 line = strings.TrimRight(line, "\n")
669
670 index, err := strconv.Atoi(line)
671 if err != nil || (index != 1 && index != 2) {
672 fmt.Println("invalid input")
673 continue
674 }
675
676 // return true for public repositories, false for private
677 return index == 1, nil
678 }
679}