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 := regexp.MustCompile(`^[a-zA-Z0-9]{40}$`)
293
294 var login string
295
296 validator := func(name string, value string) (complaint string, err error) {
297 if !re.MatchString(value) {
298 return "token has incorrect format", nil
299 }
300 login, err = getLoginFromToken(auth.NewToken(target, value))
301 if err != nil {
302 return fmt.Sprintf("token is invalid: %v", err), nil
303 }
304 return "", nil
305 }
306
307 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
308 if err != nil {
309 return nil, err
310 }
311
312 token := auth.NewToken(target, rawToken)
313 token.SetMetadata(auth.MetaKeyLogin, login)
314
315 return token, nil
316}
317
318func loginAndRequestToken(login, owner, project string) (string, error) {
319 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.")
320 fmt.Println()
321 fmt.Println("The access scope depend on the type of repository.")
322 fmt.Println("Public:")
323 fmt.Println(" - 'public_repo': to be able to read public repositories")
324 fmt.Println("Private:")
325 fmt.Println(" - 'repo' : to be able to read private repositories")
326 fmt.Println()
327
328 // prompt project visibility to know the token scope needed for the repository
329 index, err := input.PromptChoice("repository visibility", []string{"public", "private"})
330 if err != nil {
331 return "", err
332 }
333 scope := []string{"public_repo", "repo"}[index]
334
335 password, err := input.PromptPassword("Password", "password", input.Required)
336 if err != nil {
337 return "", err
338 }
339
340 // Attempt to authenticate and create a token
341 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
342 resp, err := requestToken(note, login, password, scope)
343 if err != nil {
344 return "", err
345 }
346
347 defer resp.Body.Close()
348
349 // Handle 2FA is needed
350 OTPHeader := resp.Header.Get("X-GitHub-OTP")
351 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
352 otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
353 if err != nil {
354 return "", err
355 }
356
357 resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
358 if err != nil {
359 return "", err
360 }
361
362 defer resp.Body.Close()
363 }
364
365 if resp.StatusCode == http.StatusCreated {
366 return decodeBody(resp.Body)
367 }
368
369 b, _ := ioutil.ReadAll(resp.Body)
370 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
371}
372
373func promptURL(repo repository.RepoCommon) (string, string, error) {
374 validRemotes, err := getValidGithubRemoteURLs(repo)
375 if err != nil {
376 return "", "", err
377 }
378
379 validator := func(name, value string) (string, error) {
380 _, _, err := splitURL(value)
381 if err != nil {
382 return err.Error(), nil
383 }
384 return "", nil
385 }
386
387 url, err := input.PromptURLWithRemote("Github project URL", "URL", validRemotes, input.Required, validator)
388 if err != nil {
389 return "", "", err
390 }
391
392 return splitURL(url)
393}
394
395// splitURL extract the owner and project from a github repository URL. It will remove the
396// '.git' extension from the URL before parsing it.
397// Note that Github removes the '.git' extension from projects names at their creation
398func splitURL(url string) (owner string, project string, err error) {
399 cleanURL := strings.TrimSuffix(url, ".git")
400
401 re := regexp.MustCompile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
402
403 res := re.FindStringSubmatch(cleanURL)
404 if res == nil {
405 return "", "", ErrBadProjectURL
406 }
407
408 owner = res[1]
409 project = res[2]
410 return
411}
412
413func getValidGithubRemoteURLs(repo repository.RepoCommon) ([]string, error) {
414 remotes, err := repo.GetRemotes()
415 if err != nil {
416 return nil, err
417 }
418
419 urls := make([]string, 0, len(remotes))
420 for _, url := range remotes {
421 // split url can work again with shortURL
422 owner, project, err := splitURL(url)
423 if err == nil {
424 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
425 urls = append(urls, shortURL)
426 }
427 }
428
429 sort.Strings(urls)
430
431 return urls, nil
432}
433
434func validateUsername(username string) (bool, error) {
435 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
436
437 client := &http.Client{
438 Timeout: defaultTimeout,
439 }
440
441 resp, err := client.Get(url)
442 if err != nil {
443 return false, err
444 }
445
446 err = resp.Body.Close()
447 if err != nil {
448 return false, err
449 }
450
451 return resp.StatusCode == http.StatusOK, nil
452}
453
454func validateProject(owner, project string, token *auth.Token) (bool, error) {
455 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
456
457 req, err := http.NewRequest("GET", url, nil)
458 if err != nil {
459 return false, err
460 }
461
462 // need the token for private repositories
463 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
464
465 client := &http.Client{
466 Timeout: defaultTimeout,
467 }
468
469 resp, err := client.Do(req)
470 if err != nil {
471 return false, err
472 }
473
474 err = resp.Body.Close()
475 if err != nil {
476 return false, err
477 }
478
479 return resp.StatusCode == http.StatusOK, nil
480}
481
482func getLoginFromToken(token *auth.Token) (string, error) {
483 ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout)
484 defer cancel()
485
486 client := buildClient(token)
487
488 var q loginQuery
489
490 err := client.Query(ctx, &q, nil)
491 if err != nil {
492 return "", err
493 }
494 if q.Viewer.Login == "" {
495 return "", fmt.Errorf("github say username is empty")
496 }
497
498 return q.Viewer.Login, nil
499}