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