Monorepo for Tangled tangled.org

appview,knotserver: support immutable nix flakeref link header

This will allow users to directly request archive from the knot without
using xrpc.
Xrpc doesn't fit here because it strips out the http headers which might
include valuable metadata like download filename or immutable link.

- implement archive on knot as `/{owner}/{repo}/archive/{ref}`
endpoint
- appview proxies the request to knot on `/archive` like it is doing for
git http endpoints.
if knot version isn't compatible, it will fallback to legacy xrpc
endpoint.
- rename the `git_http.go` file to generalized `proxy_knot.go` filaname

xrpc method `sh.tangled.repo.archive` will be deprecated in future

added `go-version` depenedency to make version constraints

Close: <https://tangled.org/tangled.org/core/issues/231>
Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me d8b6a8f5 233dd7f6

verified
Changed files
+269 -168
appview
knotserver
nix
-49
appview/repo/archive.go
··· 1 - package repo 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - "strings" 8 - 9 - "tangled.org/core/api/tangled" 10 - xrpcclient "tangled.org/core/appview/xrpcclient" 11 - 12 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - "github.com/go-chi/chi/v5" 14 - "github.com/go-git/go-git/v5/plumbing" 15 - ) 16 - 17 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 - l := rp.logger.With("handler", "DownloadArchive") 19 - ref := chi.URLParam(r, "ref") 20 - ref, _ = url.PathUnescape(ref) 21 - f, err := rp.repoResolver.Resolve(r) 22 - if err != nil { 23 - l.Error("failed to get repo and knot", "err", err) 24 - return 25 - } 26 - scheme := "http" 27 - if !rp.config.Core.Dev { 28 - scheme = "https" 29 - } 30 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 - xrpcc := &indigoxrpc.Client{ 32 - Host: host, 33 - } 34 - didSlashRepo := f.DidSlashRepo() 35 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 36 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 - rp.pages.Error503(w) 39 - return 40 - } 41 - // Set headers for file download, just pass along whatever the knot specifies 42 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 - w.Header().Set("Content-Type", "application/gzip") 46 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 - // Write the archive data directly 48 - w.Write(archiveBytes) 49 - }
-4
appview/repo/router.go
··· 40 40 r.Get("/blob/{ref}/*", rp.Blob) 41 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 42 43 - // intentionally doesn't use /* as this isn't 44 - // a file path 45 - r.Get("/archive/{ref}", rp.DownloadArchive) 46 - 47 43 r.Route("/fork", func(r chi.Router) { 48 44 r.Use(middleware.AuthMiddleware(rp.oauth)) 49 45 r.Get("/", rp.ForkRepo)
-114
appview/state/git_http.go
··· 1 - package state 2 - 3 - import ( 4 - "fmt" 5 - "io" 6 - "maps" 7 - "net/http" 8 - 9 - "github.com/bluesky-social/indigo/atproto/identity" 10 - "github.com/go-chi/chi/v5" 11 - "tangled.org/core/appview/models" 12 - ) 13 - 14 - func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 - user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*models.Repo) 17 - 18 - scheme := "https" 19 - if s.config.Core.Dev { 20 - scheme = "http" 21 - } 22 - 23 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 24 - s.proxyRequest(w, r, targetURL) 25 - 26 - } 27 - 28 - func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 - if !ok { 31 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 - return 33 - } 34 - repo := r.Context().Value("repo").(*models.Repo) 35 - 36 - scheme := "https" 37 - if s.config.Core.Dev { 38 - scheme = "http" 39 - } 40 - 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 - s.proxyRequest(w, r, targetURL) 43 - } 44 - 45 - func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 46 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 47 - if !ok { 48 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 - return 50 - } 51 - repo := r.Context().Value("repo").(*models.Repo) 52 - 53 - scheme := "https" 54 - if s.config.Core.Dev { 55 - scheme = "http" 56 - } 57 - 58 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 - s.proxyRequest(w, r, targetURL) 60 - } 61 - 62 - func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { 63 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 64 - if !ok { 65 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 66 - return 67 - } 68 - repo := r.Context().Value("repo").(*models.Repo) 69 - 70 - scheme := "https" 71 - if s.config.Core.Dev { 72 - scheme = "http" 73 - } 74 - 75 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 76 - s.proxyRequest(w, r, targetURL) 77 - } 78 - 79 - func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 80 - client := &http.Client{} 81 - 82 - // Create new request 83 - proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 84 - if err != nil { 85 - http.Error(w, err.Error(), http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - // Copy original headers 90 - proxyReq.Header = r.Header 91 - 92 - repoOwnerHandle := chi.URLParam(r, "user") 93 - proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 94 - 95 - // Execute request 96 - resp, err := client.Do(proxyReq) 97 - if err != nil { 98 - http.Error(w, err.Error(), http.StatusInternalServerError) 99 - return 100 - } 101 - defer resp.Body.Close() 102 - 103 - // Copy response headers 104 - maps.Copy(w.Header(), resp.Header) 105 - 106 - // Set response status code 107 - w.WriteHeader(resp.StatusCode) 108 - 109 - // Copy response body 110 - if _, err := io.Copy(w, resp.Body); err != nil { 111 - http.Error(w, err.Error(), http.StatusInternalServerError) 112 - return 113 - } 114 - }
+185
appview/state/proxy_knot.go
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "maps" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 + "github.com/go-chi/chi/v5" 13 + "github.com/go-git/go-git/v5/plumbing" 14 + "github.com/hashicorp/go-version" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/models" 17 + xrpcclient "tangled.org/core/appview/xrpcclient" 18 + ) 19 + 20 + func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 21 + user := r.Context().Value("resolvedId").(identity.Identity) 22 + repo := r.Context().Value("repo").(*models.Repo) 23 + 24 + scheme := "https" 25 + if s.config.Core.Dev { 26 + scheme = "http" 27 + } 28 + 29 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 30 + s.proxyRequest(w, r, targetURL) 31 + 32 + } 33 + 34 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 35 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 36 + if !ok { 37 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 38 + return 39 + } 40 + repo := r.Context().Value("repo").(*models.Repo) 41 + 42 + scheme := "https" 43 + if s.config.Core.Dev { 44 + scheme = "http" 45 + } 46 + 47 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 48 + s.proxyRequest(w, r, targetURL) 49 + } 50 + 51 + func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 52 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 53 + if !ok { 54 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 55 + return 56 + } 57 + repo := r.Context().Value("repo").(*models.Repo) 58 + 59 + scheme := "https" 60 + if s.config.Core.Dev { 61 + scheme = "http" 62 + } 63 + 64 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 65 + s.proxyRequest(w, r, targetURL) 66 + } 67 + 68 + func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { 69 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 70 + if !ok { 71 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 72 + return 73 + } 74 + repo := r.Context().Value("repo").(*models.Repo) 75 + 76 + scheme := "https" 77 + if s.config.Core.Dev { 78 + scheme = "http" 79 + } 80 + 81 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 82 + s.proxyRequest(w, r, targetURL) 83 + } 84 + 85 + var knotVersionDownloadArchiveConstraint = version.MustConstraints(version.NewConstraint(">= 1.12.0-alpha")) 86 + 87 + func (s *State) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + l := s.logger.With("handler", "DownloadArchive") 89 + ref := chi.URLParam(r, "ref") 90 + 91 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 92 + if !ok { 93 + l.Error("failed to resolve user") 94 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 95 + return 96 + } 97 + repo := r.Context().Value("repo").(*models.Repo) 98 + 99 + scheme := "https" 100 + if s.config.Core.Dev { 101 + scheme = "http" 102 + } 103 + 104 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 105 + xrpcc := &indigoxrpc.Client{ 106 + Host: host, 107 + } 108 + l = l.With("knot", repo.Knot) 109 + 110 + isCompatible := func() bool { 111 + out, err := tangled.KnotVersion(r.Context(), xrpcc) 112 + if err != nil { 113 + l.Warn("failed to get knot version", "err", err) 114 + return false 115 + } 116 + 117 + v, err := version.NewVersion(out.Version) 118 + if err != nil { 119 + l.Warn("failed to parse knot version", "version", out.Version, "err", err) 120 + return false 121 + } 122 + 123 + if !knotVersionDownloadArchiveConstraint.Check(v) { 124 + l.Warn("knot version incompatible.", "version", v) 125 + return false 126 + } 127 + return true 128 + }() 129 + l.Debug("knot compatibility check", "isCompatible", isCompatible) 130 + if isCompatible { 131 + targetURL := fmt.Sprintf("%s://%s/%s/%s/archive/%s", scheme, repo.Knot, user.DID, repo.Name, ref) 132 + s.proxyRequest(w, r, targetURL) 133 + } else { 134 + l.Debug("requesting xrpc/sh.tangled.repo.archive") 135 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo.DidSlashRepo()) 136 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 137 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 138 + s.pages.Error503(w) 139 + return 140 + } 141 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 142 + filename := fmt.Sprintf("%s-%s.tar.gz", repo.Name, safeRefFilename) 143 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 144 + w.Header().Set("Content-Type", "application/gzip") 145 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 146 + w.Write(archiveBytes) 147 + } 148 + } 149 + 150 + func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 151 + client := &http.Client{} 152 + 153 + // Create new request 154 + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 155 + if err != nil { 156 + http.Error(w, err.Error(), http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + // Copy original headers 161 + proxyReq.Header = r.Header 162 + 163 + repoOwnerHandle := chi.URLParam(r, "user") 164 + proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 165 + 166 + // Execute request 167 + resp, err := client.Do(proxyReq) 168 + if err != nil { 169 + http.Error(w, err.Error(), http.StatusInternalServerError) 170 + return 171 + } 172 + defer resp.Body.Close() 173 + 174 + // Copy response headers 175 + maps.Copy(w.Header(), resp.Header) 176 + 177 + // Set response status code 178 + w.WriteHeader(resp.StatusCode) 179 + 180 + // Copy response body 181 + if _, err := io.Copy(w, resp.Body); err != nil { 182 + http.Error(w, err.Error(), http.StatusInternalServerError) 183 + return 184 + } 185 + }
+3 -1
appview/state/router.go
··· 104 104 r.Post("/git-upload-archive", s.UploadArchive) 105 105 r.Post("/git-upload-pack", s.UploadPack) 106 106 r.Post("/git-receive-pack", s.ReceivePack) 107 - 107 + // intentionally doesn't use /* as this isn't 108 + // a file path 109 + r.Get("/archive/{ref}", s.DownloadArchive) 108 110 }) 109 111 }) 110 112
+1
go.mod
··· 131 131 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 132 132 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 133 133 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 134 + github.com/hashicorp/go-version v1.8.0 // indirect 134 135 github.com/hashicorp/golang-lru v1.0.2 // indirect 135 136 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 136 137 github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
+2
go.sum
··· 264 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 267 269 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 268 270 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 269 271 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+69
knotserver/archive.go
··· 1 + package knotserver 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "github.com/go-chi/chi/v5" 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "tangled.org/core/knotserver/git" 13 + ) 14 + 15 + func (h *Knot) Archive(w http.ResponseWriter, r *http.Request) { 16 + var ( 17 + did = chi.URLParam(r, "did") 18 + name = chi.URLParam(r, "name") 19 + ref = chi.URLParam(r, "ref") 20 + ) 21 + repo, err := securejoin.SecureJoin(did, name) 22 + if err != nil { 23 + gitError(w, "repository not found", http.StatusNotFound) 24 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 25 + return 26 + } 27 + 28 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repo) 29 + if err != nil { 30 + gitError(w, "repository not found", http.StatusNotFound) 31 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 32 + return 33 + } 34 + 35 + gr, err := git.Open(repoPath, ref) 36 + 37 + immutableLink := fmt.Sprintf( 38 + "https://%s/%s/%s/archive/%s", 39 + h.c.Server.Hostname, 40 + did, 41 + name, 42 + gr.Hash(), 43 + ) 44 + 45 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 46 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 47 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 48 + w.Header().Set("Content-Type", "application/gzip") 49 + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 50 + 51 + gw := gzip.NewWriter(w) 52 + defer gw.Close() 53 + 54 + err = gr.WriteTar(gw, "") 55 + if err != nil { 56 + // once we start writing to the body we can't report error anymore 57 + // so we are only left with logging the error 58 + h.l.Error("writing tar file", "error", err) 59 + return 60 + } 61 + 62 + err = gw.Flush() 63 + if err != nil { 64 + // once we start writing to the body we can't report error anymore 65 + // so we are only left with logging the error 66 + h.l.Error("flushing", "error", err.Error()) 67 + return 68 + } 69 + }
+4
knotserver/git/git.go
··· 76 76 return &g, nil 77 77 } 78 78 79 + func (g *GitRepo) Hash() plumbing.Hash { 80 + return g.h 81 + } 82 + 79 83 // re-open a repository and update references 80 84 func (g *GitRepo) Refresh() error { 81 85 refreshed, err := PlainOpen(g.path)
+2
knotserver/router.go
··· 85 85 r.Post("/git-upload-archive", h.UploadArchive) 86 86 r.Post("/git-upload-pack", h.UploadPack) 87 87 r.Post("/git-receive-pack", h.ReceivePack) 88 + // convenience routes 89 + r.Get("/archive/{ref}", h.Archive) 88 90 }) 89 91 }) 90 92
+3
nix/gomod2nix.toml
··· 304 304 [mod."github.com/hashicorp/go-sockaddr"] 305 305 version = "v1.0.7" 306 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 + [mod."github.com/hashicorp/go-version"] 308 + version = "v1.8.0" 309 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 307 310 [mod."github.com/hashicorp/golang-lru"] 308 311 version = "v1.0.2" 309 312 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="