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