1package xrpc
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "encoding/json"
7 "fmt"
8 "net/http"
9 "path/filepath"
10 "slices"
11 "strings"
12
13 "tangled.sh/tangled.sh/core/api/tangled"
14 "tangled.sh/tangled.sh/core/knotserver/git"
15 xrpcerr "tangled.sh/tangled.sh/core/xrpc/errors"
16)
17
18func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
19 _, repoPath, ref, err := x.parseStandardParams(r)
20 if err != nil {
21 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22 return
23 }
24
25 treePath := r.URL.Query().Get("path")
26 if treePath == "" {
27 writeError(w, xrpcerr.NewXrpcError(
28 xrpcerr.WithTag("InvalidRequest"),
29 xrpcerr.WithMessage("missing path parameter"),
30 ), http.StatusBadRequest)
31 return
32 }
33
34 raw := r.URL.Query().Get("raw") == "true"
35
36 gr, err := git.Open(repoPath, ref)
37 if err != nil {
38 writeError(w, xrpcerr.NewXrpcError(
39 xrpcerr.WithTag("RefNotFound"),
40 xrpcerr.WithMessage("repository or ref not found"),
41 ), http.StatusNotFound)
42 return
43 }
44
45 contents, err := gr.RawContent(treePath)
46 if err != nil {
47 x.Logger.Error("file content", "error", err.Error())
48 writeError(w, xrpcerr.NewXrpcError(
49 xrpcerr.WithTag("FileNotFound"),
50 xrpcerr.WithMessage("file not found at the specified path"),
51 ), http.StatusNotFound)
52 return
53 }
54
55 mimeType := http.DetectContentType(contents)
56
57 if filepath.Ext(treePath) == ".svg" {
58 mimeType = "image/svg+xml"
59 }
60
61 if raw {
62 contentHash := sha256.Sum256(contents)
63 eTag := fmt.Sprintf("\"%x\"", contentHash)
64
65 switch {
66 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
67 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
68 w.WriteHeader(http.StatusNotModified)
69 return
70 }
71 w.Header().Set("ETag", eTag)
72
73 case strings.HasPrefix(mimeType, "text/"):
74 w.Header().Set("Cache-Control", "public, no-cache")
75 // serve all text content as text/plain
76 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
77
78 case isTextualMimeType(mimeType):
79 // handle textual application types (json, xml, etc.) as text/plain
80 w.Header().Set("Cache-Control", "public, no-cache")
81 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
82
83 default:
84 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
85 writeError(w, xrpcerr.NewXrpcError(
86 xrpcerr.WithTag("InvalidRequest"),
87 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
88 ), http.StatusForbidden)
89 return
90 }
91 w.Write(contents)
92 return
93 }
94
95 isTextual := func(mt string) bool {
96 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
97 }
98
99 var content string
100 var encoding string
101
102 isBinary := !isTextual(mimeType)
103
104 if isBinary {
105 content = base64.StdEncoding.EncodeToString(contents)
106 encoding = "base64"
107 } else {
108 content = string(contents)
109 encoding = "utf-8"
110 }
111
112 response := tangled.RepoBlob_Output{
113 Ref: ref,
114 Path: treePath,
115 Content: content,
116 Encoding: &encoding,
117 Size: &[]int64{int64(len(contents))}[0],
118 IsBinary: &isBinary,
119 }
120
121 if mimeType != "" {
122 response.MimeType = &mimeType
123 }
124
125 w.Header().Set("Content-Type", "application/json")
126 if err := json.NewEncoder(w).Encode(response); err != nil {
127 x.Logger.Error("failed to encode response", "error", err)
128 writeError(w, xrpcerr.NewXrpcError(
129 xrpcerr.WithTag("InternalServerError"),
130 xrpcerr.WithMessage("failed to encode response"),
131 ), http.StatusInternalServerError)
132 return
133 }
134}
135
136// isTextualMimeType returns true if the MIME type represents textual content
137// that should be served as text/plain for security reasons
138func isTextualMimeType(mimeType string) bool {
139 textualTypes := []string{
140 "application/json",
141 "application/xml",
142 "application/yaml",
143 "application/x-yaml",
144 "application/toml",
145 "application/javascript",
146 "application/ecmascript",
147 }
148
149 return slices.Contains(textualTypes, mimeType)
150}