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", "login", input.Required, 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 // Todo: if no user exist with the given login
132 // - tag the default user with the github login
133 // - add a command to manually tag a user ?
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, nil
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[keyOwner]; !ok {
154 return fmt.Errorf("missing %s key", keyOwner)
155 }
156
157 if _, ok := conf[keyProject]; !ok {
158 return fmt.Errorf("missing %s key", keyProject)
159 }
160
161 return nil
162}
163
164func requestToken(note, login, password string, scope string) (*http.Response, error) {
165 return requestTokenWith2FA(note, login, password, "", scope)
166}
167
168func requestTokenWith2FA(note, login, password, otpCode string, scope string) (*http.Response, error) {
169 url := fmt.Sprintf("%s/authorizations", githubV3Url)
170 params := struct {
171 Scopes []string `json:"scopes"`
172 Note string `json:"note"`
173 Fingerprint string `json:"fingerprint"`
174 }{
175 Scopes: []string{scope},
176 Note: note,
177 Fingerprint: randomFingerprint(),
178 }
179
180 data, err := json.Marshal(params)
181 if err != nil {
182 return nil, err
183 }
184
185 req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
186 if err != nil {
187 return nil, err
188 }
189
190 req.SetBasicAuth(login, password)
191 req.Header.Set("Content-Type", "application/json")
192
193 if otpCode != "" {
194 req.Header.Set("X-GitHub-OTP", otpCode)
195 }
196
197 client := &http.Client{
198 Timeout: defaultTimeout,
199 }
200
201 return client.Do(req)
202}
203
204func decodeBody(body io.ReadCloser) (string, error) {
205 data, _ := ioutil.ReadAll(body)
206
207 aux := struct {
208 Token string `json:"token"`
209 }{}
210
211 err := json.Unmarshal(data, &aux)
212 if err != nil {
213 return "", err
214 }
215
216 if aux.Token == "" {
217 return "", fmt.Errorf("no token found in response: %s", string(data))
218 }
219
220 return aux.Token, nil
221}
222
223func randomFingerprint() string {
224 // Doesn't have to be crypto secure, it's just to avoid token collision
225 rand.Seed(time.Now().UnixNano())
226 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
227 b := make([]rune, 32)
228 for i := range b {
229 b[i] = letterRunes[rand.Intn(len(letterRunes))]
230 }
231 return string(b)
232}
233
234func promptTokenOptions(repo repository.RepoConfig, login, owner, project string) (auth.Credential, error) {
235 for {
236 creds, err := auth.List(repo, auth.WithTarget(target), auth.WithMeta(auth.MetaKeyLogin, login))
237 if err != nil {
238 return nil, err
239 }
240
241 fmt.Println()
242 fmt.Println("[1]: enter my token")
243 fmt.Println("[2]: interactive token creation")
244
245 if len(creds) > 0 {
246 sort.Sort(auth.ById(creds))
247
248 fmt.Println()
249 fmt.Println("Existing tokens for Github:")
250 for i, cred := range creds {
251 token := cred.(*auth.Token)
252 fmt.Printf("[%d]: %s => %s (login: %s, %s)\n",
253 i+3,
254 colors.Cyan(token.ID().Human()),
255 colors.Red(text.TruncateMax(token.Value, 10)),
256 token.Metadata()[auth.MetaKeyLogin],
257 token.CreateTime().Format(time.RFC822),
258 )
259 }
260 }
261
262 fmt.Println()
263 fmt.Print("Select option: ")
264
265 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
266 fmt.Println()
267 if err != nil {
268 return nil, err
269 }
270
271 line = strings.TrimSpace(line)
272 index, err := strconv.Atoi(line)
273 if err != nil || index < 1 || index > len(creds)+2 {
274 fmt.Println("invalid input")
275 continue
276 }
277
278 switch index {
279 case 1:
280 value, err := promptToken()
281 if err != nil {
282 return nil, err
283 }
284 token := auth.NewToken(value, target)
285 token.Metadata()[auth.MetaKeyLogin] = login
286 return token, nil
287 case 2:
288 value, err := loginAndRequestToken(login, owner, project)
289 if err != nil {
290 return nil, err
291 }
292 token := auth.NewToken(value, target)
293 token.Metadata()[auth.MetaKeyLogin] = login
294 return token, nil
295 default:
296 return creds[index-3], nil
297 }
298 }
299}
300
301func promptToken() (string, error) {
302 fmt.Println("You can generate a new token by visiting https://github.com/settings/tokens.")
303 fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.")
304 fmt.Println()
305 fmt.Println("The access scope depend on the type of repository.")
306 fmt.Println("Public:")
307 fmt.Println(" - 'public_repo': to be able to read public repositories")
308 fmt.Println("Private:")
309 fmt.Println(" - 'repo' : to be able to read private repositories")
310 fmt.Println()
311
312 re, err := regexp.Compile(`^[a-zA-Z0-9]{40}`)
313 if err != nil {
314 panic("regexp compile:" + err.Error())
315 }
316
317 validator := func(name string, value string) (complaint string, err error) {
318 if re.MatchString(value) {
319 return "", nil
320 }
321 return "token has incorrect format", nil
322 }
323
324 return input.Prompt("Enter token", "token", input.Required, validator)
325}
326
327func loginAndRequestToken(login, owner, project string) (string, error) {
328 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.")
329 fmt.Println()
330 fmt.Println("The access scope depend on the type of repository.")
331 fmt.Println("Public:")
332 fmt.Println(" - 'public_repo': to be able to read public repositories")
333 fmt.Println("Private:")
334 fmt.Println(" - 'repo' : to be able to read private repositories")
335 fmt.Println()
336
337 // prompt project visibility to know the token scope needed for the repository
338 i, err := input.PromptChoice("repository visibility", []string{"public", "private"})
339 if err != nil {
340 return "", err
341 }
342 isPublic := i == 0
343
344 password, err := input.PromptPassword("Password", "password", input.Required)
345 if err != nil {
346 return "", err
347 }
348
349 var scope string
350 if isPublic {
351 // public_repo is requested to be able to read public repositories
352 scope = "public_repo"
353 } else {
354 // 'repo' is request to be able to read private repositories
355 // /!\ token will have read/write rights on every private repository you have access to
356 scope = "repo"
357 }
358
359 // Attempt to authenticate and create a token
360
361 note := fmt.Sprintf("git-bug - %s/%s", owner, project)
362
363 resp, err := requestToken(note, login, password, scope)
364 if err != nil {
365 return "", err
366 }
367
368 defer resp.Body.Close()
369
370 // Handle 2FA is needed
371 OTPHeader := resp.Header.Get("X-GitHub-OTP")
372 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
373 otpCode, err := input.PromptPassword("Two-factor authentication code", "code", input.Required)
374 if err != nil {
375 return "", err
376 }
377
378 resp, err = requestTokenWith2FA(note, login, password, otpCode, scope)
379 if err != nil {
380 return "", err
381 }
382
383 defer resp.Body.Close()
384 }
385
386 if resp.StatusCode == http.StatusCreated {
387 return decodeBody(resp.Body)
388 }
389
390 b, _ := ioutil.ReadAll(resp.Body)
391 return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b))
392}
393
394func promptURL(repo repository.RepoCommon) (string, string, error) {
395 // remote suggestions
396 remotes, err := repo.GetRemotes()
397 if err != nil {
398 return "", "", err
399 }
400
401 validRemotes := getValidGithubRemoteURLs(remotes)
402 if len(validRemotes) > 0 {
403 for {
404 fmt.Println("\nDetected projects:")
405
406 // print valid remote github urls
407 for i, remote := range validRemotes {
408 fmt.Printf("[%d]: %v\n", i+1, remote)
409 }
410
411 fmt.Printf("\n[0]: Another project\n\n")
412 fmt.Printf("Select option: ")
413
414 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
415 if err != nil {
416 return "", "", err
417 }
418
419 line = strings.TrimSpace(line)
420
421 index, err := strconv.Atoi(line)
422 if err != nil || index < 0 || index > len(validRemotes) {
423 fmt.Println("invalid input")
424 continue
425 }
426
427 // if user want to enter another project url break this loop
428 if index == 0 {
429 break
430 }
431
432 // get owner and project with index
433 owner, project, _ := splitURL(validRemotes[index-1])
434 return owner, project, nil
435 }
436 }
437
438 // manually enter github url
439 for {
440 fmt.Print("Github project URL: ")
441
442 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
443 if err != nil {
444 return "", "", err
445 }
446
447 line = strings.TrimSpace(line)
448 if line == "" {
449 fmt.Println("URL is empty")
450 continue
451 }
452
453 // get owner and project from url
454 owner, project, err := splitURL(line)
455 if err != nil {
456 fmt.Println(err)
457 continue
458 }
459
460 return owner, project, nil
461 }
462}
463
464// splitURL extract the owner and project from a github repository URL. It will remove the
465// '.git' extension from the URL before parsing it.
466// Note that Github removes the '.git' extension from projects names at their creation
467func splitURL(url string) (owner string, project string, err error) {
468 cleanURL := strings.TrimSuffix(url, ".git")
469
470 re, err := regexp.Compile(`github\.com[/:]([a-zA-Z0-9\-_]+)/([a-zA-Z0-9\-_.]+)`)
471 if err != nil {
472 panic("regexp compile:" + err.Error())
473 }
474
475 res := re.FindStringSubmatch(cleanURL)
476 if res == nil {
477 return "", "", ErrBadProjectURL
478 }
479
480 owner = res[1]
481 project = res[2]
482 return
483}
484
485func getValidGithubRemoteURLs(remotes map[string]string) []string {
486 urls := make([]string, 0, len(remotes))
487 for _, url := range remotes {
488 // split url can work again with shortURL
489 owner, project, err := splitURL(url)
490 if err == nil {
491 shortURL := fmt.Sprintf("%s/%s/%s", "github.com", owner, project)
492 urls = append(urls, shortURL)
493 }
494 }
495
496 sort.Strings(urls)
497
498 return urls
499}
500
501func validateUsername(username string) (bool, error) {
502 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
503
504 client := &http.Client{
505 Timeout: defaultTimeout,
506 }
507
508 resp, err := client.Get(url)
509 if err != nil {
510 return false, err
511 }
512
513 err = resp.Body.Close()
514 if err != nil {
515 return false, err
516 }
517
518 return resp.StatusCode == http.StatusOK, nil
519}
520
521func validateProject(owner, project string, token *auth.Token) (bool, error) {
522 url := fmt.Sprintf("%s/repos/%s/%s", githubV3Url, owner, project)
523
524 req, err := http.NewRequest("GET", url, nil)
525 if err != nil {
526 return false, err
527 }
528
529 // need the token for private repositories
530 req.Header.Set("Authorization", fmt.Sprintf("token %s", token.Value))
531
532 client := &http.Client{
533 Timeout: defaultTimeout,
534 }
535
536 resp, err := client.Do(req)
537 if err != nil {
538 return false, err
539 }
540
541 err = resp.Body.Close()
542 if err != nil {
543 return false, err
544 }
545
546 return resp.StatusCode == http.StatusOK, nil
547}