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 = 5 * time.Second
32)
33
34var (
35 rxGithubURL = regexp.MustCompile(`github\.com[\/:]([^\/]*[a-z]+)\/([^\/]*[a-z]+)`)
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 for {
226 fmt.Print("Enter token: ")
227
228 byteToken, err := terminal.ReadPassword(int(syscall.Stdin))
229 fmt.Println()
230
231 if err != nil {
232 return "", err
233 }
234
235 if len(byteToken) > 0 {
236 return string(byteToken), nil
237 }
238
239 fmt.Println("token is empty")
240 }
241}
242
243func loginAndRequestToken(owner, project string) (string, error) {
244 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.")
245 fmt.Println()
246 fmt.Println("Depending on your configuration the token will have one of the following scopes:")
247 fmt.Println(" - 'user:email': to be able to read public-only users email")
248 fmt.Println(" - 'repo' : to be able to read private repositories")
249 // 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 :-|")
250 fmt.Println()
251
252 // prompt project visibility to know the token scope needed for the repository
253 isPublic, err := promptProjectVisibility()
254 if err != nil {
255 return "", err
256 }
257
258 username, err := promptUsername()
259 if err != nil {
260 return "", err
261 }
262
263 password, err := promptPassword()
264 if err != nil {
265 return "", err
266 }
267
268 var scope string
269 if isPublic {
270 // user:email is requested to be able to read public emails
271 // - a private email will stay private, even with this token
272 scope = "user:email"
273 } else {
274 // 'repo' is request to be able to read private repositories
275 // /!\ token will have read/write rights on every private repository you have access to
276 scope = "repo"
277 }
278
279 // Attempt to authenticate and create a token
280
281 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
282
283 resp, err := requestToken(note, username, password, scope)
284 if err != nil {
285 return "", err
286 }
287
288 defer resp.Body.Close()
289
290 // Handle 2FA is needed
291 OTPHeader := resp.Header.Get("X-GitHub-OTP")
292 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
293 otpCode, err := prompt2FA()
294 if err != nil {
295 return "", err
296 }
297
298 resp, err = requestTokenWith2FA(note, username, password, otpCode, scope)
299 if err != nil {
300 return "", err
301 }
302
303 defer resp.Body.Close()
304 }
305
306 if resp.StatusCode == http.StatusCreated {
307 token, err := decodeBody(resp.Body)
308 if err != nil {
309 return "", err
310 }
311
312 return token, nil
313 }
314
315 b, _ := ioutil.ReadAll(resp.Body)
316 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
317}
318
319func promptUsername() (string, error) {
320 for {
321 fmt.Print("username: ")
322
323 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
324 if err != nil {
325 return "", err
326 }
327
328 line = strings.TrimRight(line, "\n")
329
330 ok, err := validateUsername(line)
331 if err != nil {
332 return "", err
333 }
334 if ok {
335 return line, nil
336 }
337
338 fmt.Println("invalid username")
339 }
340}
341
342func promptURL(remotes map[string]string) (string, string, error) {
343 validRemotes := valideGithubURLRemotes(remotes)
344 if len(validRemotes) > 0 {
345 for {
346 fmt.Println("\nDetected projects:")
347
348 // print valid remote github urls
349 for i, remote := range validRemotes {
350 fmt.Printf("[%d]: %v\n", i+1, remote)
351 }
352
353 fmt.Printf("\n[0]: Another project\n\n")
354 fmt.Printf("Select option: ")
355
356 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
357 if err != nil {
358 return "", "", err
359 }
360
361 line = strings.TrimRight(line, "\n")
362
363 index, err := strconv.Atoi(line)
364 if err != nil || (index < 0 && index >= len(validRemotes)) {
365 fmt.Println("invalid input")
366 continue
367 }
368
369 // if user want to enter another project url break this loop
370 if index == 0 {
371 break
372 }
373
374 // get owner and project with index
375 _, owner, project, _ := splitURL(validRemotes[index-1])
376 return owner, project, nil
377 }
378 }
379
380 // manually enter github url
381 for {
382 fmt.Print("Github project URL: ")
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 if line == "" {
391 fmt.Println("URL is empty")
392 continue
393 }
394
395 // get owner and project from url
396 _, owner, project, err := splitURL(line)
397 if err != nil {
398 fmt.Println(err)
399 continue
400 }
401
402 return owner, project, nil
403 }
404}
405
406func splitURL(url string) (string, string, string, error) {
407 res := rxGithubURL.FindStringSubmatch(url)
408 if res == nil {
409 return "", "", "", fmt.Errorf("bad github project url")
410 }
411
412 return res[0], res[1], res[2], nil
413}
414
415func valideGithubURLRemotes(remotes map[string]string) []string {
416 urls := make([]string, 0, len(remotes))
417 for _, url := range remotes {
418 // split url can work again with shortURL
419 shortURL, _, _, err := splitURL(url)
420 if err == nil {
421 urls = append(urls, shortURL)
422 }
423 }
424
425 return urls
426}
427
428func validateUsername(username string) (bool, error) {
429 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
430
431 resp, err := http.Get(url)
432 if err != nil {
433 return false, err
434 }
435
436 err = resp.Body.Close()
437 if err != nil {
438 return false, err
439 }
440
441 return resp.StatusCode == http.StatusOK, nil
442}
443
444func validateProject(owner, project, token string) (bool, error) {
445 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
446
447 req, err := http.NewRequest("GET", url, nil)
448 if err != nil {
449 return false, err
450 }
451
452 // need the token for private repositories
453 req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
454
455 client := &http.Client{
456 Timeout: defaultTimeout,
457 }
458
459 resp, err := client.Do(req)
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 promptPassword() (string, error) {
473 for {
474 fmt.Print("password: ")
475
476 bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
477 // new line for coherent formatting, ReadPassword clip the normal new line
478 // entered by the user
479 fmt.Println()
480
481 if err != nil {
482 return "", err
483 }
484
485 if len(bytePassword) > 0 {
486 return string(bytePassword), nil
487 }
488
489 fmt.Println("password is empty")
490 }
491}
492
493func prompt2FA() (string, error) {
494 for {
495 fmt.Print("two-factor authentication code: ")
496
497 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
498 fmt.Println()
499 if err != nil {
500 return "", err
501 }
502
503 if len(byte2fa) != 6 {
504 fmt.Println("invalid 2FA code size")
505 continue
506 }
507
508 str2fa := string(byte2fa)
509 _, err = strconv.Atoi(str2fa)
510 if err != nil {
511 fmt.Println("2fa code must be digits only")
512 continue
513 }
514
515 return str2fa, nil
516 }
517}
518
519func promptProjectVisibility() (bool, error) {
520 for {
521 fmt.Println("[0]: public")
522 fmt.Println("[1]: private")
523 fmt.Print("repository visibility: ")
524
525 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
526 fmt.Println()
527 if err != nil {
528 return false, err
529 }
530
531 line = strings.TrimRight(line, "\n")
532
533 index, err := strconv.Atoi(line)
534 if err != nil || (index != 0 && index != 1) {
535 fmt.Println("invalid input")
536 continue
537 }
538
539 // return true for public repositories, false for private
540 return index == 0, nil
541 }
542}