package staticsite import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) // GitLabProvider implements GitProvider for GitLab repositories. type GitLabProvider struct { apiBase string // e.g., "https://gitlab.com/api/v4" rawBase string // e.g., "https://gitlab.com" token string httpClient *http.Client } // NewGitLabProvider creates a new GitLab provider. // baseURL should be "https://gitlab.com" or a self-hosted GitLab URL. func NewGitLabProvider(baseURL, token string) *GitLabProvider { base := strings.TrimRight(baseURL, "/") return &GitLabProvider{ apiBase: base + "/api/v4", rawBase: base, token: token, httpClient: NewSafeHTTPClient(60 * time.Second), } } func (g *GitLabProvider) Name() string { return "gitlab" } // projectPath returns the URL-encoded project path (owner/repo → owner%2Frepo). func projectPath(owner, repo string) string { return url.PathEscape(owner + "/" + repo) } func (g *GitLabProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { var allRepos []RepoInfo page := 1 for { apiURL := fmt.Sprintf("%s/projects?membership=true&per_page=100&page=%d&order_by=last_activity_at", g.apiBase, page) if query != "" { apiURL += "&search=" + url.QueryEscape(query) } body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list repos: %w", err) } var projects []struct { PathWithNamespace string `json:"path_with_namespace"` Name string `json:"name"` Description string `json:"description"` Visibility string `json:"visibility"` WebURL string `json:"web_url"` Namespace struct { Path string `json:"path"` } `json:"namespace"` } if err := json.Unmarshal(body, &projects); err != nil { return nil, fmt.Errorf("decode repos: %w", err) } for _, p := range projects { allRepos = append(allRepos, RepoInfo{ Owner: p.Namespace.Path, Name: p.Name, FullName: p.PathWithNamespace, Description: p.Description, Private: p.Visibility != "public", HTMLURL: p.WebURL, }) } if len(projects) < 100 { break } page++ } return allRepos, nil } func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) error { apiURL := fmt.Sprintf("%s/projects/%s", g.apiBase, projectPath(owner, repo)) _, err := g.doGet(ctx, apiURL) return err } // SetCommitStatus reports a deploy status on a commit via GitLab's commit- // status API. GitLab's state vocabulary differs (pending/running/success/ // failed/canceled), so failure AND error both map to "failed". The status // metadata (name/target_url/description) is passed as query parameters, // which is how GitLab's POST .../statuses/{sha} endpoint accepts them. func (g *GitLabProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { q := url.Values{} q.Set("state", gitlabState(status)) q.Set("name", commitStatusContext) if targetURL != "" { q.Set("target_url", targetURL) } if description != "" { q.Set("description", truncateDescription(description)) } apiURL := fmt.Sprintf("%s/projects/%s/statuses/%s?%s", g.apiBase, projectPath(owner, repo), url.PathEscape(sha), q.Encode()) // No JSON body — all fields ride as query params. Reuse postJSON for // the SSRF-safe POST + 2xx handling; an empty body is valid here. if err := postJSON(ctx, g.httpClient, apiURL, nil, g.setAuth); err != nil { return fmt.Errorf("set commit status: %w", err) } return nil } // gitlabState maps a provider-agnostic CommitStatus onto GitLab's API // vocabulary. GitLab has no "failure"/"error" split — both map to // "failed". func gitlabState(status CommitStatus) string { switch status { case CommitStatusSuccess: return "success" case CommitStatusFailure, CommitStatusError: return "failed" default: return "pending" } } func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 for { apiURL := fmt.Sprintf("%s/projects/%s/repository/branches?per_page=100&page=%d", g.apiBase, projectPath(owner, repo), page) body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list branches: %w", err) } var branches []struct { Name string `json:"name"` } if err := json.Unmarshal(body, &branches); err != nil { return nil, fmt.Errorf("decode branches: %w", err) } for _, b := range branches { allBranches = append(allBranches, b.Name) } if len(branches) < 100 { break } page++ } return allBranches, nil } func (g *GitLabProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { apiURL := fmt.Sprintf("%s/projects/%s/repository/branches/%s", g.apiBase, projectPath(owner, repo), url.PathEscape(branch)) body, err := g.doGet(ctx, apiURL) if err != nil { return "", fmt.Errorf("get branch: %w", err) } var result struct { Commit struct { ID string `json:"id"` } `json:"commit"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("decode branch: %w", err) } return result.Commit.ID, nil } func (g *GitLabProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { var allEntries []FolderEntry page := 1 for { apiURL := fmt.Sprintf("%s/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", g.apiBase, projectPath(owner, repo), url.QueryEscape(branch), page) body, err := g.doGet(ctx, apiURL) if err != nil { return nil, fmt.Errorf("list tree: %w", err) } var entries []struct { Path string `json:"path"` Type string `json:"type"` // "blob" or "tree" } if err := json.Unmarshal(body, &entries); err != nil { return nil, fmt.Errorf("decode tree: %w", err) } for _, e := range entries { allEntries = append(allEntries, FolderEntry{ Path: e.Path, IsDir: e.Type == "tree", }) } if len(entries) < 100 { break } page++ } return allEntries, nil } func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { entries, err := g.ListTree(ctx, owner, repo, branch) if err != nil { return fmt.Errorf("list tree: %w", err) } folderPath = strings.TrimPrefix(folderPath, "/") folderPath = strings.TrimSuffix(folderPath, "/") prefix := folderPath + "/" for _, entry := range entries { if entry.IsDir { continue } if !strings.HasPrefix(entry.Path, prefix) { continue } relativePath := strings.TrimPrefix(entry.Path, prefix) localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) // Path-traversal defense: reject tree entries whose resolved // path escapes destDir (e.g. `../etc/passwd` smuggled through // a hostile self-hosted GitLab). cleanDest := filepath.Clean(destDir) if cleanRel := filepath.Clean(localPath); cleanRel != cleanDest && !strings.HasPrefix(cleanRel, cleanDest+string(os.PathSeparator)) { return fmt.Errorf("rejecting tree entry outside dest: %s", relativePath) } // GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path} // Each segment is path-escaped to match projectPath()'s shape and // to refuse traversal sequences supplied via the request. fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s", g.rawBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(branch), entry.Path) if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { return fmt.Errorf("download %s: %w", relativePath, err) } } return nil } func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } g.setAuth(req) req.Header.Set("Accept", "application/json") resp, err := g.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) } return body, nil } func (g *GitLabProvider) setAuth(req *http.Request) { if g.token != "" { req.Header.Set("PRIVATE-TOKEN", g.token) } }