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