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