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