package staticsite import ( "context" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) // RepoInfo represents a repository returned by the provider's list/search API. type RepoInfo struct { Owner string `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` // "owner/name" Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` } // GitProvider abstracts Git hosting API operations. // Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab. type GitProvider interface { // Name returns the provider identifier (e.g., "gitea", "github", "gitlab"). Name() string // TestConnection verifies that the repository is accessible. TestConnection(ctx context.Context, owner, repo string) error // ListRepos returns repositories accessible with the current token. // If query is non-empty, results are filtered by name. ListRepos(ctx context.Context, query string) ([]RepoInfo, error) // ListBranches returns all branch names for a repository. ListBranches(ctx context.Context, owner, repo string) ([]string, error) // GetLatestCommitSHA returns the latest commit SHA for a branch. GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) // ListTree returns the full directory tree for a branch. ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) // DownloadFolder downloads all files from a folder path to a local directory. DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error } // ProviderType identifies a Git hosting provider. type ProviderType string const ( ProviderGitea ProviderType = "gitea" // Also works for Forgejo and Gogs. ProviderGitHub ProviderType = "github" ProviderGitLab ProviderType = "gitlab" ) // ValidProviderTypes lists all supported provider types. var ValidProviderTypes = []ProviderType{ProviderGitea, ProviderGitHub, ProviderGitLab} // NewGitProvider creates a GitProvider for the given type. // If providerType is empty, it attempts autodetection from the baseURL. func NewGitProvider(providerType ProviderType, baseURL, token string) (GitProvider, error) { if providerType == "" { providerType = DetectProvider(baseURL) } switch providerType { case ProviderGitea: return NewGiteaContentFetcher(baseURL, token), nil case ProviderGitHub: return NewGitHubProvider(baseURL, token), nil case ProviderGitLab: return NewGitLabProvider(baseURL, token), nil default: return nil, fmt.Errorf("unsupported git provider: %s", providerType) } } // DetectProvider guesses the provider type from a base URL. func DetectProvider(baseURL string) ProviderType { lower := strings.ToLower(baseURL) switch { case strings.Contains(lower, "github.com"): return ProviderGitHub case strings.Contains(lower, "gitlab.com"): return ProviderGitLab default: // Default to Gitea for self-hosted instances (Gitea/Forgejo/Gogs all share the same API). return ProviderGitea } } // DetectProviderWithProbe tries to autodetect the provider by probing known API endpoints. // Falls back to URL-based detection if probing fails. func DetectProviderWithProbe(ctx context.Context, baseURL string) ProviderType { // First try URL-based detection for well-known hosts. urlBased := DetectProvider(baseURL) if urlBased == ProviderGitHub || urlBased == ProviderGitLab { return urlBased } // For unknown hosts, probe for Gitea/GitLab API signatures using the // SSRF-safe client so a probe URL cannot be used to reach loopback // or cloud-metadata addresses. client := NewSafeHTTPClient(5 * time.Second) base := strings.TrimRight(baseURL, "/") // Try Gitea/Forgejo API. if resp, err := httpGet(ctx, client, base+"/api/v1/version"); err == nil && resp == http.StatusOK { return ProviderGitea } // Try GitLab API. if resp, err := httpGet(ctx, client, base+"/api/v4/version"); err == nil && resp == http.StatusOK { return ProviderGitLab } // Default to Gitea. return ProviderGitea } // httpGet performs a simple GET and returns the status code. func httpGet(ctx context.Context, client *http.Client, url string) (int, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return 0, err } resp, err := client.Do(req) if err != nil { return 0, err } resp.Body.Close() return resp.StatusCode, nil } // downloadFileHTTP is a shared helper for downloading a file from a URL. func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("create request: %w", err) } if authHeader != nil { authHeader(req) } resp, err := client.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url) } if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { return fmt.Errorf("create directory: %w", err) } file, err := os.Create(localPath) if err != nil { return fmt.Errorf("create file: %w", err) } defer file.Close() if _, err := io.Copy(file, resp.Body); err != nil { return fmt.Errorf("write file: %w", err) } return nil }