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 w.Header().Set("Content-Type", mimeType)
73
74 case strings.HasPrefix(mimeType, "text/"):
75 w.Header().Set("Cache-Control", "public, no-cache")
76 // serve all text content as text/plain
77 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
78
79 case isTextualMimeType(mimeType):
80 // handle textual application types (json, xml, etc.) as text/plain
81 w.Header().Set("Cache-Control", "public, no-cache")
82 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
83
84 default:
85 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
86 writeError(w, xrpcerr.NewXrpcError(
87 xrpcerr.WithTag("InvalidRequest"),
88 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
89 ), http.StatusForbidden)
90 return
91 }
92 w.Write(contents)
93 return
94 }
95
96 isTextual := func(mt string) bool {
97 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
98 }
99
100 var content string
101 var encoding string
102
103 isBinary := !isTextual(mimeType)
104
105 if isBinary {
106 content = base64.StdEncoding.EncodeToString(contents)
107 encoding = "base64"
108 } else {
109 content = string(contents)
110 encoding = "utf-8"
111 }
112
113 response := tangled.RepoBlob_Output{
114 Ref: ref,
115 Path: treePath,
116 Content: content,
117 Encoding: &encoding,
118 Size: &[]int64{int64(len(contents))}[0],
119 IsBinary: &isBinary,
120 }
121
122 if mimeType != "" {
123 response.MimeType = &mimeType
124 }
125
126 w.Header().Set("Content-Type", "application/json")
127 if err := json.NewEncoder(w).Encode(response); err != nil {
128 x.Logger.Error("failed to encode response", "error", err)
129 writeError(w, xrpcerr.NewXrpcError(
130 xrpcerr.WithTag("InternalServerError"),
131 xrpcerr.WithMessage("failed to encode response"),
132 ), http.StatusInternalServerError)
133 return
134 }
135}
136
137// isTextualMimeType returns true if the MIME type represents textual content
138// that should be served as text/plain for security reasons
139func isTextualMimeType(mimeType string) bool {
140 textualTypes := []string{
141 "application/json",
142 "application/xml",
143 "application/yaml",
144 "application/x-yaml",
145 "application/toml",
146 "application/javascript",
147 "application/ecmascript",
148 }
149
150 return slices.Contains(textualTypes, mimeType)
151}