1package gitlab
2
3import (
4 "fmt"
5 "net/url"
6 "regexp"
7 "strconv"
8 "strings"
9
10 "github.com/pkg/errors"
11 "github.com/xanzy/go-gitlab"
12
13 "github.com/MichaelMure/git-bug/bridge/core"
14 "github.com/MichaelMure/git-bug/bridge/core/auth"
15 "github.com/MichaelMure/git-bug/cache"
16 "github.com/MichaelMure/git-bug/input"
17 "github.com/MichaelMure/git-bug/repository"
18)
19
20var (
21 ErrBadProjectURL = errors.New("bad project url")
22)
23
24func (g *Gitlab) ValidParams() map[string]interface{} {
25 return map[string]interface{}{
26 "URL": nil,
27 "BaseURL": nil,
28 "Login": nil,
29 "CredPrefix": nil,
30 "TokenRaw": nil,
31 }
32}
33
34func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams, interactive bool) (core.Configuration, error) {
35 var err error
36 var baseUrl string
37
38 switch {
39 case params.BaseURL != "":
40 baseUrl = params.BaseURL
41 default:
42 if !interactive {
43 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the gitlab instance URL via the --base-url option.")
44 }
45 baseUrl, err = input.PromptDefault("Gitlab server URL", "URL", defaultBaseURL, input.Required, input.IsURL)
46 if err != nil {
47 return nil, errors.Wrap(err, "base url prompt")
48 }
49 }
50
51 var projectURL string
52
53 // get project url
54 switch {
55 case params.URL != "":
56 projectURL = params.URL
57 default:
58 // terminal prompt
59 if !interactive {
60 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the gitlab project URL via the --url option.")
61 }
62 projectURL, err = promptProjectURL(repo, baseUrl)
63 if err != nil {
64 return nil, errors.Wrap(err, "url prompt")
65 }
66 }
67
68 if !strings.HasPrefix(projectURL, params.BaseURL) {
69 return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, projectURL)
70 }
71
72 var login string
73 var cred auth.Credential
74
75 switch {
76 case params.CredPrefix != "":
77 cred, err = auth.LoadWithPrefix(repo, params.CredPrefix)
78 if err != nil {
79 return nil, err
80 }
81 l, ok := cred.GetMetadata(auth.MetaKeyLogin)
82 if !ok {
83 return nil, fmt.Errorf("credential doesn't have a login")
84 }
85 login = l
86 case params.TokenRaw != "":
87 token := auth.NewToken(target, params.TokenRaw)
88 login, err = getLoginFromToken(baseUrl, token)
89 if err != nil {
90 return nil, err
91 }
92 token.SetMetadata(auth.MetaKeyLogin, login)
93 token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
94 cred = token
95 default:
96 if params.Login == "" {
97 if !interactive {
98 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the login name via the --login option.")
99 }
100 // TODO: validate username
101 login, err = input.Prompt("Gitlab login", "login", input.Required)
102 } else {
103 // TODO: validate username
104 login = params.Login
105 }
106 if err != nil {
107 return nil, err
108 }
109 if !interactive {
110 return nil, fmt.Errorf("Non-interactive-mode is active. Please specify the access token via the --token option.")
111 }
112 cred, err = promptTokenOptions(repo, login, baseUrl)
113 if err != nil {
114 return nil, err
115 }
116 }
117
118 token, ok := cred.(*auth.Token)
119 if !ok {
120 return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
121 }
122
123 // validate project url and get its ID
124 id, err := validateProjectURL(baseUrl, projectURL, token)
125 if err != nil {
126 return nil, errors.Wrap(err, "project validation")
127 }
128
129 conf := make(core.Configuration)
130 conf[core.ConfigKeyTarget] = target
131 conf[confKeyProjectID] = strconv.Itoa(id)
132 conf[confKeyGitlabBaseUrl] = baseUrl
133 conf[confKeyDefaultLogin] = login
134
135 err = g.ValidateConfig(conf)
136 if err != nil {
137 return nil, err
138 }
139
140 // don't forget to store the now known valid token
141 if !auth.IdExist(repo, cred.ID()) {
142 err = auth.Store(repo, cred)
143 if err != nil {
144 return nil, err
145 }
146 }
147
148 return conf, core.FinishConfig(repo, metaKeyGitlabLogin, login)
149}
150
151func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
152 if v, ok := conf[core.ConfigKeyTarget]; !ok {
153 return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
154 } else if v != target {
155 return fmt.Errorf("unexpected target name: %v", v)
156 }
157 if _, ok := conf[confKeyGitlabBaseUrl]; !ok {
158 return fmt.Errorf("missing %s key", confKeyGitlabBaseUrl)
159 }
160 if _, ok := conf[confKeyProjectID]; !ok {
161 return fmt.Errorf("missing %s key", confKeyProjectID)
162 }
163 if _, ok := conf[confKeyDefaultLogin]; !ok {
164 return fmt.Errorf("missing %s key", confKeyDefaultLogin)
165 }
166
167 return nil
168}
169
170func promptTokenOptions(repo repository.RepoKeyring, login, baseUrl string) (auth.Credential, error) {
171 creds, err := auth.List(repo,
172 auth.WithTarget(target),
173 auth.WithKind(auth.KindToken),
174 auth.WithMeta(auth.MetaKeyLogin, login),
175 auth.WithMeta(auth.MetaKeyBaseURL, baseUrl),
176 )
177 if err != nil {
178 return nil, err
179 }
180
181 cred, index, err := input.PromptCredential(target, "token", creds, []string{
182 "enter my token",
183 })
184 switch {
185 case err != nil:
186 return nil, err
187 case cred != nil:
188 return cred, nil
189 case index == 0:
190 return promptToken(baseUrl)
191 default:
192 panic("missed case")
193 }
194}
195
196func promptToken(baseUrl string) (*auth.Token, error) {
197 fmt.Printf("You can generate a new token by visiting %s.\n", strings.TrimSuffix(baseUrl, "/")+"/profile/personal_access_tokens")
198 fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
199 fmt.Println()
200 fmt.Println("'api' access scope: to be able to make api calls")
201 fmt.Println()
202
203 re := regexp.MustCompile(`^[a-zA-Z0-9\-\_]{20}$`)
204
205 var login string
206
207 validator := func(name string, value string) (complaint string, err error) {
208 if !re.MatchString(value) {
209 return "token has incorrect format", nil
210 }
211 login, err = getLoginFromToken(baseUrl, auth.NewToken(target, value))
212 if err != nil {
213 return fmt.Sprintf("token is invalid: %v", err), nil
214 }
215 return "", nil
216 }
217
218 rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
219 if err != nil {
220 return nil, err
221 }
222
223 token := auth.NewToken(target, rawToken)
224 token.SetMetadata(auth.MetaKeyLogin, login)
225 token.SetMetadata(auth.MetaKeyBaseURL, baseUrl)
226
227 return token, nil
228}
229
230func promptProjectURL(repo repository.RepoCommon, baseUrl string) (string, error) {
231 validRemotes, err := getValidGitlabRemoteURLs(repo, baseUrl)
232 if err != nil {
233 return "", err
234 }
235
236 return input.PromptURLWithRemote("Gitlab project URL", "URL", validRemotes, input.Required)
237}
238
239func getProjectPath(baseUrl, projectUrl string) (string, error) {
240 cleanUrl := strings.TrimSuffix(projectUrl, ".git")
241 cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
242 objectUrl, err := url.Parse(cleanUrl)
243 if err != nil {
244 return "", ErrBadProjectURL
245 }
246
247 objectBaseUrl, err := url.Parse(baseUrl)
248 if err != nil {
249 return "", ErrBadProjectURL
250 }
251
252 if objectUrl.Hostname() != objectBaseUrl.Hostname() {
253 return "", fmt.Errorf("base url and project url hostnames doesn't match")
254 }
255 return objectUrl.Path[1:], nil
256}
257
258func getValidGitlabRemoteURLs(repo repository.RepoCommon, baseUrl string) ([]string, error) {
259 remotes, err := repo.GetRemotes()
260 if err != nil {
261 return nil, err
262 }
263
264 urls := make([]string, 0, len(remotes))
265 for _, u := range remotes {
266 p, err := getProjectPath(baseUrl, u)
267 if err != nil {
268 continue
269 }
270
271 urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, p))
272 }
273
274 return urls, nil
275}
276
277func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
278 projectPath, err := getProjectPath(baseUrl, url)
279 if err != nil {
280 return 0, err
281 }
282
283 client, err := buildClient(baseUrl, token)
284 if err != nil {
285 return 0, err
286 }
287
288 project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
289 if err != nil {
290 return 0, errors.Wrap(err, "wrong token scope ou non-existent project")
291 }
292
293 return project.ID, nil
294}
295
296func getLoginFromToken(baseUrl string, token *auth.Token) (string, error) {
297 client, err := buildClient(baseUrl, token)
298 if err != nil {
299 return "", err
300 }
301
302 user, _, err := client.Users.CurrentUser()
303 if err != nil {
304 return "", err
305 }
306 if user.Username == "" {
307 return "", fmt.Errorf("gitlab say username is empty")
308 }
309
310 return user.Username, nil
311}