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