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