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