diff options
-rw-r--r-- | bridge/gitlab/config.go | 518 |
1 files changed, 518 insertions, 0 deletions
diff --git a/bridge/gitlab/config.go b/bridge/gitlab/config.go new file mode 100644 index 00000000..997494dd --- /dev/null +++ b/bridge/gitlab/config.go @@ -0,0 +1,518 @@ +package gitlab + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net/http" + neturl "net/url" + "os" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/pkg/errors" + "github.com/xanzy/go-gitlab" + "golang.org/x/crypto/ssh/terminal" + + "github.com/MichaelMure/git-bug/bridge/core" + "github.com/MichaelMure/git-bug/repository" +) + +const ( + target = "gitlab" + gitlabV4Url = "https://gitlab.com/api/v4" + keyID = "id" + keyTarget = "target" + keyToken = "token" + + defaultTimeout = 60 * time.Second +) + +//note to my self: bridge configure --target=gitlab --url=$URL + +var ( + ErrBadProjectURL = errors.New("bad project url") +) + +func (*Gitlab) Configure(repo repository.RepoCommon, params core.BridgeParams) (core.Configuration, error) { + if params.Project != "" { + fmt.Println("warning: --project is ineffective for a gitlab bridge") + } + if params.Owner != "" { + fmt.Println("warning: --owner is ineffective for a gitlab bridge") + } + + conf := make(core.Configuration) + var err error + var url string + var token string + var projectID string + + // get project url + if params.URL != "" { + url = params.URL + + } else { + // remote suggestions + remotes, err := repo.GetRemotes() + if err != nil { + return nil, err + } + + // terminal prompt + url, err = promptURL(remotes) + if err != nil { + return nil, err + } + } + + // get user token + if params.Token != "" { + token = params.Token + } else { + token, err = promptTokenOptions(url) + if err != nil { + return nil, err + } + } + + var ok bool + // validate project url and get it ID + ok, projectID, err = validateProjectURL(url, token) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("invalid project id or wrong token scope") + } + + conf[keyID] = projectID + conf[keyToken] = token + conf[keyTarget] = target + + return conf, nil +} + +func (*Gitlab) ValidateConfig(conf core.Configuration) error { + if v, ok := conf[keyTarget]; !ok { + return fmt.Errorf("missing %s key", keyTarget) + } else if v != target { + return fmt.Errorf("unexpected target name: %v", v) + } + + if _, ok := conf[keyToken]; !ok { + return fmt.Errorf("missing %s key", keyToken) + } + + if _, ok := conf[keyID]; !ok { + return fmt.Errorf("missing %s key", keyID) + } + + return nil +} + +func requestToken(note, username, password string, scope string) (*http.Response, error) { + return requestTokenWith2FA(note, username, password, "", scope) +} + +//TODO: FIX THIS ONE +func requestTokenWith2FA(note, username, password, otpCode string, scope string) (*http.Response, error) { + url := fmt.Sprintf("%s/authorizations", gitlabV4Url) + params := struct { + Scopes []string `json:"scopes"` + Note string `json:"note"` + Fingerprint string `json:"fingerprint"` + }{ + Scopes: []string{scope}, + Note: note, + Fingerprint: randomFingerprint(), + } + + data, err := json.Marshal(params) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + req.SetBasicAuth(username, password) + req.Header.Set("Content-Type", "application/json") + + if otpCode != "" { + req.Header.Set("X-GitHub-OTP", otpCode) + } + + client := &http.Client{ + Timeout: defaultTimeout, + } + + return client.Do(req) +} + +func decodeBody(body io.ReadCloser) (string, error) { + data, _ := ioutil.ReadAll(body) + + aux := struct { + Token string `json:"token"` + }{} + + err := json.Unmarshal(data, &aux) + if err != nil { + return "", err + } + + if aux.Token == "" { + return "", fmt.Errorf("no token found in response: %s", string(data)) + } + + return aux.Token, nil +} + +func randomFingerprint() string { + // Doesn't have to be crypto secure, it's just to avoid token collision + rand.Seed(time.Now().UnixNano()) + var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, 32) + for i := range b { + b[i] = letterRunes[rand.Intn(len(letterRunes))] + } + return string(b) +} + +func promptTokenOptions(url string) (string, error) { + for { + fmt.Println() + fmt.Println("[1]: user provided token") + fmt.Println("[2]: interactive token creation") + fmt.Print("Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Println() + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index != 1 && index != 2) { + fmt.Println("invalid input") + continue + } + + if index == 1 { + return promptToken() + } + + return loginAndRequestToken(url) + } +} + +func promptToken() (string, error) { + fmt.Println("You can generate a new token by visiting https://gitlab.com/settings/tokens.") + fmt.Println("Choose 'Generate new token' and set the necessary access scope for your repository.") + fmt.Println() + fmt.Println("The access scope depend on the type of repository.") + fmt.Println("Public:") + fmt.Println(" - 'public_repo': to be able to read public repositories") + fmt.Println("Private:") + fmt.Println(" - 'repo' : to be able to read private repositories") + fmt.Println() + + re, err := regexp.Compile(`^[a-zA-Z0-9\-]{20}`) + if err != nil { + panic("regexp compile:" + err.Error()) + } + + for { + fmt.Print("Enter token: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + token := strings.TrimRight(line, "\n") + if re.MatchString(token) { + return token, nil + } + + fmt.Println("token is invalid") + } +} + +// TODO: FIX THIS ONE TOO +func loginAndRequestToken(url string) (string, error) { + + // prompt project visibility to know the token scope needed for the repository + isPublic, err := promptProjectVisibility() + if err != nil { + return "", err + } + + username, err := promptUsername() + if err != nil { + return "", err + } + + password, err := promptPassword() + if err != nil { + return "", err + } + + var scope string + //TODO: Gitlab scopes + if isPublic { + // public_repo is requested to be able to read public repositories + scope = "public_repo" + } else { + // 'repo' is request to be able to read private repositories + // /!\ token will have read/write rights on every private repository you have access to + scope = "repo" + } + + // Attempt to authenticate and create a token + + note := fmt.Sprintf("git-bug - %s/%s", url) + + resp, err := requestToken(note, username, password, scope) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + // Handle 2FA is needed + OTPHeader := resp.Header.Get("X-GitHub-OTP") + if resp.StatusCode == http.StatusUnauthorized && OTPHeader != "" { + otpCode, err := prompt2FA() + if err != nil { + return "", err + } + + resp, err = requestTokenWith2FA(note, username, password, otpCode, scope) + if err != nil { + return "", err + } + + defer resp.Body.Close() + } + + if resp.StatusCode == http.StatusCreated { + return decodeBody(resp.Body) + } + + b, _ := ioutil.ReadAll(resp.Body) + return "", fmt.Errorf("error creating token %v: %v", resp.StatusCode, string(b)) +} + +func promptUsername() (string, error) { + for { + fmt.Print("username: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + + ok, err := validateUsername(line) + if err != nil { + return "", err + } + if ok { + return line, nil + } + + fmt.Println("invalid username") + } +} + +func promptURL(remotes map[string]string) (string, error) { + validRemotes := getValidGitlabRemoteURLs(remotes) + if len(validRemotes) > 0 { + for { + fmt.Println("\nDetected projects:") + + // print valid remote gitlab urls + for i, remote := range validRemotes { + fmt.Printf("[%d]: %v\n", i+1, remote) + } + + fmt.Printf("\n[0]: Another project\n\n") + fmt.Printf("Select option: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index < 0 && index >= len(validRemotes)) { + fmt.Println("invalid input") + continue + } + + // if user want to enter another project url break this loop + if index == 0 { + break + } + + return validRemotes[index-1], nil + } + } + + // manually enter gitlab url + for { + fmt.Print("Gitlab project URL: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", err + } + + url := strings.TrimRight(line, "\n") + if line == "" { + fmt.Println("URL is empty") + continue + } + + return url, nil + } +} + +func splitURL(url string) (string, string, error) { + cleanUrl := strings.TrimSuffix(url, ".git") + objectUrl, err := neturl.Parse(cleanUrl) + if err != nil { + return "", "", nil + } + + return fmt.Sprintf("%s%s", objectUrl.Host, objectUrl.Path), objectUrl.Path, nil +} + +func getValidGitlabRemoteURLs(remotes map[string]string) []string { + urls := make([]string, 0, len(remotes)) + for _, u := range remotes { + url, _, err := splitURL(u) + if err != nil { + continue + } + + urls = append(urls, url) + } + + return urls +} + +func validateUsername(username string) (bool, error) { + // no need for a token for this action + client := buildClient("") + + users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{Username: &username}) + if err != nil { + return false, err + } + + if len(users) == 0 { + return false, fmt.Errorf("username not found") + } else if len(users) > 1 { + return false, fmt.Errorf("found multiple matches") + } + + return users[0].Username == username, nil +} + +func validateProjectURL(url, token string) (bool, string, error) { + client := buildClient(token) + + _, projectPath, err := splitURL(url) + if err != nil { + return false, "", err + } + + project, _, err := client.Projects.GetProject(projectPath[1:], &gitlab.GetProjectOptions{}) + if err != nil { + return false, "", err + } + projectID := strconv.Itoa(project.ID) + + return true, projectID, nil +} + +func promptPassword() (string, error) { + for { + fmt.Print("password: ") + + bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) + // new line for coherent formatting, ReadPassword clip the normal new line + // entered by the user + fmt.Println() + + if err != nil { + return "", err + } + + if len(bytePassword) > 0 { + return string(bytePassword), nil + } + + fmt.Println("password is empty") + } +} + +func prompt2FA() (string, error) { + for { + fmt.Print("two-factor authentication code: ") + + byte2fa, err := terminal.ReadPassword(int(syscall.Stdin)) + fmt.Println() + if err != nil { + return "", err + } + + if len(byte2fa) > 0 { + return string(byte2fa), nil + } + + fmt.Println("code is empty") + } +} + +func promptProjectVisibility() (bool, error) { + for { + fmt.Println("[1]: public") + fmt.Println("[2]: private") + fmt.Print("repository visibility: ") + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + fmt.Println() + if err != nil { + return false, err + } + + line = strings.TrimRight(line, "\n") + + index, err := strconv.Atoi(line) + if err != nil || (index != 0 && index != 1) { + fmt.Println("invalid input") + continue + } + + // return true for public repositories, false for private + return index == 0, nil + } +} |