1package github
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io/ioutil"
8 "math/rand"
9 "net/http"
10 "net/url"
11 "regexp"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/pkg/errors"
18
19 "github.com/MichaelMure/git-bug/bridge/core"
20 "github.com/MichaelMure/git-bug/bridge/core/auth"
21 "github.com/MichaelMure/git-bug/cache"
22 "github.com/MichaelMure/git-bug/input"
23 "github.com/MichaelMure/git-bug/repository"
24)
25
26var (
27 ErrBadProjectURL = errors.New("bad project url")
28 githubClientID = "ce3600aa56c2e69f18a5"
29)
30
31func (g *Github) ValidParams() map[string]interface{} {
32 return map[string]interface{}{
33 "URL": nil,
34 "Login": nil,
35 "CredPrefix": nil,
36 "TokenRaw": nil,
37 "Owner": nil,
38 "Project": nil,
39 }
40}
41
42func (g *Github) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
43 var err error
44 var owner string
45 var project string
46 var ok bool
47
48 // getting owner and project name
49 switch {
50 case params.Owner != "" && params.Project != "":
51 // first try to use params if both or project and owner are provided
52 owner = params.Owner
53 project = params.Project
54 case 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 default:
61 // terminal prompt
62 owner, project, err = promptURL(repo)
63 if err != nil {
64 return nil, err
65 }
66 }
67
68 // validate project owner and override with the correct case
69 ok, owner, err = validateUsername(owner)
70 if err != nil {
71 return nil, err
72 }
73 if !ok {
74 return nil, fmt.Errorf("invalid parameter owner: %v", owner)
75 }
76
77 var login string
78 var cred auth.Credential
79
80 switch {
81 case params.CredPrefix != "":
82 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
83 if err != nil {
84 return nil, err
85 }
86 l, ok := cred.GetMetadata(auth.MetaKeyLogin)
87 if !ok {
88 return nil, fmt.Errorf("credential doesn't have a login")
89 }
90 login = l
91 case params.TokenRaw != "":
92 token := auth.NewToken(target, params.TokenRaw)
93 login, err = getLoginFromToken(token)
94 if err != nil {
95 return nil, err
96 }
97 token.SetMetadata(auth.MetaKeyLogin, login)
98 cred = token
99 default:
100 if params.Login == "" {
101 login, err = promptLogin()
102 } else {
103 // validate login and override with the correct case
104 ok, login, err = validateUsername(params.Login)
105 if !ok {
106 return nil, fmt.Errorf("invalid parameter login: %v", params.Login)
107 }
108 }
109 if err != nil {
110 return nil, err
111 }
112 cred, err = promptTokenOptions(repo, login, owner, project)
113 if err != nil {
114 return nil, err
115 }
116 }
117
118 token, ok := cred.(*auth.Token)
119 if !ok {
120 return nil, fmt.Errorf("the Github bridge only handle token credentials")
121 }
122
123 // verify access to the repository with token
124 ok, err = validateProject(owner, project, token)
125 if err != nil {
126 return nil, err
127 }
128 if !ok {
129 return nil, fmt.Errorf("project doesn't exist or authentication token has an incorrect scope")
130 }
131
132 conf := make(core.Configuration)
133 conf[core.ConfigKeyTarget] = target
134 conf[confKeyOwner] = owner
135 conf[confKeyProject] = project
136 conf[confKeyDefaultLogin] = login
137
138 err = g.ValidateConfig(conf)
139 if err != nil {
140 return nil, err
141 }
142
143 // don't forget to store the now known valid token
144 if !auth.IdExist(repo, cred.ID()) {
145 err = auth.Store(repo, cred)
146 if err != nil {
147 return nil, err
148 }
149 }
150
151 return conf, core.FinishConfig(repo, metaKeyGithubLogin, login)
152}
153
154func (*Github) ValidateConfig(conf core.Configuration) error {
155 if v, ok := conf[core.ConfigKeyTarget]; !ok {
156 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
157 } else if v != target {
158 return fmt.Errorf("unexpected target name: %v", v)
159 }
160 if _, ok := conf[confKeyOwner]; !ok {
161 return fmt.Errorf("missing %s key", confKeyOwner)
162 }
163 if _, ok := conf[confKeyProject]; !ok {
164 return fmt.Errorf("missing %s key", confKeyProject)
165 }
166 if _, ok := conf[confKeyDefaultLogin]; !ok {
167 return fmt.Errorf("missing %s key", confKeyDefaultLogin)
168 }
169
170 return nil
171}
172
173func requestToken() (string, error) {
174 scope, err := promptUserForProjectVisibility()
175 if err != nil {
176 return "", errors.WithStack(err)
177 }
178 ghResp, err := requestUserVerificationCode(scope)
179 if err != nil {
180 return "", err
181 }
182 promptUserToGoToBrowser(ghResp["verification_uri"], ghResp["user_code"])
183 interval, err := strconv.ParseInt(ghResp["interval"], 10, 64) // base 10, bitSize 64
184 if err != nil {
185 return "", errors.Wrap(err, "Error parsing integer received from Github API")
186 }
187 return pollGithubForAuthorization(ghResp["device_code"], interval)
188}
189
190func promptUserForProjectVisibility() (string, error) {
191 fmt.Println("git-bug will now generate an access token in your Github profile. The token is stored in the global git config.")
192 fmt.Println()
193 fmt.Println("The access scope depend on the type of repository.")
194 fmt.Println("Public:")
195 fmt.Println(" - 'public_repo': to be able to read public repositories")
196 fmt.Println("Private:")
197 fmt.Println(" - 'repo' : to be able to read private repositories")
198 fmt.Println()
199 index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
200 if err != nil {
201 return "", err
202 }
203 return []string{"public_repo", "repo"}[index], nil
204}
205
206func requestUserVerificationCode(scope string) (map[string]string, error) {
207 params := url.Values{}
208 params.Set("client_id", githubClientID)
209 params.Set("scope", scope)
210 client := &http.Client{
211 Timeout: defaultTimeout,
212 }
213 resp, err := client.PostForm("https://github.com/login/device/code", params)
214 if err != nil {
215 return nil, errors.Wrap(err, "error requesting user verification code")
216 }
217 defer resp.Body.Close()
218 if resp.StatusCode != http.StatusOK {
219 return nil, fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
220 }
221 data, err := ioutil.ReadAll(resp.Body)
222 if err != nil {
223 return nil, errors.Wrap(err, "error requesting user verification code")
224 }
225 values, err := url.ParseQuery(string(data))
226 if err != nil {
227 return nil, errors.Wrap(err, "error decoding Github API response")
228 }
229 result := map[string]string{"device_code": "", "user_code": "", "verification_uri": "", "interval": ""}
230 for key, _ := range result {
231 result[key] = values.Get(key)
232 }
233 return result, nil
234}
235
236func promptUserToGoToBrowser(url, userCode string) {
237 fmt.Println("Please visit the following Github URL in a browser and enter your user authentication code.")
238 fmt.Println()
239 fmt.Println(" URL:", url)
240 fmt.Println(" user authentiation code:", userCode)
241 fmt.Println()
242}
243
244func pollGithubForAuthorization(deviceCode string, intervalSec int64) (string, error) {
245 params := url.Values{}
246 params.Set("client_id", githubClientID)
247 params.Set("device_code", deviceCode)
248 params.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") // fixed by RFC 8628
249 client := &http.Client{
250 Timeout: defaultTimeout,
251 }
252 interval := time.Duration(intervalSec * 1100) // milliseconds, add 10% margin
253 for {
254 resp, err := client.PostForm("https://github.com/login/oauth/access_token", params)
255 if err != nil {
256 return "", errors.Wrap(err, "error polling the Github API")
257 }
258 defer resp.Body.Close()
259 if resp.StatusCode != http.StatusOK {
260 return "", fmt.Errorf("unexpected response status code %d from Github API", resp.StatusCode)
261 }
262 data, err := ioutil.ReadAll(resp.Body)
263 if err != nil {
264 return "", errors.Wrap(err, "error polling the Github API")
265 }
266 values, err := url.ParseQuery(string(data))
267 if err != nil {
268 return "", errors.Wrap(err, "error decoding Github API response")
269 }
270
271 if token := values.Get("access_token"); token != "" {
272 return token, nil
273 }
274
275 switch apiError := values.Get("error"); apiError {
276 case "slow_down":
277 interval += 5500 // add 5 seconds (RFC 8628), plus some margin
278 time.Sleep(interval * time.Millisecond)
279 continue
280 case "authorization_pending":
281 time.Sleep(interval * time.Millisecond)
282 continue
283 case "":
284 return "", errors.New("unexpected response from Github API")
285 default:
286 // apiError should equal one of: "expired_token", "unsupported_grant_type",
287 // "incorrect_client_credentials", "incorrect_device_code", or "access_denied"
288 return "", fmt.Errorf("error creating token: %v, %v", apiError, values.Get("error_description"))
289 }
290 }
291}
292
293func randomFingerprint() string {
294 // Doesn't have to be crypto secure, it's just to avoid token collision
295 rand.Seed(time.Now().UnixNano())
296 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
297 b := make([]rune, 32)
298 for i := range b {
299 b[i] = letterRunes[rand.Intn(len(letterRunes))]
300 }
301 return string(b)
302}
303
304func promptTokenOptions(repo repository.RepoKeyring, login, owner, project string) (auth.Credential, error) {
305 creds, err := auth.List(repo,
306 auth.WithTarget(target),
307 auth.WithKind(auth.KindToken),
308 auth.WithMeta(auth.MetaKeyLogin, login),
309 )
310 if err != nil {
311 return nil, err
312 }
313
314 cred, index, err := input.PromptCredential(target, "token", creds, []string{
315 "enter my token",
316 "interactive token creation",
317 })
318 switch {
319 case err != nil:
320 return nil, err
321 case cred != nil:
322 return cred, nil
323 case index == 0:
324 return promptToken()
325 case index == 1:
326 value, err := requestToken()
327 if err != nil {
328 return nil, err
329 }
330 token := auth.NewToken(target, value)
331 token.SetMetadata(auth.MetaKeyLogin, login)
332 return token, nil
333 default:
334 panic("missed case")
335 }
336}
337
338func promptToken() (*auth.Token, error) {
339 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
340 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
341 fmt.Println()
342 fmt.Println("The access scope depend on the type of repository.")
343 fmt.Println("Public:")
344 fmt.Println(" - 'public_repo': to be able to read public repositories")
345 fmt.Println("Private:")
346 fmt.Println(" - 'repo' : to be able to read private repositories")
347 fmt.Println()
348
349 re := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
350
351 var login string
352
353 validator := func(name string, value string) (complaint string, err error) {
354 if !re.MatchString(value) {
355 return "token has incorrect format", nil
356 }
357 login, err = getLoginFromToken(auth.NewToken(target, value))
358 if err != nil {
359 return fmt.Sprintf("token is invalid: %v", err), nil
360 }
361 return "", nil
362 }
363
364 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
365 if err != nil {
366 return nil, err
367 }
368
369 token := auth.NewToken(target, rawToken)
370 token.SetMetadata(auth.MetaKeyLogin, login)
371
372 return token, nil
373}
374
375func promptURL(repo repository.RepoCommon) (string, string, error) {
376 validRemotes, err := getValidGithubRemoteURLs(repo)
377 if err != nil {
378 return "", "", err
379 }
380
381 validator := func(name, value string) (string, error) {
382 _, _, err := splitURL(value)
383 if err != nil {
384 return err.Error(), nil
385 }
386 return "", nil
387 }
388
389 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
390 if err != nil {
391 return "", "", err
392 }
393
394 return splitURL(url)
395}
396
397// splitURL extract the owner and project from a github repository URL. It will remove the
398// '.git' extension from the URL before parsing it.
399// Note that Github removes the '.git' extension from projects names at their creation
400func splitURL(url string) (owner string, project string, err error) {
401 cleanURL := strings.TrimSuffix(url, ".git")
402
403 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
404
405 res := re.FindStringSubmatch(cleanURL)
406 if res == nil {
407 return "", "", ErrBadProjectURL
408 }
409
410 owner = res[1]
411 project = res[2]
412 return
413}
414
415func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
416 remotes, err := repo.GetRemotes()
417 if err != nil {
418 return nil, err
419 }
420
421 urls := make([]string, 0, len(remotes))
422 for _, url := range remotes {
423 // split url can work again with shortURL
424 owner, project, err := splitURL(url)
425 if err == nil {
426 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
427 urls = append(urls, shortURL)
428 }
429 }
430
431 sort.Strings(urls)
432
433 return urls, nil
434}
435
436func promptLogin() (string, error) {
437 var login string
438
439 validator := func(_ string, value string) (string, error) {
440 ok, fixed, err := validateUsername(value)
441 if err != nil {
442 return "", err
443 }
444 if !ok {
445 return "invalid login", nil
446 }
447 login = fixed
448 return "", nil
449 }
450
451 _, err := input.Prompt("Github login", "login", input.Required, validator)
452 if err != nil {
453 return "", err
454 }
455
456 return login, nil
457}
458
459func validateUsername(username string) (bool, string, error) {
460 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
461
462 client := &http.Client{
463 Timeout: defaultTimeout,
464 }
465
466 resp, err := client.Get(url)
467 if err != nil {
468 return false, "", err
469 }
470
471 if resp.StatusCode != http.StatusOK {
472 return false, "", nil
473 }
474
475 data, err := ioutil.ReadAll(resp.Body)
476 if err != nil {
477 return false, "", err
478 }
479
480 err = resp.Body.Close()
481 if err != nil {
482 return false, "", err
483 }
484
485 var decoded struct {
486 Login string `json:"login"`
487 }
488 err = json.Unmarshal(data, &decoded)
489 if err != nil {
490 return false, "", err
491 }
492
493 if decoded.Login == "" {
494 return false, "", fmt.Errorf("validateUsername: missing login in the response")
495 }
496
497 return true, decoded.Login, nil
498}
499
500func validateProject(owner, project string, token *auth.Token) (bool, error) {
501 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
502
503 req, err := http.NewRequest("GET", url, nil)
504 if err != nil {
505 return false, err
506 }
507
508 // need the token for private repositories
509 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
510
511 client := &http.Client{
512 Timeout: defaultTimeout,
513 }
514
515 resp, err := client.Do(req)
516 if err != nil {
517 return false, err
518 }
519
520 err = resp.Body.Close()
521 if err != nil {
522 return false, err
523 }
524
525 return resp.StatusCode == http.StatusOK, nil
526}
527
528func getLoginFromToken(token *auth.Token) (string, error) {
529 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
530 defer cancel()
531
532 client := buildClient(token)
533
534 var q loginQuery
535
536 err := client.Query(ctx, &q, nil)
537 if err != nil {
538 return "", err
539 }
540 if q.Viewer.Login == "" {
541 return "", fmt.Errorf("github say username is empty")
542 }
543
544 return q.Viewer.Login, nil
545}