Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

appview: zip download for repo

Implementation of [issue #581](https://tangled.org/tangled.org/core/issues/581). Adds a `zip` download when downloading a repo. Defaults to zip for windows, and `tar.gz` for non-windows with a dropdown to choose the preferred format.

Signed-off-by: Smit Patil <smit@smit.codes>

+153 -53
+1 -1
appview/pages/templates/repo/fragments/artifactList.html
··· 13 13 <div id="artifact-git-source" class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 14 14 <div id="left-side" class="flex items-center gap-2 min-w-0 max-w-[60%]"> 15 15 {{ i "archive" "w-4 h-4" }} 16 - <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}" class="no-underline hover:no-underline"> 16 + <a href="/{{ $root.RepoInfo.FullName }}/archive/{{ pathEscape (print "refs/tags/" $tag.Name) }}?format=tar.gz" class="no-underline hover:no-underline"> 17 17 Source code (.tar.gz) 18 18 </a> 19 19 </div>
+19 -7
appview/pages/templates/repo/fragments/cloneDropdown.html
··· 53 53 For self-hosted knots, clone URLs may differ based on your setup. 54 54 </p> 55 55 56 - <a 57 - href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}" 58 - class="flex items-center btn-create w-full mt-4 gap-2 text-sm hover:no-underline hover:text-gray-100" 59 - > 60 - {{ i "download" "w-4 h-4" }} 61 - Download tar.gz 62 - </a> 56 + <br/> 57 + <hr/> 58 + 59 + <div class="flex gap-2 mt-4"> 60 + <a 61 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}?format=tar.gz" 62 + class="btn flex-1" 63 + > 64 + {{ i "download" "w-4 h-4" }} 65 + Download tar.gz 66 + </a> 67 + <a 68 + href="/{{ .RepoInfo.FullName }}/archive/{{ .Ref | urlquery }}?format=zip" 69 + class="btn flex-1" 70 + > 71 + {{ i "download" "w-4 h-4" }} 72 + Download .zip 73 + </a> 74 + </div> 63 75 </div> 64 76 65 77 <style>
+44 -7
appview/repo/archive.go
··· 15 15 l := rp.logger.With("handler", "DownloadArchive") 16 16 ref := chi.URLParam(r, "ref") 17 17 ref, _ = url.PathUnescape(ref) 18 - ref = strings.TrimSuffix(ref, ".tar.gz") 18 + format := r.URL.Query().Get("format") 19 + ref, format = archiveRefAndFormat(ref, format, r.UserAgent()) 19 20 f, err := rp.repoResolver.Resolve(r) 20 21 if err != nil { 21 22 l.Error("failed to get repo and knot", "err", err) ··· 26 27 query := url.Values{} 27 28 query.Set("repo", f.RepoDid) 28 29 query.Set("ref", ref) 29 - query.Set("format", "tar.gz") 30 + query.Set("format", format) 30 31 query.Set("prefix", r.URL.Query().Get("prefix")) 31 32 xrpcURL := fmt.Sprintf( 32 33 "%s/xrpc/%s?%s", ··· 44 45 } 45 46 defer resp.Body.Close() 46 47 47 - // force application/gzip here 48 - w.Header().Set("Content-Type", "application/gzip") 48 + w.Header().Set("Content-Type", archiveContentType(format)) 49 49 50 50 filename := "" 51 51 if cd := resp.Header.Get("Content-Disposition"); strings.HasPrefix(cd, "attachment;") { 52 52 filename = cd // knot has already set the attachment CD 53 53 } 54 54 if filename == "" { 55 - filename = fmt.Sprintf("attachment; filename=\"%s-%s.tar.gz\"", f.Name, ref) 55 + filename = fmt.Sprintf("attachment; filename=\"%s-%s.%s\"", f.Name, ref, format) 56 56 } 57 57 w.Header().Set("Content-Disposition", filename) 58 58 w.Header().Set("X-Content-Type-Options", "nosniff") 59 59 60 60 if link := resp.Header.Get("Link"); link != "" { 61 61 if resolvedRef, err := extractImmutableLink(link); err == nil { 62 - newLink := fmt.Sprintf("<%s/%s/archive/%s.tar.gz>; rel=\"immutable\"", 63 - rp.config.Core.BaseUrl(), f.RepoIdentifier(), resolvedRef) 62 + newLink := fmt.Sprintf("<%s/%s/archive/%s.%s>; rel=\"immutable\"", 63 + rp.config.Core.BaseUrl(), f.RepoIdentifier(), resolvedRef, format) 64 64 w.Header().Set("Link", newLink) 65 65 } 66 66 } ··· 69 69 if _, err := io.Copy(w, resp.Body); err != nil { 70 70 l.Error("failed to write response", "err", err) 71 71 } 72 + } 73 + 74 + func archiveRefAndFormat(ref string, requestedFormat string, userAgent string) (string, string) { 75 + switch { 76 + case strings.HasSuffix(ref, ".tar.gz"): 77 + ref = strings.TrimSuffix(ref, ".tar.gz") 78 + if requestedFormat == "" { 79 + requestedFormat = "tar.gz" 80 + } 81 + case strings.HasSuffix(ref, ".zip"): 82 + ref = strings.TrimSuffix(ref, ".zip") 83 + if requestedFormat == "" { 84 + requestedFormat = "zip" 85 + } 86 + } 87 + 88 + switch requestedFormat { 89 + case "zip", "tar.gz": 90 + return ref, requestedFormat 91 + default: 92 + if prefersZipArchive(userAgent) { 93 + return ref, "zip" 94 + } 95 + return ref, "tar.gz" 96 + } 97 + } 98 + 99 + func prefersZipArchive(userAgent string) bool { 100 + ua := strings.ToLower(userAgent) 101 + return strings.Contains(ua, "windows") || strings.Contains(ua, "win64") || strings.Contains(ua, "win32") 102 + } 103 + 104 + func archiveContentType(format string) string { 105 + if format == "zip" { 106 + return "application/zip" 107 + } 108 + return "application/gzip" 72 109 } 73 110 74 111 func extractImmutableLink(linkHeader string) (string, error) {
+4 -3
input.css
··· 201 201 @layer components { 202 202 .btn { 203 203 @apply relative z-10 inline-flex overflow-hidden items-center justify-center 204 - min-h-[32px] px-2 py-[6px] 204 + min-h-[32px] px-2 py-[6px] gap-1.5 205 205 rounded border border-gray-200 dark:border-gray-700 206 206 bg-white dark:bg-gray-800 207 207 text-sm text-gray-900 dark:text-gray-100 208 208 transition-colors duration-150 ease-in-out 209 209 outline-transparent cursor-pointer 210 - focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-400 dark:focus-visible:outline-gray-600; 210 + focus-visible:outline focus-visible:outline-2 focus-visible:outline-gray-400 dark:focus-visible:outline-gray-600 211 + no-underline hover:no-underline; 211 212 212 213 @apply before:absolute before:inset-0 before:-z-10 before:block before:rounded-sm before:content-[''] 213 214 before:transition-all before:duration-150 before:ease-in-out ··· 270 271 max-h-[32px] 271 272 rounded border border-gray-200 dark:border-gray-700 272 273 divide-x divide-gray-200 dark:divide-gray-700 273 - overflow-hidden; 274 + overflow-clip; 274 275 } 275 276 276 277 .btn-group-item {
+51 -19
knotmirror/xrpc/git_get_archive.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "bytes" 5 + "context" 4 6 "fmt" 7 + "io" 5 8 "net/http" 6 9 "net/url" 7 10 "os/exec" ··· 29 32 return 30 33 } 31 34 32 - if format != "tar.gz" { 33 - writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "only tar.gz format is supported"}) 34 - return 35 - } 36 35 if format == "" { 37 36 format = "tar.gz" 38 37 } 38 + if format != "tar.gz" && format != "zip" { 39 + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "only tar.gz and zip formats are supported"}) 40 + return 41 + } 39 42 40 43 l := x.logger.With("repo", repo, "ref", ref, "format", format, "prefix", prefix) 41 44 l.Debug("request") ··· 57 60 rev = "HEAD" 58 61 } 59 62 commit, err := gitea.GetCommit(ctx, repoPath, rev) 63 + if err != nil { 64 + l.Warn("local mirror failed, trying proxy", "err", err) 65 + if x.proxyToKnot(w, r, repo) { 66 + return 67 + } 68 + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to resolve ref"}) 69 + return 70 + } 60 71 61 72 repoName, err := func() (string, error) { 62 73 r, err := db.GetRepoByRepoDid(ctx, x.db, repo) ··· 78 89 } 79 90 80 91 safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 92 + if safeRefFilename == "" { 93 + safeRefFilename = commit.Hash.String() 94 + } 81 95 immutableLink := func() string { 82 96 params := url.Values{} 83 97 params.Set("repo", repo.String()) ··· 87 101 return fmt.Sprintf("%s/xrpc/%s?%s", x.cfg.BaseUrl(), tangled.GitTempGetArchiveNSID, params.Encode()) 88 102 }() 89 103 90 - filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 104 + var archivePrefix string 105 + if prefix != "" { 106 + archivePrefix = prefix 107 + } else { 108 + archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 109 + } 110 + 111 + filename := fmt.Sprintf("%s-%s.%s", repoName, safeRefFilename, format) 91 112 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 92 - w.Header().Set("Content-Type", "application/gzip") 113 + w.Header().Set("Content-Type", archiveContentType(format)) 93 114 w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 94 115 95 - cmd := exec.Command( 96 - "git", 97 - "archive", 98 - fmt.Sprintf("--prefix=%s", prefix), 99 - "--format=tar.gz", 100 - commit.Hash.String(), 101 - ) 116 + if err := writeLocalArchive(ctx, w, repoPath, commit.Hash.String(), format, archivePrefix); err != nil { 117 + l.Error("writing archive", "err", err.Error(), "format", format) 118 + w.WriteHeader(http.StatusInternalServerError) 119 + } 120 + } 121 + 122 + func archiveContentType(format string) string { 123 + if format == "zip" { 124 + return "application/zip" 125 + } 126 + return "application/gzip" 127 + } 128 + 129 + func writeLocalArchive(ctx context.Context, w io.Writer, repoPath, rev, format, prefix string) error { 130 + args := []string{"-C", repoPath, "archive", "--format=" + format} 131 + if prefix != "" { 132 + args = append(args, "--prefix="+strings.TrimRight(prefix, "/")+"/") 133 + } 134 + args = append(args, rev) 102 135 103 - var stderr strings.Builder 104 - cmd.Dir = repoPath 136 + cmd := exec.CommandContext(ctx, "git", args...) 105 137 cmd.Stdout = w 106 - cmd.Stderr = &stderr 138 + stderr := new(bytes.Buffer) 139 + cmd.Stderr = stderr 107 140 108 141 if err := cmd.Run(); err != nil { 109 - err = fmt.Errorf("%w\n%s", err, stderr.String()) 110 - l.Error("failed to archive", "err", err) 111 - w.WriteHeader(http.StatusInternalServerError) 142 + return fmt.Errorf("%w, stderr: %s", err, stderr.String()) 112 143 } 144 + return nil 113 145 }
+23
knotserver/git/cmd.go
··· 1 1 package git 2 2 3 3 import ( 4 + "bytes" 4 5 "fmt" 6 + "io" 5 7 "os/exec" 8 + "strings" 6 9 ) 7 10 8 11 const ( ··· 44 47 func (g *GitRepo) mergeBase(extraArgs ...string) ([]byte, error) { 45 48 return g.runGitCmd("merge-base", extraArgs...) 46 49 } 50 + 51 + func (g *GitRepo) WriteArchive(w io.Writer, format string, prefix string) error { 52 + args := []string{"archive", "--format=" + format} 53 + if prefix != "" { 54 + args = append(args, "--prefix="+strings.TrimRight(prefix, "/")+"/") 55 + } 56 + args = append(args, g.h.String()) 57 + 58 + cmd := exec.Command("git", args...) 59 + cmd.Dir = g.path 60 + cmd.Stdout = w 61 + stderr := new(bytes.Buffer) 62 + cmd.Stderr = stderr 63 + 64 + if err := cmd.Run(); err != nil { 65 + return fmt.Errorf("%w, stderr: %s", err, stderr.String()) 66 + } 67 + 68 + return nil 69 + }
+11 -16
knotserver/xrpc/repo_archive.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 - "compress/gzip" 5 4 "fmt" 6 5 "net/http" 7 6 "net/url" ··· 32 31 33 32 prefix := r.URL.Query().Get("prefix") 34 33 35 - if format != "tar.gz" { 34 + if format != "tar.gz" && format != "zip" { 36 35 writeError(w, xrpcerr.NewXrpcError( 37 36 xrpcerr.WithTag("InvalidRequest"), 38 - xrpcerr.WithMessage("only tar.gz format is supported"), 37 + xrpcerr.WithMessage("only tar.gz and zip formats are supported"), 39 38 ), http.StatusBadRequest) 40 39 return 41 40 } ··· 70 69 archivePrefix = fmt.Sprintf("%s-%s", repoName, safeRefFilename) 71 70 } 72 71 73 - filename := fmt.Sprintf("%s-%s.tar.gz", repoName, safeRefFilename) 72 + filename := fmt.Sprintf("%s-%s.%s", repoName, safeRefFilename, format) 74 73 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 75 - w.Header().Set("Content-Type", "application/gzip") 74 + w.Header().Set("Content-Type", archiveContentType(format)) 76 75 w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 77 76 78 - gw := gzip.NewWriter(w) 79 - defer gw.Close() 80 - 81 - err = gr.WriteTar(gw, archivePrefix) 77 + err = gr.WriteArchive(w, format, archivePrefix) 82 78 if err != nil { 83 79 // once we start writing to the body we can't report error anymore 84 80 // so we are only left with logging the error 85 - x.Logger.Error("writing tar file", "error", err.Error()) 81 + x.Logger.Error("writing archive", "error", err.Error(), "format", format) 86 82 return 87 83 } 84 + } 88 85 89 - err = gw.Flush() 90 - if err != nil { 91 - // once we start writing to the body we can't report error anymore 92 - // so we are only left with logging the error 93 - x.Logger.Error("flushing", "error", err.Error()) 94 - return 86 + func archiveContentType(format string) string { 87 + if format == "zip" { 88 + return "application/zip" 95 89 } 90 + return "application/gzip" 96 91 } 97 92 98 93 func (x *Xrpc) buildImmutableLink(repo string, format string, ref string, prefix string) (string, error) {