Monorepo for Tangled
1package xrpc
2
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/base64"
7 "fmt"
8 "net/http"
9 "path/filepath"
10 "slices"
11 "strings"
12 "time"
13
14 "tangled.org/core/api/tangled"
15 "tangled.org/core/knotserver/git"
16 xrpcerr "tangled.org/core/xrpc/errors"
17)
18
19func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) {
20 repo := r.URL.Query().Get("repo")
21 repoPath, err := x.parseRepoParam(repo)
22 if err != nil {
23 writeError(w, err.(xrpcerr.XrpcError), http.StatusBadRequest)
24 return
25 }
26
27 ref := r.URL.Query().Get("ref")
28 // ref can be empty (git.Open handles this)
29
30 treePath := r.URL.Query().Get("path")
31 if treePath == "" {
32 writeError(w, xrpcerr.NewXrpcError(
33 xrpcerr.WithTag("InvalidRequest"),
34 xrpcerr.WithMessage("missing path parameter"),
35 ), http.StatusBadRequest)
36 return
37 }
38
39 raw := r.URL.Query().Get("raw") == "true"
40
41 gr, err := git.Open(repoPath, ref)
42 if err != nil {
43 writeError(w, xrpcerr.RefNotFoundError, http.StatusNotFound)
44 return
45 }
46
47 // first check if this path is a submodule
48 submodule, err := gr.Submodule(treePath)
49 if err != nil {
50 // this is okay, continue and try to treat it as a regular file
51 } else {
52 response := tangled.RepoBlob_Output{
53 Ref: ref,
54 Path: treePath,
55 Submodule: &tangled.RepoBlob_Submodule{
56 Name: submodule.Name,
57 Url: submodule.URL,
58 Branch: &submodule.Branch,
59 },
60 }
61 writeJson(w, response)
62 return
63 }
64
65 contents, err := gr.RawContent(treePath)
66 if err != nil {
67 x.Logger.Error("file content", "error", err.Error(), "treePath", treePath)
68 writeError(w, xrpcerr.NewXrpcError(
69 xrpcerr.WithTag("FileNotFound"),
70 xrpcerr.WithMessage("file not found at the specified path"),
71 ), http.StatusNotFound)
72 return
73 }
74
75 mimeType := http.DetectContentType(contents)
76
77 if filepath.Ext(treePath) == ".svg" {
78 mimeType = "image/svg+xml"
79 }
80
81 if raw {
82 contentHash := sha256.Sum256(contents)
83 eTag := fmt.Sprintf("\"%x\"", contentHash)
84
85 switch {
86 case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"):
87 if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag {
88 w.WriteHeader(http.StatusNotModified)
89 return
90 }
91 w.Header().Set("ETag", eTag)
92 w.Header().Set("Content-Type", mimeType)
93
94 case strings.HasPrefix(mimeType, "text/"):
95 w.Header().Set("Cache-Control", "public, no-cache")
96 // serve all text content as text/plain
97 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
98
99 case isTextualMimeType(mimeType):
100 // handle textual application types (json, xml, etc.) as text/plain
101 w.Header().Set("Cache-Control", "public, no-cache")
102 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
103
104 default:
105 x.Logger.Error("attempted to serve disallowed file type", "mimetype", mimeType)
106 writeError(w, xrpcerr.NewXrpcError(
107 xrpcerr.WithTag("InvalidRequest"),
108 xrpcerr.WithMessage("only image, video, and text files can be accessed directly"),
109 ), http.StatusForbidden)
110 return
111 }
112 w.Write(contents)
113 return
114 }
115
116 isTextual := func(mt string) bool {
117 return strings.HasPrefix(mt, "text/") || isTextualMimeType(mt)
118 }
119
120 var content string
121 var encoding string
122
123 isBinary := !isTextual(mimeType)
124 size := int64(len(contents))
125
126 if isBinary {
127 content = base64.StdEncoding.EncodeToString(contents)
128 encoding = "base64"
129 } else {
130 content = string(contents)
131 encoding = "utf-8"
132 }
133
134 response := tangled.RepoBlob_Output{
135 Ref: ref,
136 Path: treePath,
137 Content: &content,
138 Encoding: &encoding,
139 Size: &size,
140 IsBinary: &isBinary,
141 }
142
143 if mimeType != "" {
144 response.MimeType = &mimeType
145 }
146
147 ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
148 defer cancel()
149
150 lastCommit, err := gr.GetLastCommitForPath(ctx, treePath)
151 if err == nil && lastCommit != nil {
152 shortHash := lastCommit.Hash.String()[:8]
153 response.LastCommit = &tangled.RepoBlob_LastCommit{
154 Hash: lastCommit.Hash.String(),
155 ShortHash: &shortHash,
156 Message: lastCommit.Message,
157 When: lastCommit.When.Format(time.RFC3339),
158 }
159
160 // try to get author information
161 commit, err := gr.Commit(lastCommit.Hash)
162 if err == nil {
163 response.LastCommit.Author = &tangled.RepoBlob_Signature{
164 Name: commit.Author.Name,
165 Email: commit.Author.Email,
166 }
167 }
168 }
169
170 writeJson(w, response)
171}
172
173// isTextualMimeType returns true if the MIME type represents textual content
174// that should be served as text/plain for security reasons
175func isTextualMimeType(mimeType string) bool {
176 textualTypes := []string{
177 "application/json",
178 "application/xml",
179 "application/yaml",
180 "application/x-yaml",
181 "application/toml",
182 "application/javascript",
183 "application/ecmascript",
184 }
185
186 return slices.Contains(textualTypes, mimeType)
187}