diff --git a/appview/repo/archive.go b/appview/repo/archive.go --- a/appview/repo/archive.go +++ b/appview/repo/archive.go @@ -2,16 +2,12 @@ import ( "fmt" + "io" "net/http" "net/url" "strings" - "tangled.org/core/api/tangled" - xrpcclient "tangled.org/core/appview/xrpcclient" - - indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" - "github.com/go-git/go-git/v5/plumbing" ) func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { @@ -29,22 +25,71 @@ scheme = "https" } host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } didSlashRepo := f.DidSlashRepo() - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) + + // build the xrpc url + u, err := url.Parse(host) + if err != nil { + l.Error("failed to parse host URL", "err", err) rp.pages.Error503(w) return } - // Set headers for file download, just pass along whatever the knot specifies - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) - w.Header().Set("Content-Type", "application/gzip") - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) - // Write the archive data directly - w.Write(archiveBytes) + + u.Path = "/xrpc/sh.tangled.repo.archive" + query := url.Values{} + query.Set("format", "tar.gz") + query.Set("prefix", r.URL.Query().Get("prefix")) + query.Set("ref", ref) + query.Set("repo", didSlashRepo) + u.RawQuery = query.Encode() + + xrpcURL := u.String() + + // make the get request + resp, err := http.Get(xrpcURL) + if err != nil { + l.Error("failed to call XRPC repo.archive", "err", err) + rp.pages.Error503(w) + return + } + + // pass through headers from upstream response + if contentDisposition := resp.Header.Get("Content-Disposition"); contentDisposition != "" { + w.Header().Set("Content-Disposition", contentDisposition) + } + if contentType := resp.Header.Get("Content-Type"); contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if contentLength := resp.Header.Get("Content-Length"); contentLength != "" { + w.Header().Set("Content-Length", contentLength) + } + if link := resp.Header.Get("Link"); link != "" { + if resolvedRef, err := extractImmutableLink(link); err == nil { + newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"", + rp.config.Core.AppviewHost, f.DidSlashRepo(), resolvedRef) + w.Header().Set("Link", newLink) + } + } + + // stream the archive data directly + if _, err := io.Copy(w, resp.Body); err != nil { + l.Error("failed to write response", "err", err) + } +} + +func extractImmutableLink(linkHeader string) (string, error) { + trimmed := strings.TrimPrefix(linkHeader, "<") + trimmed = strings.TrimSuffix(trimmed, ">; rel=\"immutable\"") + + parsedLink, err := url.Parse(trimmed) + if err != nil { + return "", err + } + + resolvedRef := parsedLink.Query().Get("ref") + if resolvedRef == "" { + return "", fmt.Errorf("no ref found in link header") + } + + return resolvedRef, nil } diff --git a/knotserver/git/git.go b/knotserver/git/git.go --- a/knotserver/git/git.go +++ b/knotserver/git/git.go @@ -76,6 +76,10 @@ return &g, nil } +func (g *GitRepo) Hash() plumbing.Hash { + return g.h +} + // re-open a repository and update references func (g *GitRepo) Refresh() error { refreshed, err := PlainOpen(g.path) diff --git a/knotserver/xrpc/repo_archive.go b/knotserver/xrpc/repo_archive.go --- a/knotserver/xrpc/repo_archive.go +++ b/knotserver/xrpc/repo_archive.go @@ -4,10 +4,12 @@ "compress/gzip" "fmt" "net/http" + "net/url" "strings" "github.com/go-git/go-git/v5/plumbing" + "tangled.org/core/api/tangled" "tangled.org/core/knotserver/git" xrpcerr "tangled.org/core/xrpc/errors" ) @@ -47,6 +49,18 @@ repoParts := strings.Split(repo, "/") repoName := repoParts[len(repoParts)-1] + immutableLink, err := x.buildImmutableLink(repo, format, gr.Hash().String(), prefix) + if err != nil { + x.Logger.Error( + "failed to build immutable link", + "err", err.Error(), + "repo", repo, + "format", format, + "ref", gr.Hash().String(), + "prefix", prefix, + ) + } + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") var archivePrefix string @@ -59,6 +73,7 @@ filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) w.Header().Set("Content-Type", "application/gzip") + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) gw := gzip.NewWriter(w) defer gw.Close() @@ -78,4 +93,24 @@ x.Logger.Error("flushing", "error", err.Error()) return } +} + +func (x *Xrpc) buildImmutableLink(repo string, format string, ref string, prefix string) (string, error) { + scheme := "https" + if x.Config.Server.Dev { + scheme = "http" + } + + u, err := url.Parse(scheme + "://" + x.Config.Server.Hostname + "/xrpc/" + tangled.RepoArchiveNSID) + if err != nil { + return "", err + } + + params := url.Values{} + params.Set("repo", repo) + params.Set("format", format) + params.Set("ref", ref) + params.Set("prefix", prefix) + + return fmt.Sprintf("%s?%s", u.String(), params.Encode()), nil }