Monorepo for Tangled
tangled.org
1package repo
2
3import (
4 "encoding/base64"
5 "fmt"
6 "io"
7 "mime"
8 "net/http"
9 "net/url"
10 "path/filepath"
11 "slices"
12 "strings"
13 "time"
14
15 "tangled.org/core/api/tangled"
16 "tangled.org/core/appview/config"
17 "tangled.org/core/appview/db"
18 "tangled.org/core/appview/models"
19 "tangled.org/core/appview/pages"
20 "tangled.org/core/appview/pages/markup"
21 "tangled.org/core/appview/reporesolver"
22 xrpcclient "tangled.org/core/appview/xrpcclient"
23 "tangled.org/core/types"
24
25 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
26 "github.com/go-chi/chi/v5"
27 "github.com/go-git/go-git/v5/plumbing"
28)
29
30// the content can be one of the following:
31//
32// - code : text | | raw
33// - markup : text | rendered | raw
34// - svg : text | rendered | raw
35// - png : | rendered | raw
36// - video : | rendered | raw
37// - submodule : | rendered |
38// - rest : | |
39func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
40 l := rp.logger.With("handler", "RepoBlob")
41
42 f, err := rp.repoResolver.Resolve(r)
43 if err != nil {
44 l.Error("failed to get repo and knot", "err", err)
45 return
46 }
47
48 ref := chi.URLParam(r, "ref")
49 ref, _ = url.PathUnescape(ref)
50
51 filePath := chi.URLParam(r, "*")
52 filePath, _ = url.PathUnescape(filePath)
53
54 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
55 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoDid)
56 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
57 l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err)
58 rp.pages.Error503(w)
59 return
60 }
61
62 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
63
64 // Use XRPC response directly instead of converting to internal types
65 var breadcrumbs [][]string
66 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))})
67 if filePath != "" {
68 for idx, elem := range strings.Split(filePath, "/") {
69 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
70 }
71 }
72
73 // Create the blob view
74 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
75
76 user := rp.oauth.GetMultiAccountUser(r)
77
78 // Get email to DID mapping for commit author
79 var emails []string
80 if resp.LastCommit != nil && resp.LastCommit.Author != nil {
81 emails = append(emails, resp.LastCommit.Author.Email)
82 }
83 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
84 if err != nil {
85 l.Error("failed to get email to did mapping", "err", err)
86 emailToDidMap = make(map[string]string)
87 }
88
89 var lastCommitInfo *types.LastCommitInfo
90 if resp.LastCommit != nil {
91 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When)
92 lastCommitInfo = &types.LastCommitInfo{
93 Hash: plumbing.NewHash(resp.LastCommit.Hash),
94 Message: resp.LastCommit.Message,
95 When: when,
96 }
97 if resp.LastCommit.Author != nil {
98 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name
99 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email
100 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When)
101 }
102 }
103
104 rp.pages.RepoBlob(w, pages.RepoBlobParams{
105 LoggedInUser: user,
106 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
107 BreadCrumbs: breadcrumbs,
108 BlobView: blobView,
109 EmailToDid: emailToDidMap,
110 LastCommitInfo: lastCommitInfo,
111 RepoBlob_Output: resp,
112 })
113}
114
115func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
116 l := rp.logger.With("handler", "RepoBlobRaw")
117
118 f, err := rp.repoResolver.Resolve(r)
119 if err != nil {
120 l.Error("failed to get repo and knot", "err", err)
121 w.WriteHeader(http.StatusBadRequest)
122 return
123 }
124
125 ref := chi.URLParam(r, "ref")
126 ref, _ = url.PathUnescape(ref)
127
128 filePath := chi.URLParam(r, "*")
129 filePath, _ = url.PathUnescape(filePath)
130
131 blobURL := generateBlobURL(rp.config, f, ref, filePath)
132
133 req, err := http.NewRequest("GET", blobURL, nil)
134 if err != nil {
135 l.Error("failed to create request", "err", err)
136 return
137 }
138
139 // forward the If-None-Match header
140 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
141 req.Header.Set("If-None-Match", clientETag)
142 }
143 client := &http.Client{}
144
145 resp, err := client.Do(req)
146 if err != nil {
147 l.Error("failed to reach knotserver", "err", err)
148 rp.pages.Error503(w)
149 return
150 }
151
152 defer resp.Body.Close()
153
154 // forward 304 not modified
155 if resp.StatusCode == http.StatusNotModified {
156 w.WriteHeader(http.StatusNotModified)
157 return
158 }
159
160 if resp.StatusCode != http.StatusOK {
161 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
162 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
163 w.WriteHeader(resp.StatusCode)
164 return
165 }
166
167 contentType := resp.Header.Get("Content-Type")
168
169 // Normalize to bare media type before classification; strips parameters
170 // (e.g. "; charset=utf-8") and prevents bypass attempts like
171 // "image/svg+xml; innocent=param". A parse error yields an empty string
172 // which falls through to the 415 default — the safe outcome.
173 mediaType, _, _ := mime.ParseMediaType(contentType)
174
175 // Prevent browser sniffing regardless of branch taken below.
176 w.Header().Set("X-Content-Type-Options", "nosniff")
177
178 switch {
179 case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType):
180 // Serve all textual content as plain text so the browser never
181 // interprets knot-supplied markup or scripts.
182 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
183 case safeBinaryMIMEType(mediaType):
184 // Use the normalized type, never the raw knot-supplied string.
185 w.Header().Set("Content-Type", mediaType)
186 default:
187 // If mediatype is unknown or it's unsafe (e.g. SVG which allows XSS,)
188 // fallback to octet-stream
189 w.Header().Set("Content-Type", "application/octet-stream")
190 }
191 if _, err := io.Copy(w, resp.Body); err != nil {
192 l.Error("error streaming knotmirror response", "err", err)
193 w.WriteHeader(http.StatusInternalServerError)
194 return
195 }
196}
197
198// NewBlobView creates a BlobView from the XRPC response
199func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
200 view := models.BlobView{
201 Contents: "",
202 Lines: 0,
203 }
204
205 // Set size
206 if resp.Size != nil {
207 view.SizeHint = uint64(*resp.Size)
208 } else if resp.Content != nil {
209 view.SizeHint = uint64(len(*resp.Content))
210 }
211
212 if resp.Submodule != nil {
213 view.ContentType = models.BlobContentTypeSubmodule
214 view.HasRenderedView = true
215 view.ContentSrc = resp.Submodule.Url
216 return view
217 }
218
219 // Determine if binary
220 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) {
221 view.ContentSrc = generateBlobURL(config, repo, ref, filePath)
222 ext := strings.ToLower(filepath.Ext(resp.Path))
223
224 switch ext {
225 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif":
226 view.ContentType = models.BlobContentTypeImage
227 view.HasRawView = true
228 view.HasRenderedView = true
229 view.ShowingRendered = true
230
231 case ".svg":
232 view.ContentType = models.BlobContentTypeSvg
233 view.HasRawView = true
234 view.HasTextView = true
235 view.HasRenderedView = true
236 view.ShowingRendered = queryParams.Get("code") != "true"
237 if resp.Content != nil {
238 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
239 view.Contents = string(bytes)
240 view.Lines = countLines(view.Contents)
241 }
242
243 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
244 view.ContentType = models.BlobContentTypeVideo
245 view.HasRawView = true
246 view.HasRenderedView = true
247 view.ShowingRendered = true
248 }
249
250 return view
251 }
252
253 // otherwise, we are dealing with text content
254 view.HasRawView = true
255 view.HasTextView = true
256
257 if resp.Content != nil {
258 view.Contents = *resp.Content
259 view.Lines = countLines(view.Contents)
260 }
261
262 // with text, we may be dealing with markdown
263 format := markup.GetFormat(resp.Path)
264 if format == markup.FormatMarkdown {
265 view.ContentType = models.BlobContentTypeMarkup
266 view.HasRenderedView = true
267 view.ShowingRendered = queryParams.Get("code") != "true"
268 }
269
270 return view
271}
272
273func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string {
274 query := url.Values{}
275 query.Set("repo", repo.RepoDid)
276 query.Set("ref", ref)
277 query.Set("path", filePath)
278 query.Set("raw", "true")
279
280 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode())
281 return blobURL
282}
283
284// safeBinaryMIMETypes is an explicit allowlist of binary content types that
285// are safe to serve inline. SVG is intentionally absent: it supports embedded
286// scripts and would enable XSS if a malicious knot returned one.
287var safeBinaryMIMETypes = map[string]bool{
288 "image/png": true,
289 "image/jpeg": true,
290 "image/gif": true,
291 "image/webp": true,
292 "image/avif": true,
293 "video/mp4": true,
294 "video/webm": true,
295 "video/ogg": true,
296}
297
298func safeBinaryMIMEType(mediaType string) bool {
299 return safeBinaryMIMETypes[mediaType]
300}
301
302func isTextualMimeType(mimeType string) bool {
303 textualTypes := []string{
304 "application/json",
305 "application/xml",
306 "application/yaml",
307 "application/x-yaml",
308 "application/toml",
309 "application/javascript",
310 "application/ecmascript",
311 "message/",
312 }
313 return slices.Contains(textualTypes, mimeType)
314}
315
316// TODO: dedup with strings
317func countLines(content string) int {
318 if content == "" {
319 return 0
320 }
321
322 count := strings.Count(content, "\n")
323
324 if !strings.HasSuffix(content, "\n") {
325 count++
326 }
327
328 return count
329}