package staticsite import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strings" "time" ) // giteaTreeEntry represents a single entry in a Gitea git tree response. type giteaTreeEntry struct { Path string `json:"path"` Type string `json:"type"` // "blob" or "tree" SHA string `json:"sha"` Size int64 `json:"size"` } // giteaTreeResponse represents the Gitea git tree API response. type giteaTreeResponse struct { SHA string `json:"sha"` Entries []giteaTreeEntry `json:"tree"` Truncated bool `json:"truncated"` } // giteaBranch represents a branch from the Gitea API. type giteaBranch struct { Name string `json:"name"` Commit struct { ID string `json:"id"` } `json:"commit"` } // giteaRef represents a git reference from the Gitea API. type giteaRef struct { Ref string `json:"ref"` Object struct { SHA string `json:"sha"` } `json:"object"` } // GiteaContentFetcher downloads folder contents from a Gitea repository. type GiteaContentFetcher struct { baseURL string token string httpClient *http.Client } // NewGiteaContentFetcher creates a new content fetcher. // token may be empty for public repositories. func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher { return &GiteaContentFetcher{ baseURL: strings.TrimRight(baseURL, "/"), token: token, httpClient: NewSafeHTTPClient(60 * time.Second), } } // Name returns the provider identifier. func (f *GiteaContentFetcher) Name() string { return "gitea" } // ListRepos returns repositories accessible with the current token. func (f *GiteaContentFetcher) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { var allRepos []RepoInfo page := 1 limit := 50 for { url := fmt.Sprintf("%s/api/v1/repos/search?limit=%d&page=%d", f.baseURL, limit, page) if query != "" { url += "&q=" + query } if f.token != "" { // When authenticated, include private repos. url += "&private=true" } body, err := f.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list repos: %w", err) } var result struct { Data []struct { Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` } `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { // Gitea search wraps in {"data": [...]}, but some versions return a flat array. var flat []struct { Owner struct { Login string `json:"login"` } `json:"owner"` Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Private bool `json:"private"` HTMLURL string `json:"html_url"` } if err2 := json.Unmarshal(body, &flat); err2 != nil { return nil, fmt.Errorf("decode repos: %w", err) } for _, r := range flat { allRepos = append(allRepos, RepoInfo{ Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, }) } if len(flat) < limit { break } page++ continue } for _, r := range result.Data { allRepos = append(allRepos, RepoInfo{ Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, }) } if len(result.Data) < limit { break } page++ } return allRepos, nil } // ListBranches returns all branches for a repository. func (f *GiteaContentFetcher) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 limit := 50 for { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d", f.baseURL, owner, repo, page, limit) body, err := f.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list branches: %w", err) } var branches []giteaBranch 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) < limit { break } page++ } return allBranches, nil } // GetLatestCommitSHA returns the latest commit SHA for a branch. func (f *GiteaContentFetcher) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", f.baseURL, owner, repo, branch) body, err := f.doGet(ctx, url) if err != nil { return "", fmt.Errorf("get branch info: %w", err) } var b giteaBranch if err := json.Unmarshal(body, &b); err != nil { return "", fmt.Errorf("decode branch: %w", err) } return b.Commit.ID, nil } // FolderEntry represents a file or directory in the repo tree. type FolderEntry struct { Path string `json:"path"` IsDir bool `json:"is_dir"` } // ListTree returns the full directory tree for a branch, useful for the folder picker. func (f *GiteaContentFetcher) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true", f.baseURL, owner, repo, branch) body, err := f.doGet(ctx, url) if err != nil { return nil, fmt.Errorf("list tree: %w", err) } var tree giteaTreeResponse if err := json.Unmarshal(body, &tree); err != nil { return nil, fmt.Errorf("decode tree: %w", err) } entries := make([]FolderEntry, 0, len(tree.Entries)) for _, e := range tree.Entries { entries = append(entries, FolderEntry{ Path: e.Path, IsDir: e.Type == "tree", }) } return entries, nil } // DownloadFolder downloads all files from a specific folder path in the repo // to a local temporary directory. Returns the path to the temp directory. func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { // Get the full tree. url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true", f.baseURL, owner, repo, branch) body, err := f.doGet(ctx, url) if err != nil { return fmt.Errorf("fetch tree: %w", err) } var tree giteaTreeResponse if err := json.Unmarshal(body, &tree); err != nil { return fmt.Errorf("decode tree: %w", err) } // Normalize folder path. folderPath = strings.TrimPrefix(folderPath, "/") folderPath = strings.TrimSuffix(folderPath, "/") prefix := folderPath + "/" // Download each file in the folder. for _, entry := range tree.Entries { if entry.Type != "blob" { 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 anything whose resolved // destination escapes destDir. A hostile (or compromised) // Gitea instance could return tree entries with `..` in // the path; filepath.Join cleans them and would otherwise // write outside the build context. 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) } // Create parent directories. if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { return fmt.Errorf("create directory for %s: %w", relativePath, err) } // Download the file. fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", f.baseURL, owner, repo, entry.Path, branch) if err := f.downloadFile(ctx, fileURL, localPath); err != nil { return fmt.Errorf("download %s: %w", relativePath, err) } } return nil } // TestConnection verifies that the repository is accessible. func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error { url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo) _, err := f.doGet(ctx, url) if err != nil { return fmt.Errorf("test connection: %w", err) } return nil } // SetCommitStatus reports a deploy status on a commit via Gitea's commit- // status API (also serves Forgejo/Gogs). The "context" field is fixed to // "tinyforge" so repeated deploys update one status row. func (f *GiteaContentFetcher) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { state := giteaState(status) body, err := json.Marshal(map[string]string{ "state": state, "target_url": targetURL, "description": truncateDescription(description), "context": commitStatusContext, }) if err != nil { return fmt.Errorf("marshal status: %w", err) } // Path-escape each identifier so the URL shape matches the other // provider methods and a hostile owner/repo/sha can't break out of // the intended path. The SSRF-safe client guards the host. apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/statuses/%s", f.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) if err := postJSON(ctx, f.httpClient, apiURL, body, f.setAuth); err != nil { return fmt.Errorf("set commit status: %w", err) } return nil } // setAuth applies the Gitea token header (no-op when the token is empty). func (f *GiteaContentFetcher) setAuth(req *http.Request) { if f.token != "" { req.Header.Set("Authorization", "token "+f.token) } } // giteaState maps a provider-agnostic CommitStatus onto Gitea's API // vocabulary. Gitea accepts the same four words Tinyforge uses, so this is // a 1:1 mapping with a "pending" fallback for any unknown value. func giteaState(status CommitStatus) string { switch status { case CommitStatusSuccess: return "success" case CommitStatusFailure: return "failure" case CommitStatusError: return "error" default: return "pending" } } // doGet performs an authenticated GET request and returns the response body. func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("create request: %w", err) } if f.token != "" { req.Header.Set("Authorization", "token "+f.token) } req.Header.Set("Accept", "application/json") resp, err := f.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 } // downloadFile downloads a URL to a local file path. func (f *GiteaContentFetcher) downloadFile(ctx context.Context, url, localPath string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("create request: %w", err) } if f.token != "" { req.Header.Set("Authorization", "token "+f.token) } resp, err := f.httpClient.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) } 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 }