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