Monorepo for Tangled
tangled.org
1package repo
2
3import (
4 "fmt"
5 "io"
6 "net/http"
7 "net/url"
8 "strings"
9
10 "github.com/go-chi/chi/v5"
11 "tangled.org/core/api/tangled"
12)
13
14func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) {
15 l := rp.logger.With("handler", "DownloadArchive")
16 ref := chi.URLParam(r, "ref")
17 ref, _ = url.PathUnescape(ref)
18 format := r.URL.Query().Get("format")
19 ref, format = archiveRefAndFormat(ref, format, r.UserAgent())
20 f, err := rp.repoResolver.Resolve(r)
21 if err != nil {
22 l.Error("failed to get repo and knot", "err", err)
23 return
24 }
25
26 // build the xrpc url
27 query := url.Values{}
28 query.Set("repo", f.RepoDid)
29 query.Set("ref", ref)
30 query.Set("format", format)
31 query.Set("prefix", r.URL.Query().Get("prefix"))
32 xrpcURL := fmt.Sprintf(
33 "%s/xrpc/%s?%s",
34 rp.config.KnotMirror.Url,
35 tangled.GitTempGetArchiveNSID,
36 query.Encode(),
37 )
38
39 // make the get request
40 resp, err := http.Get(xrpcURL)
41 if err != nil {
42 l.Error("failed to call XRPC repo.archive", "err", err)
43 rp.pages.Error503(w)
44 return
45 }
46 defer resp.Body.Close()
47
48 w.Header().Set("Content-Type", archiveContentType(format))
49
50 filename := ""
51 if cd := resp.Header.Get("Content-Disposition"); strings.HasPrefix(cd, "attachment;") {
52 filename = cd // knot has already set the attachment CD
53 }
54 if filename == "" {
55 filename = fmt.Sprintf("attachment; filename=\"%s-%s.%s\"", f.Name, ref, format)
56 }
57 w.Header().Set("Content-Disposition", filename)
58 w.Header().Set("X-Content-Type-Options", "nosniff")
59
60 if link := resp.Header.Get("Link"); link != "" {
61 if resolvedRef, err := extractImmutableLink(link); err == nil {
62 newLink := fmt.Sprintf("<%s/%s/archive/%s.%s>; rel=\"immutable\"",
63 rp.config.Core.BaseUrl(), f.RepoIdentifier(), resolvedRef, format)
64 w.Header().Set("Link", newLink)
65 }
66 }
67
68 // stream the archive data directly
69 if _, err := io.Copy(w, resp.Body); err != nil {
70 l.Error("failed to write response", "err", err)
71 }
72}
73
74func 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
99func 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
104func archiveContentType(format string) string {
105 if format == "zip" {
106 return "application/zip"
107 }
108 return "application/gzip"
109}
110
111func extractImmutableLink(linkHeader string) (string, error) {
112 trimmed := strings.TrimPrefix(linkHeader, "<")
113 trimmed = strings.TrimSuffix(trimmed, ">; rel=\"immutable\"")
114
115 parsedLink, err := url.Parse(trimmed)
116 if err != nil {
117 return "", err
118 }
119
120 resolvedRef := parsedLink.Query().Get("ref")
121 if resolvedRef == "" {
122 return "", fmt.Errorf("no ref found in link header")
123 }
124
125 return resolvedRef, nil
126}