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 "strings"
15 "syscall"
16 "time"
17
18 "github.com/MichaelMure/git-bug/bridge/core"
19 "github.com/MichaelMure/git-bug/repository"
20 "golang.org/x/crypto/ssh/terminal"
21)
22
23const (
24 githubV3Url = "https://api.github.com"
25 keyUser = "user"
26 keyProject = "project"
27 keyToken = "token"
28)
29
30func (*Github) Configure(repo repository.RepoCommon) (core.Configuration, error) {
31 conf := make(core.Configuration)
32
33 fmt.Println()
34 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.")
35 fmt.Println()
36 fmt.Println("The token will have the following scopes:")
37 fmt.Println(" - user:email: to be able to read public-only users email")
38 // fmt.Println("The token will have the \"repo\" permission, giving it read/write access to your repositories and issues. There is no narrower scope available, sorry :-|")
39 fmt.Println()
40
41 projectUser, projectName, err := promptURL()
42 if err != nil {
43 return nil, err
44 }
45
46 conf[keyUser] = projectUser
47 conf[keyProject] = projectName
48
49 username, err := promptUsername()
50 if err != nil {
51 return nil, err
52 }
53
54 password, err := promptPassword()
55 if err != nil {
56 return nil, err
57 }
58
59 // Attempt to authenticate and create a token
60
61 note := fmt.Sprintf("git-bug - %s/%s", projectUser, projectName)
62
63 resp, err := requestToken(note, username, password)
64 if err != nil {
65 return nil, err
66 }
67
68 defer resp.Body.Close()
69
70 // Handle 2FA is needed
71 OTPHeader := resp.Header.Get("X-GitHub-OTP")
72 if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" {
73 otpCode, err := prompt2FA()
74 if err != nil {
75 return nil, err
76 }
77
78 resp, err = requestTokenWith2FA(note, username, password, otpCode)
79 if err != nil {
80 return nil, err
81 }
82
83 defer resp.Body.Close()
84 }
85
86 if resp.StatusCode == http.StatusCreated {
87 token, err := decodeBody(resp.Body)
88 if err != nil {
89 return nil, err
90 }
91 conf[keyToken] = token
92 return conf, nil
93 }
94
95 b, _ := ioutil.ReadAll(resp.Body)
96 fmt.Printf("Error %v: %v\n", resp.StatusCode, string(b))
97
98 return nil, nil
99}
100
101func (*Github) ValidateConfig(conf core.Configuration) error {
102 if _, ok := conf[keyToken]; !ok {
103 return fmt.Errorf("missing %s key", keyToken)
104 }
105
106 if _, ok := conf[keyUser]; !ok {
107 return fmt.Errorf("missing %s key", keyUser)
108 }
109
110 if _, ok := conf[keyProject]; !ok {
111 return fmt.Errorf("missing %s key", keyProject)
112 }
113
114 return nil
115}
116
117func requestToken(note, username, password string) (*http.Response, error) {
118 return requestTokenWith2FA(note, username, password, "")
119}
120
121func requestTokenWith2FA(note, username, password, otpCode string) (*http.Response, error) {
122 url := fmt.Sprintf("%s/authorizations", githubV3Url)
123 params := struct {
124 Scopes []string `json:"scopes"`
125 Note string `json:"note"`
126 Fingerprint string `json:"fingerprint"`
127 }{
128 // user:email is requested to be able to read public emails
129 // - a private email will stay private, even with this token
130 Scopes: []string{"user:email"},
131 Note: note,
132 Fingerprint: randomFingerprint(),
133 }
134
135 data, err := json.Marshal(params)
136 if err != nil {
137 return nil, err
138 }
139
140 req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
141 if err != nil {
142 return nil, err
143 }
144
145 req.SetBasicAuth(username, password)
146 req.Header.Set("Content-Type", "application/json")
147
148 if otpCode != "" {
149 req.Header.Set("X-GitHub-OTP", otpCode)
150 }
151
152 client := http.Client{}
153
154 return client.Do(req)
155}
156
157func decodeBody(body io.ReadCloser) (string, error) {
158 data, _ := ioutil.ReadAll(body)
159
160 aux := struct {
161 Token string `json:"token"`
162 }{}
163
164 err := json.Unmarshal(data, &aux)
165 if err != nil {
166 return "", err
167 }
168
169 if aux.Token == "" {
170 return "", fmt.Errorf("no token found in response: %s", string(data))
171 }
172
173 return aux.Token, nil
174}
175
176func randomFingerprint() string {
177 // Doesn't have to be crypto secure, it's just to avoid token collision
178 rand.Seed(time.Now().UnixNano())
179 var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
180 b := make([]rune, 32)
181 for i := range b {
182 b[i] = letterRunes[rand.Intn(len(letterRunes))]
183 }
184 return string(b)
185}
186
187func promptUsername() (string, error) {
188 for {
189 fmt.Print("username: ")
190
191 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
192 if err != nil {
193 return "", err
194 }
195
196 line = strings.TrimRight(line, "\n")
197
198 ok, err := validateUsername(line)
199 if err != nil {
200 return "", err
201 }
202 if ok {
203 return line, nil
204 }
205
206 fmt.Println("invalid username")
207 }
208}
209
210func promptURL() (string, string, error) {
211 for {
212 fmt.Print("Github project URL: ")
213
214 line, err := bufio.NewReader(os.Stdin).ReadString('\n')
215 if err != nil {
216 return "", "", err
217 }
218
219 line = strings.TrimRight(line, "\n")
220
221 if line == "" {
222 fmt.Println("URL is empty")
223 continue
224 }
225
226 projectUser, projectName, err := splitURL(line)
227
228 if err != nil {
229 fmt.Println(err)
230 continue
231 }
232
233 return projectUser, projectName, nil
234 }
235}
236
237func splitURL(url string) (string, string, error) {
238 re, err := regexp.Compile(`github\.com\/([^\/]*)\/([^\/]*)`)
239 if err != nil {
240 panic(err)
241 }
242
243 res := re.FindStringSubmatch(url)
244
245 if res == nil {
246 return "", "", fmt.Errorf("bad github project url")
247 }
248
249 return res[1], res[2], nil
250}
251
252func validateUsername(username string) (bool, error) {
253 url := fmt.Sprintf("%s/users/%s", githubV3Url, username)
254
255 resp, err := http.Get(url)
256 if err != nil {
257 return false, err
258 }
259
260 err = resp.Body.Close()
261 if err != nil {
262 return false, err
263 }
264
265 return resp.StatusCode == http.StatusOK, nil
266}
267
268func promptPassword() (string, error) {
269 for {
270 fmt.Print("password: ")
271
272 bytePassword, err := terminal.ReadPassword(int(syscall.Stdin))
273 // new line for coherent formatting, ReadPassword clip the normal new line
274 // entered by the user
275 fmt.Println()
276
277 if err != nil {
278 return "", err
279 }
280
281 if len(bytePassword) > 0 {
282 return string(bytePassword), nil
283 }
284
285 fmt.Println("password is empty")
286 }
287}
288
289func prompt2FA() (string, error) {
290 for {
291 fmt.Print("two-factor authentication code: ")
292
293 byte2fa, err := terminal.ReadPassword(int(syscall.Stdin))
294 if err != nil {
295 return "", err
296 }
297
298 if len(byte2fa) > 0 {
299 return string(byte2fa), nil
300 }
301
302 fmt.Println("code is empty")
303 }
304}