Monorepo for Tangled
tangled.org
1package xrpc
2
3import (
4 "crypto/sha256"
5 "fmt"
6 "io"
7 "net/http"
8 "path/filepath"
9 "slices"
10 "strconv"
11 "strings"
12
13 "github.com/bluesky-social/indigo/atproto/atclient"
14 "github.com/bluesky-social/indigo/atproto/syntax"
15 "tangled.org/core/knotmirror/xrpc/gitea"
16)
17
18func (x *Xrpc) GetBlob(w http.ResponseWriter, r *http.Request) {
19 var (
20 repoQuery = r.URL.Query().Get("repo")
21 ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this)
22 path = r.URL.Query().Get("path")
23 )
24
25 repo, err := syntax.ParseDID(repoQuery)
26 if err != nil {
27 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)})
28 return
29 }
30
31 l := x.logger.With("method", "git.getBlob", "repo", repo, "ref", ref, "path", path)
32 l.Debug("request")
33
34 if path == "" {
35 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"})
36 return
37 }
38
39 ctx := r.Context()
40
41 repoPath, err := x.makeRepoPath(ctx, repo)
42 if err != nil {
43 writeJson(w, http.StatusNotFound, atclient.ErrorBody{Name: "RepoNotFound", Message: fmt.Sprintf("unknown repository: %s", repo)})
44 return
45 }
46
47 entry, err := gitea.GetEntry(ctx, repoPath, ref, path)
48 if err != nil {
49 l.Warn("local mirror failed", "err", err)
50 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"})
51 return
52 }
53 size, reader, err := gitea.ReadBlob(ctx, repoPath, entry.Hash)
54 if err != nil {
55 l.Warn("local mirror failed", "err", err)
56 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"})
57 return
58 }
59 defer reader.Close()
60
61 w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
62
63 // default to octet-stream for large blobs
64 if size > 1024*1024 { // 1MiB
65 w.Header().Set("Content-Type", "application/octet-stream")
66 if _, err := io.Copy(w, reader); err != nil {
67 l.Error("failed to serve the blob", "err", err)
68 }
69 return
70 }
71
72 contents, err := io.ReadAll(reader)
73 if err != nil {
74 l.Error("failed to read blob content", "err", err)
75 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"})
76 return
77 }
78
79 eTag := fmt.Sprintf("\"%x\"", sha256.Sum256(contents))
80 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
81 w.WriteHeader(http.StatusNotModified)
82 return
83 }
84 w.Header().Set("ETag", eTag)
85 w.Header().Set("X-Content-Type-Options", "nosniff")
86
87 mimeType := http.DetectContentType(contents)
88 // override MIME types for formats that http.DetectContentType does not recognize
89 switch filepath.Ext(path) {
90 case ".svg":
91 mimeType = "image/svg+xml"
92 case ".avif":
93 mimeType = "image/avif"
94 case ".jxl":
95 mimeType = "image/jxl"
96 case ".heic", ".heif":
97 mimeType = "image/heif"
98 }
99
100 switch {
101 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
102 w.Header().Set("Content-Type", mimeType)
103
104 case strings.HasPrefix(mimeType, "text/") || isTextualMimeType(mimeType):
105 w.Header().Set("Cache-Control", "public, no-cache")
106 // serve all text content as text/plain
107 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
108
109 default:
110 // fallback to octet-stream
111 w.Header().Set("Content-Type", "application/octet-stream")
112 }
113 w.Write(contents)
114}
115
116var textualMimeTypes = []string{
117 "application/json",
118 "application/xml",
119 "application/yaml",
120 "application/x-yaml",
121 "application/toml",
122 "application/javascript",
123 "application/ecmascript",
124}
125
126// isTextualMimeType returns true if the MIME type represents textual content
127// that should be served as text/plain for security reasons
128func isTextualMimeType(mimeType string) bool {
129 return slices.Contains(textualMimeTypes, mimeType)
130}