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