config.go

  1package gitlab
  2
  3import (
  4	"bufio"
  5	"fmt"
  6	"net/url"
  7	"os"
  8	"path"
  9	"regexp"
 10	"sort"
 11	"strconv"
 12	"strings"
 13	"time"
 14
 15	text "github.com/MichaelMure/go-term-text"
 16	"github.com/pkg/errors"
 17	"github.com/xanzy/go-gitlab"
 18
 19	"github.com/MichaelMure/git-bug/bridge/core"
 20	"github.com/MichaelMure/git-bug/bridge/core/auth"
 21	"github.com/MichaelMure/git-bug/cache"
 22	"github.com/MichaelMure/git-bug/input"
 23	"github.com/MichaelMure/git-bug/repository"
 24	"github.com/MichaelMure/git-bug/util/colors"
 25)
 26
 27var (
 28	ErrBadProjectURL = errors.New("bad project url")
 29)
 30
 31func (g *Gitlab) Configure(repo *cache.RepoCache, params core.BridgeParams) (core.Configuration, error) {
 32	if params.Project != "" {
 33		fmt.Println("warning: --project is ineffective for a gitlab bridge")
 34	}
 35	if params.Owner != "" {
 36		fmt.Println("warning: --owner is ineffective for a gitlab bridge")
 37	}
 38	if params.Login != "" {
 39		fmt.Println("warning: --login is ineffective for a gitlab bridge")
 40	}
 41
 42	conf := make(core.Configuration)
 43	var err error
 44	var baseUrl string
 45
 46	switch {
 47	case params.BaseURL != "":
 48		baseUrl = params.BaseURL
 49	default:
 50		baseUrl, err = promptBaseUrlOptions()
 51		if err != nil {
 52			return nil, errors.Wrap(err, "base url prompt")
 53		}
 54	}
 55
 56	var url string
 57
 58	// get project url
 59	switch {
 60	case params.URL != "":
 61		url = params.URL
 62	default:
 63		// terminal prompt
 64		url, err = promptURL(repo, baseUrl)
 65		if err != nil {
 66			return nil, errors.Wrap(err, "url prompt")
 67		}
 68	}
 69
 70	if !strings.HasPrefix(url, params.BaseURL) {
 71		return nil, fmt.Errorf("base URL (%s) doesn't match the project URL (%s)", params.BaseURL, url)
 72	}
 73
 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	case params.TokenRaw != "":
 83		token := auth.NewToken(params.TokenRaw, target)
 84		login, err := getLoginFromToken(baseUrl, token)
 85		if err != nil {
 86			return nil, err
 87		}
 88		token.SetMetadata(auth.MetaKeyLogin, login)
 89		cred = token
 90	default:
 91		cred, err = promptTokenOptions(repo, baseUrl)
 92		if err != nil {
 93			return nil, err
 94		}
 95	}
 96
 97	token, ok := cred.(*auth.Token)
 98	if !ok {
 99		return nil, fmt.Errorf("the Gitlab bridge only handle token credentials")
100	}
101
102	// validate project url and get its ID
103	id, err := validateProjectURL(baseUrl, url, token)
104	if err != nil {
105		return nil, errors.Wrap(err, "project validation")
106	}
107
108	conf[core.ConfigKeyTarget] = target
109	conf[keyProjectID] = strconv.Itoa(id)
110	conf[keyGitlabBaseUrl] = baseUrl
111
112	err = g.ValidateConfig(conf)
113	if err != nil {
114		return nil, err
115	}
116
117	// don't forget to store the now known valid token
118	if !auth.IdExist(repo, cred.ID()) {
119		err = auth.Store(repo, cred)
120		if err != nil {
121			return nil, err
122		}
123	}
124
125	return conf, nil
126}
127
128func (g *Gitlab) ValidateConfig(conf core.Configuration) error {
129	if v, ok := conf[core.ConfigKeyTarget]; !ok {
130		return fmt.Errorf("missing %s key", core.ConfigKeyTarget)
131	} else if v != target {
132		return fmt.Errorf("unexpected target name: %v", v)
133	}
134	if _, ok := conf[keyGitlabBaseUrl]; !ok {
135		return fmt.Errorf("missing %s key", keyGitlabBaseUrl)
136	}
137	if _, ok := conf[keyProjectID]; !ok {
138		return fmt.Errorf("missing %s key", keyProjectID)
139	}
140
141	return nil
142}
143
144func promptBaseUrlOptions() (string, error) {
145	index, err := input.PromptChoice("Gitlab base url", []string{
146		"https://gitlab.com",
147		"enter your own base url",
148	})
149
150	if err != nil {
151		return "", err
152	}
153
154	if index == 0 {
155		return defaultBaseURL, nil
156	} else {
157		return promptBaseUrl()
158	}
159}
160
161func promptBaseUrl() (string, error) {
162	validator := func(name string, value string) (string, error) {
163		u, err := url.Parse(value)
164		if err != nil {
165			return err.Error(), nil
166		}
167		if u.Scheme == "" {
168			return "missing scheme", nil
169		}
170		if u.Host == "" {
171			return "missing host", nil
172		}
173		return "", nil
174	}
175
176	return input.Prompt("Base url", "url", input.Required, validator)
177}
178
179func promptTokenOptions(repo repository.RepoConfig, baseUrl string) (auth.Credential, error) {
180	for {
181		creds, err := auth.List(repo, auth.WithTarget(target), auth.WithKind(auth.KindToken))
182		if err != nil {
183			return nil, err
184		}
185
186		// if we don't have existing token, fast-track to the token prompt
187		if len(creds) == 0 {
188			return promptToken(baseUrl)
189		}
190
191		fmt.Println()
192		fmt.Println("[1]: enter my token")
193
194		fmt.Println()
195		fmt.Println("Existing tokens for Gitlab:")
196
197		sort.Sort(auth.ById(creds))
198		for i, cred := range creds {
199			token := cred.(*auth.Token)
200			fmt.Printf("[%d]: %s => %s (%s)\n",
201				i+2,
202				colors.Cyan(token.ID().Human()),
203				colors.Red(text.TruncateMax(token.Value, 10)),
204				token.CreateTime().Format(time.RFC822),
205			)
206		}
207
208		fmt.Println()
209		fmt.Print("Select option: ")
210
211		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
212		fmt.Println()
213		if err != nil {
214			return nil, err
215		}
216
217		line = strings.TrimSpace(line)
218		index, err := strconv.Atoi(line)
219		if err != nil || index < 1 || index > len(creds)+1 {
220			fmt.Println("invalid input")
221			continue
222		}
223
224		switch index {
225		case 1:
226			return promptToken(baseUrl)
227		default:
228			return creds[index-2], nil
229		}
230	}
231}
232
233func promptToken(baseUrl string) (*auth.Token, error) {
234	fmt.Printf("You can generate a new token by visiting %s.\n", path.Join(baseUrl, "profile/personal_access_tokens"))
235	fmt.Println("Choose 'Create personal access token' and set the necessary access scope for your repository.")
236	fmt.Println()
237	fmt.Println("'api' access scope: to be able to make api calls")
238	fmt.Println()
239
240	re, err := regexp.Compile(`^[a-zA-Z0-9\-\_]{20}$`)
241	if err != nil {
242		panic("regexp compile:" + err.Error())
243	}
244
245	var login string
246
247	validator := func(name string, value string) (complaint string, err error) {
248		if !re.MatchString(value) {
249			return "token has incorrect format", nil
250		}
251		login, err = getLoginFromToken(baseUrl, auth.NewToken(value, target))
252		if err != nil {
253			return fmt.Sprintf("token is invalid: %v", err), nil
254		}
255		return "", nil
256	}
257
258	rawToken, err := input.Prompt("Enter token", "token", input.Required, validator)
259	if err != nil {
260		return nil, err
261	}
262
263	token := auth.NewToken(rawToken, target)
264	token.SetMetadata(auth.MetaKeyLogin, login)
265
266	return token, nil
267}
268
269func promptURL(repo repository.RepoCommon, baseUrl string) (string, error) {
270	// remote suggestions
271	remotes, err := repo.GetRemotes()
272	if err != nil {
273		return "", errors.Wrap(err, "getting remotes")
274	}
275
276	validRemotes := getValidGitlabRemoteURLs(baseUrl, remotes)
277	if len(validRemotes) > 0 {
278		for {
279			fmt.Println("\nDetected projects:")
280
281			// print valid remote gitlab urls
282			for i, remote := range validRemotes {
283				fmt.Printf("[%d]: %v\n", i+1, remote)
284			}
285
286			fmt.Printf("\n[0]: Another project\n\n")
287			fmt.Printf("Select option: ")
288
289			line, err := bufio.NewReader(os.Stdin).ReadString('\n')
290			if err != nil {
291				return "", err
292			}
293
294			line = strings.TrimSpace(line)
295
296			index, err := strconv.Atoi(line)
297			if err != nil || index < 0 || index > len(validRemotes) {
298				fmt.Println("invalid input")
299				continue
300			}
301
302			// if user want to enter another project url break this loop
303			if index == 0 {
304				break
305			}
306
307			return validRemotes[index-1], nil
308		}
309	}
310
311	// manually enter gitlab url
312	for {
313		fmt.Print("Gitlab project URL: ")
314
315		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
316		if err != nil {
317			return "", err
318		}
319
320		url := strings.TrimSpace(line)
321		if url == "" {
322			fmt.Println("URL is empty")
323			continue
324		}
325
326		return url, nil
327	}
328}
329
330func getProjectPath(baseUrl, projectUrl string) (string, error) {
331	cleanUrl := strings.TrimSuffix(projectUrl, ".git")
332	cleanUrl = strings.Replace(cleanUrl, "git@", "https://", 1)
333	objectUrl, err := url.Parse(cleanUrl)
334	if err != nil {
335		return "", ErrBadProjectURL
336	}
337
338	objectBaseUrl, err := url.Parse(baseUrl)
339	if err != nil {
340		return "", ErrBadProjectURL
341	}
342
343	if objectUrl.Hostname() != objectBaseUrl.Hostname() {
344		return "", fmt.Errorf("base url and project url hostnames doesn't match")
345	}
346	return objectUrl.Path[1:], nil
347}
348
349func getValidGitlabRemoteURLs(baseUrl string, remotes map[string]string) []string {
350	urls := make([]string, 0, len(remotes))
351	for _, u := range remotes {
352		path, err := getProjectPath(baseUrl, u)
353		if err != nil {
354			continue
355		}
356
357		urls = append(urls, fmt.Sprintf("%s/%s", baseUrl, path))
358	}
359
360	return urls
361}
362
363func validateProjectURL(baseUrl, url string, token *auth.Token) (int, error) {
364	projectPath, err := getProjectPath(baseUrl, url)
365	if err != nil {
366		return 0, err
367	}
368
369	client, err := buildClient(baseUrl, token)
370	if err != nil {
371		return 0, err
372	}
373
374	project, _, err := client.Projects.GetProject(projectPath, &gitlab.GetProjectOptions{})
375	if err != nil {
376		return 0, errors.Wrap(err, "wrong token scope ou non-existent project")
377	}
378
379	return project.ID, nil
380}
381
382func getLoginFromToken(baseUrl string, token *auth.Token) (string, error) {
383	client, err := buildClient(baseUrl, token)
384	if err != nil {
385		return "", err
386	}
387
388	user, _, err := client.Users.CurrentUser()
389	if err != nil {
390		return "", err
391	}
392	if user.Username == "" {
393		return "", fmt.Errorf("gitlab say username is empty")
394	}
395
396	return user.Username, nil
397}