From a850df01d8db43d48b60e25f71fcd5cd05fcfbce Mon Sep 17 00:00:00 2001 From: poyo Date: Mon, 3 Aug 2026 22:50:36 +0200 Subject: [PATCH] Add ZIP download option Allows per-branch ZIP, and downloads the file in a (name)-(ref).zip format. Validates if the branch exists, if not it throws a "404 Not Found" error. --- README.md | 4 +++ app/app.go | 3 ++ app/repo-download.go | 50 ++++++++++++++++++++++++++++++ lib/git/git.go | 10 +++--- lib/git/zip.go | 47 ++++++++++++++++++++++++++++ static/css/repos.css | 15 +++++++-- static/css/styles.css | 2 +- templating/views/layouts/repo.html | 1 + 8 files changed, 124 insertions(+), 8 deletions(-) create mode 100644 app/repo-download.go create mode 100644 lib/git/zip.go diff --git a/README.md b/README.md index a4a1778..d62a8aa 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,10 @@ The `?` prefix refers to a "possible future feature". - [ ] Cloning. - [ ] Public repos. - [ ] Private repos. + - [x] ZIP: + - [x] Generate ZIP. + - [x] Stream ZIP. + - [x] Per-branch ZIP. - [x] Hackatime: - [x] "Time spent" badge on repos. - [x] Link Hackatime. diff --git a/app/app.go b/app/app.go index 3d83d58..6302a3c 100644 --- a/app/app.go +++ b/app/app.go @@ -113,6 +113,9 @@ func New(store Store, config config.Config) *App { repo.HandleFunc("/log", withMode("log", a.RepoHandler)) repo.HandleFunc("/log/{branch}", withMode("log", a.RepoHandler)) + repo.HandleFunc("/zip", a.RepoZipDownload) + repo.HandleFunc("/zip/{branch}", a.RepoZipDownload) + repoConfig := repo.PathPrefix("/config").Subrouter() repoConfig.Use(md.RequireRepoOwnership(a.Store)) diff --git a/app/repo-download.go b/app/repo-download.go new file mode 100644 index 0000000..99fc9cd --- /dev/null +++ b/app/repo-download.go @@ -0,0 +1,50 @@ +package app + +import ( + "context" + md "farga/app/middleware" + "farga/db/types" + "farga/lib/git" + "fmt" + "net/http" + "time" + + "github.com/gorilla/mux" +) + +func (a *App) RepoZipDownload(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + branch := vars["branch"] + + dbRepo, ok := r.Context().Value(md.RepoKey).(types.Repo) + if !ok { + http.Error(w, "Internal Server Error: Middleware did not provide repo data.", http.StatusInternalServerError) + return + } + + gitRepo, err := git.Repo(*dbRepo.UserID, dbRepo.ID, branch) + if err != nil { + http.Error(w, "Such a repo wasn't found.", http.StatusInternalServerError) + return + } + + repoPath := git.RepoPath(*dbRepo.UserID, dbRepo.ID) + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + // don't waste resources if the reference is invalid. + ref, err := git.ResolveHead(gitRepo.Git, branch) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s-%s.zip\"", dbRepo.Name, ref.Name().Short())) // name the file + + err = git.ZipStream(ctx, repoPath, ref.Hash().String(), w) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } +} diff --git a/lib/git/git.go b/lib/git/git.go index 8ebfafd..62cd2fe 100644 --- a/lib/git/git.go +++ b/lib/git/git.go @@ -110,13 +110,13 @@ func ResolveHead(repo *git.Repository, branchName string) (ref *plumbing.Referen ref, err = repo.Head() // get HEAD as default if err != nil { // no HEAD? fine. I'll do it myself - branches, err := repo.Branches() - if err != nil { - return nil, err // idk man. + branches, branchErr := repo.Branches() + if branchErr != nil { + return nil, branchErr // idk man. } - firstBranch, err := branches.Next() + firstBranch, nextErr := branches.Next() branches.Close() - if err != nil { + if nextErr != nil { // holy shit there are no branches return nil, ErrRepoHasNoHead } diff --git a/lib/git/zip.go b/lib/git/zip.go new file mode 100644 index 0000000..6f471fb --- /dev/null +++ b/lib/git/zip.go @@ -0,0 +1,47 @@ +package git + +import ( + "bytes" + "context" + "fmt" + "io" + "os/exec" + "regexp" +) + +var ValidRef = regexp.MustCompile(`^[A-Za-z0-9._/-]+$`) +var ErrInvalidRef = fmt.Errorf("Invalid ref.") + +// verifies if a reference (hash) exists. Will error if not. +func RefExists(ctx context.Context, repoPath, ref string) error { + if !ValidRef.MatchString(ref) { + return ErrInvalidRef + } + + cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "--quiet", ref+"^") + cmd.Dir = repoPath + if err := cmd.Run(); err != nil { + return fmt.Errorf("ref %q not found: %w", ref, err) + } + return nil +} + +// uses git's `git archive --format=zip` to create a zip archive and streams it. +func ZipStream(ctx context.Context, repoPath, ref string, w io.Writer) error { + if !ValidRef.MatchString(ref) { + return ErrInvalidRef + } + + cmd := exec.CommandContext(ctx, "git", "archive", "--format=zip", "--", ref+"^{commit}") + cmd.Dir = repoPath // repo itself + cmd.Stdout = w // could be the http writer + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("git archive failed: %w: %s", err, stderr.String()) + } + + return nil +} diff --git a/static/css/repos.css b/static/css/repos.css index 777fccf..81e5592 100644 --- a/static/css/repos.css +++ b/static/css/repos.css @@ -48,10 +48,21 @@ .download { text-align: end; + summary { + user-select: none; + -moz-user-select: none; + -ms-user-select: none; + } + + &[open] { + summary { + margin-bottom: 5px; + } + } + code { - margin: 2px; - padding: 2px; background-color: var(--bg-col); + user-select: all; } } } diff --git a/static/css/styles.css b/static/css/styles.css index 9d33a4e..8644e41 100644 --- a/static/css/styles.css +++ b/static/css/styles.css @@ -16,7 +16,7 @@ --text-col: rgb(202, 242, 255); --heading-font: "Archivo"; --text-font: "Archivo", sans-serif; - --gap: 1vh; + --gap: 8px; } @font-face { diff --git a/templating/views/layouts/repo.html b/templating/views/layouts/repo.html index a24dd19..4c62ccb 100644 --- a/templating/views/layouts/repo.html +++ b/templating/views/layouts/repo.html @@ -46,6 +46,7 @@
Code git clone ssh://{{.sshDomain}}:{{.sshPort}}{{RepoRoot .Meta}} + ZIP
-- 2.51.2