1package xrpc
2
3import (
4 "crypto/sha256"
5 "encoding/base64"
6 "fmt"
7 "net/http"
8 "path/filepath"
9 "slices"
10 "strings"
11
12 "tangled.org/core/api/tangled"
13 "tangled.org/core/knotserver/git"
14 xrpcerr "tangled.org/core/xrpc/errors"
15)
16
17func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
18 repo := r.URL.Query().Get("repo")
19 repoPath, err := x.parseRepoParam(repo)
20 if err != nil {
21 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
22 return
23 }
24
25 ref := r.URL.Query().Get("ref")
26 // ref can be empty (git.Open handles this)
27
28 treePath := r.URL.Query().Get("path")
29 if treePath == "" {
30 writeError(w, xrpcerr.NewXrpcError(
31 xrpcerr.WithTag("InvalidRequest"),
32 xrpcerr.WithMessage("missing path parameter"),
33 ), http.StatusBadRequest)
34 return
35 }
36
37 raw := r.URL.Query().Get("raw") == "true"
38
39 gr, err := git.Open(repoPath, ref)
40 if err != nil {
41 writeError(w, xrpcerr.RefNotFoundError, 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(), "treePath", treePath)
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 writeJson(w, response)
127}
128
129// isTextualMimeType returns true if the MIME type represents textual content
130// that should be served as text/plain for security reasons
131func isTextualMimeType(mimeType string) bool {
132 textualTypes := []string{
133 "application/json",
134 "application/xml",
135 "application/yaml",
136 "application/x-yaml",
137 "application/toml",
138 "application/javascript",
139 "application/ecmascript",
140 }
141
142 return slices.Contains(textualTypes, mimeType)
143}