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