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