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