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