From 0344215b42a19d3ae6a6e4ed2c908d126bf4183d Mon Sep 17 00:00:00 2001 From: KartoffelChipss Date: Tue, 5 May 2026 19:44:05 +0200 Subject: [PATCH] Add gitea provider --- backend/app/gitprovider/common.go | 105 +++++++++++++++++++++ backend/app/gitprovider/factory.go | 6 ++ backend/app/gitprovider/gitea.go | 141 +++++++++++++++++++++++++++++ backend/app/gitprovider/github.go | 96 -------------------- 4 files changed, 252 insertions(+), 96 deletions(-) create mode 100644 backend/app/gitprovider/common.go create mode 100644 backend/app/gitprovider/gitea.go diff --git a/backend/app/gitprovider/common.go b/backend/app/gitprovider/common.go new file mode 100644 index 0000000..a250a19 --- /dev/null +++ b/backend/app/gitprovider/common.go @@ -0,0 +1,105 @@ +package gitprovider + +import ( + "archive/zip" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// unzip extracts the zip at src into the dest directory, +// guarding against zip-slip path traversal attacks. +func unzip(src, dest string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + destClean := filepath.Clean(dest) + string(os.PathSeparator) + + for _, f := range r.File { + target := filepath.Join(dest, filepath.Clean("/"+f.Name)) + if !strings.HasPrefix(target, destClean) { + return fmt.Errorf("zip slip attempt: %q resolves outside destination", f.Name) + } + + if f.FileInfo().IsDir() { + if err = os.MkdirAll(target, f.Mode()); err != nil { + return err + } + continue + } + + if err = os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + + if err = writeZipEntry(f, target); err != nil { + return err + } + } + return nil +} + +func writeZipEntry(f *zip.File, target string) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, rc) + return err +} + +// copyDir recursively copies src to dst, preserving file modes. +// Used as a cross-device fallback when os.Rename fails. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + + info, err := d.Info() + if err != nil { + return err + } + return copyFile(path, target, info.Mode()) + }) +} + +func copyFile(src, dst string, mode fs.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/backend/app/gitprovider/factory.go b/backend/app/gitprovider/factory.go index f30e811..aa23854 100644 --- a/backend/app/gitprovider/factory.go +++ b/backend/app/gitprovider/factory.go @@ -13,6 +13,12 @@ func NewProvider(serverType, protocol, baseUrl, authToken string) (GitProvider, baseUrl: baseUrl, authToken: authToken, }, nil + case "gitea": + return &GiteaProvider{ + protocol: protocol, + baseUrl: baseUrl, + authToken: authToken, + }, nil default: return nil, fmt.Errorf("unsupported git provider: %s", serverType) } diff --git a/backend/app/gitprovider/gitea.go b/backend/app/gitprovider/gitea.go new file mode 100644 index 0000000..a51265f --- /dev/null +++ b/backend/app/gitprovider/gitea.go @@ -0,0 +1,141 @@ +package gitprovider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" +) + +type GiteaProvider struct { + protocol string + baseUrl string + authToken string +} + +var _ GitProvider = (*GiteaProvider)(nil) + +func (p *GiteaProvider) FetchAndDeployBranch(owner, repo, branch, destPath string) (*DeployResult, error) { + apiUrl := fmt.Sprintf("%s://%s/api/v1/", p.protocol, strings.TrimSuffix(p.baseUrl, "/")) + + result, err := fetchGiteaBranchHead(apiUrl, owner, repo, branch, p.authToken) + if err != nil { + return nil, fmt.Errorf("fetching branch head: %w", err) + } + + if err = downloadAndExtractGitea(apiUrl, owner, repo, branch, p.authToken, destPath); err != nil { + return nil, err + } + + return result, nil +} + +func fetchGiteaBranchHead(apiUrl, owner, repo, branch, token string) (*DeployResult, error) { + url := fmt.Sprintf("%srepos/%s/%s/branches/%s", apiUrl, owner, repo, branch) + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "token "+token) + req.Header.Set("Accept", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("fetching branch: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Gitea returned %s for %s", resp.Status, url) + } + + // GET /repos/{owner}/{repo}/branches/{branch} returns a Branch object + // whose commit field is a PayloadCommit. + var payload struct { + Commit struct { + ID string `json:"id"` + Message string `json:"message"` + } `json:"commit"` + } + if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decoding branch response: %w", err) + } + + return &DeployResult{ + CommitHash: payload.Commit.ID, + CommitMessage: strings.SplitN(payload.Commit.Message, "\n", 2)[0], + }, nil +} + +func downloadAndExtractGitea(apiUrl, owner, repo, branch, token, destDir string) error { + // GET /repos/{owner}/{repo}/archive/{sha}.zip + archiveURL := fmt.Sprintf("%srepos/%s/%s/archive/%s.zip", apiUrl, owner, repo, branch) + + req, err := http.NewRequest(http.MethodGet, archiveURL, nil) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Authorization", "token "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("fetching archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("Gitea returned %s for %s", resp.Status, archiveURL) + } + + storageRoot := filepath.Dir(destDir) + + tmpZip, err := os.CreateTemp(storageRoot, "giteabranch-*.zip") + if err != nil { + return fmt.Errorf("creating temp zip: %w", err) + } + defer os.Remove(tmpZip.Name()) + + if _, err = io.Copy(tmpZip, resp.Body); err != nil { + tmpZip.Close() + return fmt.Errorf("writing zip: %w", err) + } + tmpZip.Close() + + tmpDir, err := os.MkdirTemp(storageRoot, "giteabranch-unpack-*") + if err != nil { + return fmt.Errorf("creating temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err = unzip(tmpZip.Name(), tmpDir); err != nil { + return fmt.Errorf("unzipping: %w", err) + } + + // Gitea wraps everything in {repo}-{branch}/ (all lowercase). + entries, err := os.ReadDir(tmpDir) + if err != nil { + return fmt.Errorf("reading temp dir: %w", err) + } + if len(entries) != 1 || !entries[0].IsDir() { + return fmt.Errorf("unexpected archive layout: expected a single root directory") + } + extractedRoot := filepath.Join(tmpDir, entries[0].Name()) + + if err = os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { + return fmt.Errorf("creating destination parent dirs: %w", err) + } + os.RemoveAll(destDir) + + if err = os.Rename(extractedRoot, destDir); err == nil { + return nil + } + + if err = copyDir(extractedRoot, destDir); err != nil { + return fmt.Errorf("cross-device copy to destination: %w", err) + } + return nil +} diff --git a/backend/app/gitprovider/github.go b/backend/app/gitprovider/github.go index 92dbe5d..7b02bd1 100644 --- a/backend/app/gitprovider/github.go +++ b/backend/app/gitprovider/github.go @@ -1,11 +1,9 @@ package gitprovider import ( - "archive/zip" "encoding/json" "fmt" "io" - "io/fs" "net/http" "os" "path/filepath" @@ -149,97 +147,3 @@ func downloadAndExtract(repoOwner, repoName, branch, pat, destDir string) error } return nil } - -// unzip extracts the zip at src into the dest directory, -// guarding against zip-slip path traversal attacks. -func unzip(src, dest string) error { - r, err := zip.OpenReader(src) - if err != nil { - return err - } - defer r.Close() - - destClean := filepath.Clean(dest) + string(os.PathSeparator) - - for _, f := range r.File { - target := filepath.Join(dest, filepath.Clean("/"+f.Name)) - if !strings.HasPrefix(target, destClean) { - return fmt.Errorf("zip slip attempt: %q resolves outside destination", f.Name) - } - - if f.FileInfo().IsDir() { - if err = os.MkdirAll(target, f.Mode()); err != nil { - return err - } - continue - } - - if err = os.MkdirAll(filepath.Dir(target), 0o755); err != nil { - return err - } - - if err = writeZipEntry(f, target); err != nil { - return err - } - } - return nil -} - -func writeZipEntry(f *zip.File, target string) error { - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - - out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, rc) - return err -} - -// copyDir recursively copies src to dst, preserving file modes. -// Used as a cross-device fallback when os.Rename fails. -func copyDir(src, dst string) error { - return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - rel, err := filepath.Rel(src, path) - if err != nil { - return err - } - target := filepath.Join(dst, rel) - - if d.IsDir() { - return os.MkdirAll(target, 0o755) - } - - info, err := d.Info() - if err != nil { - return err - } - return copyFile(path, target, info.Mode()) - }) -} - -func copyFile(src, dst string, mode fs.FileMode) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -}