package staticsite import ( "bytes" "context" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" ) // ErrFileNotFound is returned by GitProvider.DownloadFile when the file is // absent (HTTP 404). Callers use it to distinguish "no file" (a normal, // non-error state for GitOps) from a genuine fetch failure. var ErrFileNotFound = errors.New("file not found") // 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"` } // CommitStatus is the deploy outcome reported back to the git provider as // a commit status. The values are provider-agnostic; each implementation // maps them onto its own API vocabulary (Gitea/GitHub use the same four // words, GitLab collapses failure/error into "failed"). type CommitStatus string const ( CommitStatusPending CommitStatus = "pending" CommitStatusSuccess CommitStatus = "success" CommitStatusFailure CommitStatus = "failure" CommitStatusError CommitStatus = "error" ) // commitStatusContext is the status "context"/"name" key reported to every // provider so repeated deploys update the same status row rather than // piling up new ones. const commitStatusContext = "tinyforge" // maxCommitStatusDescription caps the human-readable description so a // provider can't reject the request for an over-long field. const maxCommitStatusDescription = 140 // truncateDescription clamps a status description to the provider-safe // length, appending an ellipsis when it had to cut. func truncateDescription(s string) string { if len(s) <= maxCommitStatusDescription { return s } // Reserve room for the ellipsis rune; cut on a byte boundary that // stays under the cap. Descriptions are short ASCII strings in // practice, so a simple byte cut is fine here. return s[:maxCommitStatusDescription-1] + "…" } // 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 // DownloadFile fetches a single file's bytes from a ref (branch/sha), // capped at maxBytes. Returns ErrFileNotFound on a 404 so callers can // treat an absent file as a non-error state. Used to read a small in-repo // config file (e.g. .tinyforge.yml) without materializing a whole tree. DownloadFile(ctx context.Context, owner, repo, ref, path string, maxBytes int64) ([]byte, error) // SetCommitStatus reports a deploy status on a commit. Best-effort; // callers ignore errors beyond logging. targetURL and description are // optional (pass "" to omit); description is truncated to a provider- // safe length by the implementation. SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description 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 } // postJSON is a shared helper for POSTing a JSON body to a provider API // endpoint with the caller's auth applied. It accepts any 2xx as success // (status APIs return 201 Created on Gitea/GitHub, 200/201 on GitLab) and // returns a status-code-only error on non-2xx — it must NOT echo the // response body: the deploy hook logs this error best-effort, and a // hostile/misconfigured provider could reflect the request's auth token // back in its body. The body bytes must already be marshalled by the caller. func postJSON(ctx context.Context, client *http.Client, url string, body []byte, authHeader func(r *http.Request)) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } if authHeader != nil { authHeader(req) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { return fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("unexpected status %d", resp.StatusCode) } return nil } // getFileBytes GETs fileURL with the caller's auth applied and returns the // body, enforcing a maxBytes cap. Returns ErrFileNotFound on 404; a // status-code-only error otherwise (it must NOT echo the response body — a // hostile/misconfigured provider could reflect the request's auth token back). func getFileBytes(ctx context.Context, client *http.Client, fileURL string, maxBytes int64, authHeader func(r *http.Request)) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if authHeader != nil { authHeader(req) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("execute request: %w", err) } defer resp.Body.Close() switch { case resp.StatusCode == http.StatusNotFound: return nil, ErrFileNotFound case resp.StatusCode != http.StatusOK: return nil, fmt.Errorf("unexpected status %d", resp.StatusCode) } // Read one byte past the cap so an over-size file is detected rather than // silently truncated. data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return nil, fmt.Errorf("read response: %w", err) } if int64(len(data)) > maxBytes { return nil, fmt.Errorf("file exceeds %d byte cap", maxBytes) } return data, 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 }