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