Gitfed
bastien-mrq/gitfed/ Commits/ 43cbc04

Add branch/tag archive download (.tar.gz / .zip)

New gitexec.Archive (git archive, hardened with --end-of-options like every other ref-accepting git call in this codebase), admin.Ops GetRepoArchive, threaded through adminrpc with its own longer timeout. ?ref= is validated against the repo's real branches/tags before it reaches git — an unknown value 404s, same treatment as the branch dropdown's ?branch=. Capped at 100MB: the admin RPC transport buffers the whole response as base64 JSON (no streaming), and a truncated tar/zip is corrupt, so past the cap it's a hard refusal, not a partial file the way CommitDiff can truncate a text patch. UI: a "Download" dropdown next to the branch picker, and a download link on each tag badge.

bastien-mrq 2026-07-30 13:34 commit 43cbc04d817466312ce562392769bd0c412a6510 parent e0d5fa62fcf2d7c1086e5735cc107f7699c1ceca
13 files changed +236 −4
M CHANGELOG.md +4 −0
M ROADMAP.md +0 −2
M cmd/gitfed-web/handlers_repo.go +73 −1
M cmd/gitfed-web/render.go +3 −1
M cmd/gitfed-web/routes.go +1 −0
M internal/admin/admin.go +12 −0
M internal/adminrpc/client.go +11 −0
M internal/adminrpc/protocol.go +15 −0
M internal/adminrpc/server.go +8 −0
M internal/gitexec/gitexec.go +48 −0
M internal/gitexec/gitexec_test.go +59 −0
M internal/i18n/strings_en.go +1 −0
M internal/i18n/strings_fr.go +1 −0
CHANGELOG.md
diff --git a/CHANGELOG.md b/CHANGELOG.md index 76374ab..22adcf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ A bullet starting with `**BREAKING:**` flags a change gitfed-ctl's update wizard makes you acknowledge individually before it will let you upgrade past that version. +## 1.2.21 + +- Download a branch or tag as `.tar.gz`/`.zip` (`git archive` under the hood) — a "Download" dropdown next to the branch picker for the current branch, and a download link on each tag. `?ref=` is validated against the repo's real branches/tags before reaching git, same "unknown value 404s" treatment as the branch dropdown. New `GetRepoArchive` on `admin.Ops`; capped at 100MB since the admin RPC transport buffers the whole response as base64 JSON rather than streaming — a truncated archive would just be a corrupt file, so it's a hard refusal past the cap, not a partial result. + ## 1.2.20 - Repos can now have a short description (Settings → Visibility & topics, 200 chars), shown on the repo page under the title and matched by `/search` in addition to name and topics — the roadmap assumed this field already existed; it didn't, so this adds `store.Repo.Description` end to end (new `SetRepoDescription` on `admin.Ops`, threaded through `adminrpc`).
ROADMAP.md
diff --git a/ROADMAP.md b/ROADMAP.md index 23201c9..91ce385 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -119,8 +119,6 @@ celui-là la prochaine fois qu'on rouvre ce document. ### Organisation à plus grande échelle -- **Export/archive** d'une branche ou d'un tag en `.tar.gz`/`.zip` - via `git archive`. *(effort faible)*. - **ACL par équipe/organisation**, en plus de l'ACL par personne actuelle. *(effort élevé — nouveau modèle de données, faible valeur tant que l'instance reste utilisée par peu de monde)*.
cmd/gitfed-web/handlers_repo.go
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index d4b7537..c39d5ca 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -5,6 +5,7 @@ import ( "html/template" "net/http" "net/url" + "strconv" "strings" "git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec" @@ -138,6 +139,15 @@ var repoTpl = newTpl("repo", ` </div> </details> {{end}} + {{if not .Empty}} + <details class="gf-branch-dropdown"> + <summary class="gf-btn">{{icon "download"}} {{t .Lang "repo.download"}}</summary> + <div class="gf-branch-menu"> + <a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=tar.gz">{{.Branch}}.tar.gz</a> + <a href="/repo-archive/{{.Repo.Name}}?ref={{.Branch}}&format=zip">{{.Branch}}.zip</a> + </div> + </details> + {{end}} {{if not .Empty}}<a href="/repo-commits/{{.Repo.Name}}" class="gf-btn">{{icon "history"}} {{t .Lang "repo.commits_title"}}{{if .CommitCount}} ({{.CommitCount}}){{end}}</a>{{end}} {{if not .Empty}}<a href="/repo-mrs/{{.Repo.Name}}" class="gf-btn">{{icon "branch"}} {{t .Lang "mr.list_title"}}</a>{{end}} {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">{{t .Lang "nav.settings"}}</a>{{end}} @@ -198,7 +208,7 @@ var repoTpl = newTpl("repo", ` {{end}} {{if .Tags}} -<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section> +<section><h3>{{t .Lang "repo.tags"}}</h3>{{range .Tags}}<a href="/repo-archive/{{$.Repo.Name}}?ref={{.}}&format=tar.gz" class="badge plain" title="{{t $.Lang "repo.download"}}">{{icon "download"}} {{.}}</a> {{end}}</section> {{end}} {{if .ReadmeHTML}} @@ -452,6 +462,68 @@ func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) { s.render(w, r, path+" — "+name, "home", template.HTML(buf.String())) } +// handleRepoArchive streams a tar.gz/zip snapshot of a branch or tag — +// ?ref= is validated against the repo's real branches and tags before it +// ever reaches a git subprocess (see gitexec.Archive's contract), same +// "unknown value 404s, no silent fallback" treatment as the branch +// dropdown's ?branch=. +func (s *server) handleRepoArchive(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("repo") + + repo, err := s.ops.GetRepo(name) + if err != nil || !s.canView(r, repo) { + http.NotFound(w, r) + return + } + + format := r.URL.Query().Get("format") + if format != "tar.gz" && format != "zip" { + http.NotFound(w, r) + return + } + + ref := r.URL.Query().Get("ref") + if ref == "" { + http.NotFound(w, r) + return + } + branches, err := s.ops.ListBranches(name) + if err != nil { + s.serverError(w, r, err) + return + } + tags, err := s.ops.ListRepoTags(name) + if err != nil { + s.serverError(w, r, err) + return + } + if !containsBranch(branches, ref) && !containsBranch(tags, ref) { + http.NotFound(w, r) + return + } + + data, err := s.ops.GetRepoArchive(name, ref, format) + if err != nil { + s.serverError(w, r, err) + return + } + + contentType := "application/gzip" + if format == "zip" { + contentType = "application/zip" + } + // Slashes in the owner/repo name or in a branch like "feature/x" are + // cosmetic-only here — purely for the suggested filename, not passed + // to git (that already happened, safely, above). + base := strings.ReplaceAll(name, "/", "-") + "-" + strings.ReplaceAll(ref, "/", "-") + filename := base + "." + format + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`) + w.Header().Set("Content-Length", strconv.Itoa(len(data))) + _, _ = w.Write(data) +} + func renderFileContent(repo, dir, filename, content string) (template.HTML, error) { if strings.HasSuffix(strings.ToLower(filename), ".md") || strings.HasSuffix(strings.ToLower(filename), ".markdown") { return renderRepoMarkdown(repo, dir, content)
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 8fafaf7..d7ad3db 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -164,6 +164,7 @@ const iconSprite = `<svg width="0" height="0" style="position:absolute" aria-hid <symbol id="ic-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 3v2.3M12 18.7V21M21 12h-2.3M5.3 12H3M18.4 5.6l-1.6 1.6M7.2 16.8l-1.6 1.6M18.4 18.4l-1.6-1.6M7.2 7.2 5.6 5.6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol> <symbol id="ic-moon" viewBox="0 0 24 24"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></symbol> <symbol id="ic-auto" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M12 4a8 8 0 0 1 0 16Z" fill="currentColor"/></symbol> +<symbol id="ic-download" viewBox="0 0 24 24"><path d="M12 4v11M7.5 11.5 12 16l4.5-4.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/><path d="M5 18.5h14" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></symbol> </defs> </svg>` @@ -694,10 +695,11 @@ const shellHeadSrc = `<!doctype html> .msg.ok { background: var(--ok-bg); color: var(--ok-fg); } .msg.err { background: var(--danger-bg); color: var(--danger-fg); } .msg.warn { background: var(--pending-bg); color: var(--pending-fg); } - .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); } + .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); text-decoration: none; } .badge.pending { background: var(--pending-bg); color: var(--pending-fg); } .badge.trusted { background: var(--ok-bg); color: var(--ok-fg); } .badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; } + a.badge.plain:hover { background: var(--surface-3); color: var(--text); border-color: var(--border-strong); } .badge.danger { background: var(--danger-bg); color: var(--danger-fg); } .muted { color: var(--text-dim); font-size: 0.85rem; }
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 851805d..13e8b85 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -7,6 +7,7 @@ func (s *server) routes(mux *http.ServeMux) { mux.HandleFunc("GET /{$}", s.handleLanding) mux.HandleFunc("GET /r/{repo...}", s.handleRepoView) mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob) + mux.HandleFunc("GET /repo-archive/{repo...}", s.handleRepoArchive) mux.HandleFunc("GET /repo-commits/{repo...}", s.handleRepoCommits) mux.HandleFunc("GET /repo-commit/{repo...}", s.handleRepoCommitDetail) mux.HandleFunc("GET /repo-mrs/{repo...}", s.handleMRList)
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 2cc19f0..634b8c8 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -56,6 +56,7 @@ type Ops interface { GetRepoReadme(name string) (content string, found bool, err error) GetRepoLicense(name string) (content, filename string, found bool, err error) ListRepoTags(name string) ([]string, error) + GetRepoArchive(name, ref, format string) (data []byte, err error) ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error) ListRepoTreeAtRef(name, ref, path string) (entries []gitexec.TreeEntry, found bool, err error) GetRepoFile(name, path string) (content string, found bool, err error) @@ -414,6 +415,17 @@ func (a *Admin) ListRepoTags(name string) ([]string, error) { return gitexec.ListTags(repo.Path) } +// GetRepoArchive returns a tar.gz/zip snapshot of ref (a branch or tag). +// The caller must have already validated ref against the repo's real +// branches/tags (see gitexec.Archive's contract). +func (a *Admin) GetRepoArchive(name, ref, format string) ([]byte, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return nil, err + } + return gitexec.Archive(repo.Path, ref, format) +} + func (a *Admin) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, error) { repo, err := a.Store.GetRepo(name) if err != nil {
internal/adminrpc/client.go
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index 60d9713..1d81e60 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -126,6 +126,17 @@ func (c *Client) ImportRepo(name, sourceURL, ownerUsername string) error { return c.callWithTimeout(methodImportRepo, importRepoArgs{Name: name, SourceURL: sourceURL, Owner: ownerUsername}, nil, importRPCTimeout) } +// archiveRPCTimeout gives git archive (a local, CPU-bound tar/zip of +// however much history+content ref pulls in, up to archiveMaxBytes) more +// headroom than the default 5s every other, near-instant admin call gets. +const archiveRPCTimeout = 60 * time.Second + +func (c *Client) GetRepoArchive(name, ref, format string) ([]byte, error) { + var out archiveResult + err := c.callWithTimeout(methodGetRepoArchive, archiveArgs{Name: name, Ref: ref, Format: format}, &out, archiveRPCTimeout) + return out.Data, err +} + func (c *Client) DeleteRepo(name string) error { err := c.call(methodDeleteRepo, nameArgs{Name: name}, nil) return err
internal/adminrpc/protocol.go
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 0becf84..c8788fb 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -37,6 +37,7 @@ const ( methodGetRepoReadme = "GetRepoReadme" methodGetRepoLicense = "GetRepoLicense" methodListRepoTags = "ListRepoTags" + methodGetRepoArchive = "GetRepoArchive" methodListRepoTree = "ListRepoTree" methodListRepoTreeAtRef = "ListRepoTreeAtRef" methodGetRepoFile = "GetRepoFile" @@ -154,6 +155,20 @@ type setDescriptionArgs struct { Description string `json:"description"` } +type archiveArgs struct { + Name string `json:"name"` + Ref string `json:"ref"` + Format string `json:"format"` +} + +// archiveResult's Data is base64-encoded automatically by encoding/json +// (the standard behavior for a []byte field) — see gitexec.go's +// archiveMaxBytes comment for why this stays a single buffered response +// rather than a streamed one. +type archiveResult struct { + Data []byte `json:"data"` +} + type readmeResult struct { Content string `json:"content"` Found bool `json:"found"`
internal/adminrpc/server.go
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 58e3b93..1ca7725 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -219,6 +219,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) { tags, err := s.ops.ListRepoTags(a.Name) return listTagsResult{Tags: tags}, err + case methodGetRepoArchive: + var a archiveArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + data, err := s.ops.GetRepoArchive(a.Name, a.Ref, a.Format) + return archiveResult{Data: data}, err + case methodListRepoTree: var a pathArgs if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 9b9c731..6165c9c 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -348,6 +348,54 @@ func ListTags(repoPath string) ([]string, error) { return tags, nil } +// archiveMaxBytes caps how large an archive Archive will return. Needed +// because — unlike a normal git subprocess call in this file — the result +// crosses the admin RPC boundary as a single buffered, base64-encoded JSON +// response (internal/adminrpc has no streaming transport; see +// internal/adminrpc/client.go's call()), so an unbounded archive would mean +// an unbounded in-memory buffer on both ends. Refusing outright past this +// size is deliberate: a truncated tar/zip is corrupt and useless, so there +// is no sensible partial result the way CommitDiff can return a truncated +// patch. +const archiveMaxBytes = 100 * 1024 * 1024 + +// Archive returns a tar.gz or zip snapshot of ref (a branch or tag name — +// the caller is responsible for ref being real, same contract as +// ListTreeAtRef/ReadFileAtRef) via `git archive`, hardened with +// --end-of-options for the same reason as everywhere else a ref reaches a +// git subprocess. +func Archive(repoPath, ref, format string) ([]byte, error) { + if format != "tar.gz" && format != "zip" { + return nil, fmt.Errorf("gitexec: unsupported archive format %q", format) + } + + cmd := exec.Command("git", "--git-dir="+repoPath, "archive", "--format="+format, "--end-of-options", ref) + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return nil, err + } + + data, readErr := io.ReadAll(io.LimitReader(stdout, archiveMaxBytes+1)) + if readErr != nil { + _ = cmd.Wait() + return nil, fmt.Errorf("gitexec: archive %s: %w", ref, readErr) + } + if len(data) > archiveMaxBytes { + _ = cmd.Process.Kill() + _ = cmd.Wait() + return nil, fmt.Errorf("gitexec: archive for %s exceeds %d bytes, refusing", ref, archiveMaxBytes) + } + if err := cmd.Wait(); err != nil { + return nil, fmt.Errorf("gitexec: archive %s: %w: %s", ref, err, stderr.String()) + } + return data, nil +} + // Commit is one entry in a repo's history, as shown by ListCommits. type Commit struct { Hash string `json:"hash"`
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 5498311..adac9ac 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -362,6 +362,65 @@ func TestListTreeAtRefAndReadFileAtRef(t *testing.T) { } } +func TestArchive(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(work, "hello.txt"), []byte("hi\n"), 0644); err != nil { + t.Fatal(err) + } + run(t, work, "git", "add", "hello.txt") + run(t, work, "git", "commit", "-q", "-m", "initial") + run(t, work, "git", "tag", "v1.0.0") + run(t, work, "git", "remote", "add", "origin", barePath) + run(t, work, "git", "push", "-q", "origin", "main", "--tags") + + for _, format := range []string{"tar.gz", "zip"} { + t.Run(format, func(t *testing.T) { + for _, ref := range []string{"main", "v1.0.0"} { + data, err := Archive(barePath, ref, format) + if err != nil { + t.Fatalf("Archive(%s, %s): %v", ref, format, err) + } + if len(data) == 0 { + t.Fatalf("Archive(%s, %s) returned no data", ref, format) + } + // Real magic-byte check, not just "non-empty" — gzip + // starts 0x1f 0x8b, zip starts "PK". + switch format { + case "tar.gz": + if len(data) < 2 || data[0] != 0x1f || data[1] != 0x8b { + t.Errorf("Archive(%s, tar.gz) doesn't look like gzip: % x", ref, data[:min(4, len(data))]) + } + case "zip": + if len(data) < 2 || data[0] != 'P' || data[1] != 'K' { + t.Errorf("Archive(%s, zip) doesn't look like a zip: % x", ref, data[:min(4, len(data))]) + } + } + } + }) + } +} + +func TestArchiveUnsupportedFormat(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + if _, err := Archive(barePath, "main", "tar.bz2"); err == nil { + t.Fatal("expected an error for an unsupported format") + } +} + func TestDefaultBranchNameViaHEAD(t *testing.T) { tmp := t.TempDir() barePath := filepath.Join(tmp, "repo.git")
internal/i18n/strings_en.go
diff --git a/internal/i18n/strings_en.go b/internal/i18n/strings_en.go index f5d3d37..0cefbb0 100644 --- a/internal/i18n/strings_en.go +++ b/internal/i18n/strings_en.go @@ -283,6 +283,7 @@ var en = map[string]string{ "repo.file": "file", "repo.nothing_here": "Nothing here.", "repo.tags": "Tags", + "repo.download": "Download", "repo.commits_title": "Commits", "repo.commits_empty": "No commits yet.", "repo.commit_label": "commit",
internal/i18n/strings_fr.go
diff --git a/internal/i18n/strings_fr.go b/internal/i18n/strings_fr.go index c2d0530..fefcba1 100644 --- a/internal/i18n/strings_fr.go +++ b/internal/i18n/strings_fr.go @@ -283,6 +283,7 @@ var fr = map[string]string{ "repo.file": "fichier", "repo.nothing_here": "Rien ici.", "repo.tags": "Tags", + "repo.download": "Télécharger", "repo.commits_title": "Commits", "repo.commits_empty": "Aucun commit pour le moment.", "repo.commit_label": "commit",