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 // Register restore state cleaner before prompting passwords inputs
241 termState, err := terminal.GetState(int(syscall.Stdin))
242 if err != nil {
243 return "", err
244 }
245
246 interrupt.RegisterCleaner(func() error {
247 return terminal.Restore(int(syscall.Stdin), termState)
248 })
249
250 return loginAndRequestToken(owner, project)
251 }
252}
253
254func promptToken() (string, error) {
255 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
256 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
257 fmt.Println()
258 fmt.Println("The access scope depend on the type of repository.")
259 fmt.Println("Public:")
260 fmt.Println(" - 'public_repo': to be able to read public repositories")
261 fmt.Println("Private:")
262 fmt.Println(" - 'repo' : to be able to read private repositories")
263 fmt.Println()
264
265 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
266 if err != nil {
267 panic("regexp compile:" + err.Error())
268 }
269
270 for {
271 fmt.Print("Enter token: ")
272
273 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
274 if err != nil {
275 return "", err
276 }
277
278 token := strings.TrimRight(line, "\n")
279 if re.MatchString(token) {
280 return token, nil
281 }
282
283 fmt.Println("token is invalid")
284 }
285}
286
287func loginAndRequestToken(owner, project string) (string, error) {
288 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.")
289 fmt.Println()
290 fmt.Println("The access scope depend on the type of repository.")
291 fmt.Println("Public:")
292 fmt.Println(" - 'public_repo': to be able to read public repositories")
293 fmt.Println("Private:")
294 fmt.Println(" - 'repo' : to be able to read private repositories")
295 fmt.Println()
296
297 // prompt project visibility to know the token scope needed for the repository
298 isPublic, err := promptProjectVisibility()
299 if err != nil {
300 return "", err
301 }
302
303 username, err := promptUsername()
304 if err != nil {
305 return "", err
306 }
307
308 password, err := promptPassword()
309 if err != nil {
310 return "", err
311 }
312
313 var scope string
314 if isPublic {
315 // public_repo is requested to be able to read public repositories
316 scope = "public_repo"
317 } else {
318 // 'repo' is request to be able to read private repositories
319 // /!\ token will have read/write rights on every private repository you have access to
320 scope = "repo"
321 }
322
323 // Attempt to authenticate and create a token
324
325 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
326
327 resp, err := requestToken(note, username, password, scope)
328 if err != nil {
329 return "", err
330 }
331
332 defer resp.Body.Close()
333
334 // Handle 2FA is needed
335 OTPHeader := resp.Header.Get("X-GitHub-OTP")
336 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
337 otpCode, err := prompt2FA()
338 if err != nil {
339 return "", err
340 }
341
342 resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
343 if err != nil {
344 return "", err
345 }
346
347 defer resp.Body.Close()
348 }
349
350 if resp.StatusCode == http.StatusCreated {
351 return decodeBody(resp.Body)
352 }
353
354 b, _ := ioutil.ReadAll(resp.Body)
355 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
356}
357
358func promptUsername() (string, error) {
359 for {
360 fmt.Print("username: ")
361
362 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
363 if err != nil {
364 return "", err
365 }
366
367 line = strings.TrimRight(line, "\n")
368
369 ok, err := validateUsername(line)
370 if err != nil {
371 return "", err
372 }
373 if ok {
374 return line, nil
375 }
376
377 fmt.Println("invalid username")
378 }
379}
380
381func promptURL(remotes map[string]string) (string, string, error) {
382 validRemotes := getValidGithubRemoteURLs(remotes)
383 if len(validRemotes) > 0 {
384 for {
385 fmt.Println("\nDetected projects:")
386
387 // print valid remote github urls
388 for i, remote := range validRemotes {
389 fmt.Printf("[%d]: %v\n", i+1, remote)
390 }
391
392 fmt.Printf("\n[0]: Another project\n\n")
393 fmt.Printf("Select option: ")
394
395 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
396 if err != nil {
397 return "", "", err
398 }
399
400 line = strings.TrimRight(line, "\n")
401
402 index, err := strconv.Atoi(line)
403 if err != nil || index < 0 || index > len(validRemotes) {
404 fmt.Println("invalid input")
405 continue
406 }
407
408 // if user want to enter another project url break this loop
409 if index == 0 {
410 break
411 }
412
413 // get owner and project with index
414 owner, project, _ := splitURL(validRemotes[index-1])
415 return owner, project, nil
416 }
417 }
418
419 // manually enter github url
420 for {
421 fmt.Print("Github project URL: ")
422
423 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
424 if err != nil {
425 return "", "", err
426 }
427
428 line = strings.TrimRight(line, "\n")
429 if line == "" {
430 fmt.Println("URL is empty")
431 continue
432 }
433
434 // get owner and project from url
435 owner, project, err := splitURL(line)
436 if err != nil {
437 fmt.Println(err)
438 continue
439 }
440
441 return owner, project, nil
442 }
443}
444
445// splitURL extract the owner and project from a github repository URL. It will remove the
446// '.git' extension from the URL before parsing it.
447// Note that Github removes the '.git' extension from projects names at their creation
448func splitURL(url string) (owner string, project string, err error) {
449 cleanURL := strings.TrimSuffix(url, ".git")
450
451 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
452 if err != nil {
453 panic("regexp compile:" + err.Error())
454 }
455
456 res := re.FindStringSubmatch(cleanURL)
457 if res == nil {
458 return "", "", ErrBadProjectURL
459 }
460
461 owner = res[1]
462 project = res[2]
463 return
464}
465
466func getValidGithubRemoteURLs(remotes map[string]string) []string {
467 urls := make([]string, 0, len(remotes))
468 for _, url := range remotes {
469 // split url can work again with shortURL
470 owner, project, err := splitURL(url)
471 if err == nil {
472 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
473 urls = append(urls, shortURL)
474 }
475 }
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 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 for {
551 fmt.Print("two-factor authentication code: ")
552
553 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
554 fmt.Println()
555 if err != nil {
556 return "", err
557 }
558
559 if len(byte2fa) > 0 {
560 return string(byte2fa), nil
561 }
562
563 fmt.Println("code is empty")
564 }
565}
566
567func promptProjectVisibility() (bool, error) {
568 for {
569 fmt.Println("[1]: public")
570 fmt.Println("[2]: private")
571 fmt.Print("repository visibility: ")
572
573 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
574 fmt.Println()
575 if err != nil {
576 return false, err
577 }
578
579 line = strings.TrimRight(line, "\n")
580
581 index, err := strconv.Atoi(line)
582 if err != nil || (index != 1 && index != 2) {
583 fmt.Println("invalid input")
584 continue
585 }
586
587 // return true for public repositories, false for private
588 return index == 1, nil
589 }
590}