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