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("[0]: user provided token")
201 fmt.Println("[1]: 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 != 0 && index != 1) {
214 fmt.Println("invalid input")
215 continue
216 }
217
218 if index == 0 {
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 for {
238 fmt.Print("Enter token: ")
239
240 byteToken, err := terminal.ReadPassword(int(syscall.Stdin))
241 fmt.Println()
242
243 if err != nil {
244 return "", err
245 }
246
247 if len(byteToken) > 0 {
248 return string(byteToken), nil
249 }
250
251 fmt.Println("token is empty")
252 }
253}
254
255func loginAndRequestToken(owner, project string) (string, error) {
256 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.")
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 // prompt project visibility to know the token scope needed for the repository
266 isPublic, err := promptProjectVisibility()
267 if err != nil {
268 return "", err
269 }
270
271 username, err := promptUsername()
272 if err != nil {
273 return "", err
274 }
275
276 password, err := promptPassword()
277 if err != nil {
278 return "", err
279 }
280
281 var scope string
282 if isPublic {
283 // public_repo is requested to be able to read public repositories
284 scope = "public_repo"
285 } else {
286 // 'repo' is request to be able to read private repositories
287 // /!\ token will have read/write rights on every private repository you have access to
288 scope = "repo"
289 }
290
291 // Attempt to authenticate and create a token
292
293 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
294
295 resp, err := requestToken(note, username, password, scope)
296 if err != nil {
297 return "", err
298 }
299
300 defer resp.Body.Close()
301
302 // Handle 2FA is needed
303 OTPHeader := resp.Header.Get("X-GitHub-OTP")
304 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
305 otpCode, err := prompt2FA()
306 if err != nil {
307 return "", err
308 }
309
310 resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
311 if err != nil {
312 return "", err
313 }
314
315 defer resp.Body.Close()
316 }
317
318 if resp.StatusCode == http.StatusCreated {
319 return decodeBody(resp.Body)
320 }
321
322 b, _ := ioutil.ReadAll(resp.Body)
323 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
324}
325
326func promptUsername() (string, error) {
327 for {
328 fmt.Print("username: ")
329
330 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
331 if err != nil {
332 return "", err
333 }
334
335 line = strings.TrimRight(line, "\n")
336
337 ok, err := validateUsername(line)
338 if err != nil {
339 return "", err
340 }
341 if ok {
342 return line, nil
343 }
344
345 fmt.Println("invalid username")
346 }
347}
348
349func promptURL(remotes map[string]string) (string, string, error) {
350 validRemotes := getValidGithubRemoteURLs(remotes)
351 if len(validRemotes) > 0 {
352 for {
353 fmt.Println("\nDetected projects:")
354
355 // print valid remote github urls
356 for i, remote := range validRemotes {
357 fmt.Printf("[%d]: %v\n", i+1, remote)
358 }
359
360 fmt.Printf("\n[0]: Another project\n\n")
361 fmt.Printf("Select option: ")
362
363 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
364 if err != nil {
365 return "", "", err
366 }
367
368 line = strings.TrimRight(line, "\n")
369
370 index, err := strconv.Atoi(line)
371 if err != nil || (index < 0 && index >= len(validRemotes)) {
372 fmt.Println("invalid input")
373 continue
374 }
375
376 // if user want to enter another project url break this loop
377 if index == 0 {
378 break
379 }
380
381 // get owner and project with index
382 owner, project, _ := splitURL(validRemotes[index-1])
383 return owner, project, nil
384 }
385 }
386
387 // manually enter github url
388 for {
389 fmt.Print("Github project URL: ")
390
391 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
392 if err != nil {
393 return "", "", err
394 }
395
396 line = strings.TrimRight(line, "\n")
397 if line == "" {
398 fmt.Println("URL is empty")
399 continue
400 }
401
402 // get owner and project from url
403 owner, project, err := splitURL(line)
404 if err != nil {
405 fmt.Println(err)
406 continue
407 }
408
409 return owner, project, nil
410 }
411}
412
413// splitURL extract the owner and project from a github repository URL. It will remove the
414// '.git' extension from the URL before parsing it.
415// Note that Github removes the '.git' extension from projects names at their creation
416func splitURL(url string) (owner string, project string, err error) {
417 cleanURL := strings.TrimSuffix(url, ".git")
418
419 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
420 if err != nil {
421 panic("regexp compile:" + err.Error())
422 }
423
424 res := re.FindStringSubmatch(cleanURL)
425 if res == nil {
426 return "", "", ErrBadProjectURL
427 }
428
429 owner = res[1]
430 project = res[2]
431 return
432}
433
434func getValidGithubRemoteURLs(remotes map[string]string) []string {
435 urls := make([]string, 0, len(remotes))
436 for _, url := range remotes {
437 // split url can work again with shortURL
438 owner, project, err := splitURL(url)
439 if err == nil {
440 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
441 urls = append(urls, shortURL)
442 }
443 }
444
445 return urls
446}
447
448func validateUsername(username string) (bool, error) {
449 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
450
451 client := &http.Client{
452 Timeout: defaultTimeout,
453 }
454
455 resp, err := client.Get(url)
456 if err != nil {
457 return false, err
458 }
459
460 err = resp.Body.Close()
461 if err != nil {
462 return false, err
463 }
464
465 return resp.StatusCode == http.StatusOK, nil
466}
467
468func validateProject(owner, project, token string) (bool, error) {
469 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
470
471 req, err := http.NewRequest("GET", url, nil)
472 if err != nil {
473 return false, err
474 }
475
476 // need the token for private repositories
477 req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
478
479 client := &http.Client{
480 Timeout: defaultTimeout,
481 }
482
483 resp, err := client.Do(req)
484 if err != nil {
485 return false, err
486 }
487
488 err = resp.Body.Close()
489 if err != nil {
490 return false, err
491 }
492
493 return resp.StatusCode == http.StatusOK, nil
494}
495
496func promptPassword() (string, error) {
497 for {
498 fmt.Print("password: ")
499
500 bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
501 // new line for coherent formatting, ReadPassword clip the normal new line
502 // entered by the user
503 fmt.Println()
504
505 if err != nil {
506 return "", err
507 }
508
509 if len(bytePassword) > 0 {
510 return string(bytePassword), nil
511 }
512
513 fmt.Println("password is empty")
514 }
515}
516
517func prompt2FA() (string, error) {
518 for {
519 fmt.Print("two-factor authentication code: ")
520
521 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
522 fmt.Println()
523 if err != nil {
524 return "", err
525 }
526
527 if len(byte2fa) > 0 {
528 return string(byte2fa), nil
529 }
530
531 fmt.Println("code is empty")
532 }
533}
534
535func promptProjectVisibility() (bool, error) {
536 for {
537 fmt.Println("[0]: public")
538 fmt.Println("[1]: private")
539 fmt.Print("repository visibility: ")
540
541 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
542 fmt.Println()
543 if err != nil {
544 return false, err
545 }
546
547 line = strings.TrimRight(line, "\n")
548
549 index, err := strconv.Atoi(line)
550 if err != nil || (index != 0 && index != 1) {
551 fmt.Println("invalid input")
552 continue
553 }
554
555 // return true for public repositories, false for private
556 return index == 0, nil
557 }
558}