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