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