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