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