This repository has no description
4.1 kB
137 lines
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/knotserver/git"
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.ParseATURI(repoQuery)
27 if err != nil || repo.RecordKey() == "" {
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("repo", repo, "ref", ref, "path", path)
33
34 if path == "" {
35 writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"})
36 return
37 }
38
39 file, err := x.getFile(r.Context(), repo, ref, path)
40 if err != nil {
41 l.Warn("local mirror failed, trying proxy", "err", err)
42 if x.proxyToKnot(w, r, repo) {
43 return
44 }
45 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"})
46 return
47 }
48
49 reader, err := file.Reader()
50 if err != nil {
51 l.Error("failed to read blob", "err", err)
52 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"})
53 return
54 }
55 defer reader.Close()
56
57 // default to octet-stream for large blobs
58 if file.Size > 1000*1000 { // 1MB
59 w.Header().Set("Content-Type", "application/octet-stream")
60 if _, err := io.Copy(w, reader); err != nil {
61 l.Error("failed to serve the blob", "err", err)
62 }
63 return
64 }
65
66 contents, err := io.ReadAll(reader)
67 if err != nil {
68 l.Error("failed to read blob content", "err", err)
69 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"})
70 return
71 }
72
73 mimeType := http.DetectContentType(contents)
74 // override MIME types for formats that http.DetectContentType does not recognize
75 switch filepath.Ext(path) {
76 case ".svg":
77 mimeType = "image/svg+xml"
78 case ".avif":
79 mimeType = "image/avif"
80 case ".jxl":
81 mimeType = "image/jxl"
82 case ".heic", ".heif":
83 mimeType = "image/heif"
84 }
85
86 switch {
87 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
88 eTag := fmt.Sprintf("\"%x\"", sha256.Sum256(contents))
89 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
90 w.WriteHeader(http.StatusNotModified)
91 return
92 }
93 w.Header().Set("ETag", eTag)
94 w.Header().Set("Content-Type", mimeType)
95
96 case strings.HasPrefix(mimeType, "text/") || isTextualMimeType(mimeType):
97 w.Header().Set("Cache-Control", "public, no-cache")
98 // serve all text content as text/plain
99 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
100
101 default:
102 l.Error("attempted to serve disallowed file type", "mimetype", mimeType)
103 writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InvalidRequest", Message: "only image, video, and text files can be accessed directly"})
104 return
105 }
106 w.Write(contents)
107}
108
109func (x *Xrpc) getFile(ctx context.Context, repo syntax.ATURI, ref, path string) (*object.File, error) {
110 repoPath, err := x.makeRepoPath(ctx, repo)
111 if err != nil {
112 return nil, fmt.Errorf("resolving repo at-uri: %w", err)
113 }
114
115 gr, err := git.Open(repoPath, ref)
116 if err != nil {
117 return nil, fmt.Errorf("opening git repo: %w", err)
118 }
119
120 return gr.File(path)
121}
122
123var textualMimeTypes = []string{
124 "application/json",
125 "application/xml",
126 "application/yaml",
127 "application/x-yaml",
128 "application/toml",
129 "application/javascript",
130 "application/ecmascript",
131}
132
133// isTextualMimeType returns true if the MIME type represents textual content
134// that should be served as text/plain for security reasons
135func isTextualMimeType(mimeType string) bool {
136 return slices.Contains(textualMimeTypes, mimeType)
137}