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