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