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 214ccbdd 3db1b26e

verified
Changed files
+252 -151
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)
-97
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) UploadPack(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-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 - s.proxyRequest(w, r, targetURL) 43 - } 44 - 45 - func (s *State) ReceivePack(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-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 - s.proxyRequest(w, r, targetURL) 60 - } 61 - 62 - func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 63 - client := &http.Client{} 64 - 65 - // Create new request 66 - proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 67 - if err != nil { 68 - http.Error(w, err.Error(), http.StatusInternalServerError) 69 - return 70 - } 71 - 72 - // Copy original headers 73 - proxyReq.Header = r.Header 74 - 75 - repoOwnerHandle := chi.URLParam(r, "user") 76 - proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 77 - 78 - // Execute request 79 - resp, err := client.Do(proxyReq) 80 - if err != nil { 81 - http.Error(w, err.Error(), http.StatusInternalServerError) 82 - return 83 - } 84 - defer resp.Body.Close() 85 - 86 - // Copy response headers 87 - maps.Copy(w.Header(), resp.Header) 88 - 89 - // Set response status code 90 - w.WriteHeader(resp.StatusCode) 91 - 92 - // Copy response body 93 - if _, err := io.Copy(w, resp.Body); err != nil { 94 - http.Error(w, err.Error(), http.StatusInternalServerError) 95 - return 96 - } 97 - }
+168
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) UploadPack(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-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 48 + s.proxyRequest(w, r, targetURL) 49 + } 50 + 51 + func (s *State) ReceivePack(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-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 65 + s.proxyRequest(w, r, targetURL) 66 + } 67 + 68 + var knotVersionDownloadArchiveConstraint = version.MustConstraints(version.NewConstraint(">= 1.12.0-alpha")) 69 + 70 + func (s *State) DownloadArchive(w http.ResponseWriter, r *http.Request) { 71 + l := s.logger.With("handler", "DownloadArchive") 72 + ref := chi.URLParam(r, "ref") 73 + 74 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 75 + if !ok { 76 + l.Error("failed to resolve user") 77 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 78 + return 79 + } 80 + repo := r.Context().Value("repo").(*models.Repo) 81 + 82 + scheme := "https" 83 + if s.config.Core.Dev { 84 + scheme = "http" 85 + } 86 + 87 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 88 + xrpcc := &indigoxrpc.Client{ 89 + Host: host, 90 + } 91 + l = l.With("knot", repo.Knot) 92 + 93 + isCompatible := func() bool { 94 + out, err := tangled.KnotVersion(r.Context(), xrpcc) 95 + if err != nil { 96 + l.Warn("failed to get knot version", "err", err) 97 + return false 98 + } 99 + 100 + v, err := version.NewVersion(out.Version) 101 + if err != nil { 102 + l.Warn("failed to parse knot version", "version", out.Version, "err", err) 103 + return false 104 + } 105 + 106 + if !knotVersionDownloadArchiveConstraint.Check(v) { 107 + l.Warn("knot version incompatible.", "version", v) 108 + return false 109 + } 110 + return true 111 + }() 112 + l.Debug("knot compatibility check", "isCompatible", isCompatible) 113 + if isCompatible { 114 + targetURL := fmt.Sprintf("%s://%s/%s/%s/archive/%s", scheme, repo.Knot, user.DID, repo.Name, ref) 115 + s.proxyRequest(w, r, targetURL) 116 + } else { 117 + l.Debug("requesting xrpc/sh.tangled.repo.archive") 118 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo.DidSlashRepo()) 119 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 120 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 121 + s.pages.Error503(w) 122 + return 123 + } 124 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 125 + filename := fmt.Sprintf("%s-%s.tar.gz", repo.Name, safeRefFilename) 126 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 127 + w.Header().Set("Content-Type", "application/gzip") 128 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 129 + w.Write(archiveBytes) 130 + } 131 + } 132 + 133 + func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 134 + client := &http.Client{} 135 + 136 + // Create new request 137 + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 138 + if err != nil { 139 + http.Error(w, err.Error(), http.StatusInternalServerError) 140 + return 141 + } 142 + 143 + // Copy original headers 144 + proxyReq.Header = r.Header 145 + 146 + repoOwnerHandle := chi.URLParam(r, "user") 147 + proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 148 + 149 + // Execute request 150 + resp, err := client.Do(proxyReq) 151 + if err != nil { 152 + http.Error(w, err.Error(), http.StatusInternalServerError) 153 + return 154 + } 155 + defer resp.Body.Close() 156 + 157 + // Copy response headers 158 + maps.Copy(w.Header(), resp.Header) 159 + 160 + // Set response status code 161 + w.WriteHeader(resp.StatusCode) 162 + 163 + // Copy response body 164 + if _, err := io.Copy(w, resp.Body); err != nil { 165 + http.Error(w, err.Error(), http.StatusInternalServerError) 166 + return 167 + } 168 + }
+3 -1
appview/state/router.go
··· 103 103 r.Get("/info/refs", s.InfoRefs) 104 104 r.Post("/git-upload-pack", s.UploadPack) 105 105 r.Post("/git-receive-pack", s.ReceivePack) 106 - 106 + // intentionally doesn't use /* as this isn't 107 + // a file path 108 + r.Get("/archive/{ref}", s.DownloadArchive) 107 109 }) 108 110 }) 109 111
+1
go.mod
··· 132 132 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 133 133 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 134 134 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 135 + github.com/hashicorp/go-version v1.8.0 // indirect 135 136 github.com/hashicorp/golang-lru v1.0.2 // indirect 136 137 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 137 138 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
··· 84 84 r.Get("/info/refs", h.InfoRefs) 85 85 r.Post("/git-upload-pack", h.UploadPack) 86 86 r.Post("/git-receive-pack", h.ReceivePack) 87 + // convenience routes 88 + r.Get("/archive/{ref}", h.Archive) 87 89 }) 88 90 }) 89 91
+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="