This repository has no description
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 scheme := "http"
55 if !rp.config.Core.Dev {
56 scheme = "https"
57 }
58 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
59 xrpcc := &indigoxrpc.Client{
60 Host: host,
61 }
62 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoIdentifier())
63 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
64 l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err)
65 rp.pages.Error503(w)
66 return
67 }
68
69 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
70
71 // Use XRPC response directly instead of converting to internal types
72 var breadcrumbs [][]string
73 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))})
74 if filePath != "" {
75 for idx, elem := range strings.Split(filePath, "/") {
76 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))})
77 }
78 }
79
80 // Create the blob view
81 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query())
82
83 user := rp.oauth.GetMultiAccountUser(r)
84
85 // Get email to DID mapping for commit author
86 var emails []string
87 if resp.LastCommit != nil && resp.LastCommit.Author != nil {
88 emails = append(emails, resp.LastCommit.Author.Email)
89 }
90 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
91 if err != nil {
92 l.Error("failed to get email to did mapping", "err", err)
93 emailToDidMap = make(map[string]string)
94 }
95
96 var lastCommitInfo *types.LastCommitInfo
97 if resp.LastCommit != nil {
98 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When)
99 lastCommitInfo = &types.LastCommitInfo{
100 Hash: plumbing.NewHash(resp.LastCommit.Hash),
101 Message: resp.LastCommit.Message,
102 When: when,
103 }
104 if resp.LastCommit.Author != nil {
105 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name
106 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email
107 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When)
108 }
109 }
110
111 rp.pages.RepoBlob(w, pages.RepoBlobParams{
112 LoggedInUser: user,
113 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
114 BreadCrumbs: breadcrumbs,
115 BlobView: blobView,
116 EmailToDid: emailToDidMap,
117 LastCommitInfo: lastCommitInfo,
118 RepoBlob_Output: resp,
119 })
120}
121
122func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
123 l := rp.logger.With("handler", "RepoBlobRaw")
124
125 f, err := rp.repoResolver.Resolve(r)
126 if err != nil {
127 l.Error("failed to get repo and knot", "err", err)
128 w.WriteHeader(http.StatusBadRequest)
129 return
130 }
131
132 ref := chi.URLParam(r, "ref")
133 ref, _ = url.PathUnescape(ref)
134
135 filePath := chi.URLParam(r, "*")
136 filePath, _ = url.PathUnescape(filePath)
137
138 scheme := "http"
139 if !rp.config.Core.Dev {
140 scheme = "https"
141 }
142 repo := f.RepoIdentifier()
143 baseURL := &url.URL{
144 Scheme: scheme,
145 Host: f.Knot,
146 Path: "/xrpc/sh.tangled.repo.blob",
147 }
148 query := baseURL.Query()
149 query.Set("repo", repo)
150 query.Set("ref", ref)
151 query.Set("path", filePath)
152 query.Set("raw", "true")
153 baseURL.RawQuery = query.Encode()
154 blobURL := baseURL.String()
155 req, err := http.NewRequest("GET", blobURL, nil)
156 if err != nil {
157 l.Error("failed to create request", "err", err)
158 return
159 }
160
161 // forward the If-None-Match header
162 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" {
163 req.Header.Set("If-None-Match", clientETag)
164 }
165 client := &http.Client{}
166
167 resp, err := client.Do(req)
168 if err != nil {
169 l.Error("failed to reach knotserver", "err", err)
170 rp.pages.Error503(w)
171 return
172 }
173
174 defer resp.Body.Close()
175
176 // forward 304 not modified
177 if resp.StatusCode == http.StatusNotModified {
178 w.WriteHeader(http.StatusNotModified)
179 return
180 }
181
182 if resp.StatusCode != http.StatusOK {
183 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode)
184 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
185 w.WriteHeader(resp.StatusCode)
186 return
187 }
188
189 contentType := resp.Header.Get("Content-Type")
190 body, err := io.ReadAll(resp.Body)
191 if err != nil {
192 l.Error("error reading response body from knotserver", "err", err)
193 w.WriteHeader(http.StatusInternalServerError)
194 return
195 }
196
197 // Normalize to bare media type before classification; strips parameters
198 // (e.g. "; charset=utf-8") and prevents bypass attempts like
199 // "image/svg+xml; innocent=param". A parse error yields an empty string
200 // which falls through to the 415 default — the safe outcome.
201 mediaType, _, _ := mime.ParseMediaType(contentType)
202
203 // Prevent browser sniffing regardless of branch taken below.
204 w.Header().Set("X-Content-Type-Options", "nosniff")
205
206 switch {
207 case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType):
208 // Serve all textual content as plain text so the browser never
209 // interprets knot-supplied markup or scripts.
210 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
211 w.Write(body)
212 case safeBinaryMIMEType(mediaType):
213 // Use the normalized type, never the raw knot-supplied string.
214 w.Header().Set("Content-Type", mediaType)
215 w.Write(body)
216 default:
217 w.WriteHeader(http.StatusUnsupportedMediaType)
218 w.Write([]byte("unsupported content type"))
219 }
220}
221
222// NewBlobView creates a BlobView from the XRPC response
223func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
224 view := models.BlobView{
225 Contents: "",
226 Lines: 0,
227 }
228
229 // Set size
230 if resp.Size != nil {
231 view.SizeHint = uint64(*resp.Size)
232 } else if resp.Content != nil {
233 view.SizeHint = uint64(len(*resp.Content))
234 }
235
236 if resp.Submodule != nil {
237 view.ContentType = models.BlobContentTypeSubmodule
238 view.HasRenderedView = true
239 view.ContentSrc = resp.Submodule.Url
240 return view
241 }
242
243 // Determine if binary
244 if resp.IsBinary != nil && *resp.IsBinary {
245 view.ContentSrc = generateBlobURL(config, repo, ref, filePath)
246 ext := strings.ToLower(filepath.Ext(resp.Path))
247
248 switch ext {
249 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif":
250 view.ContentType = models.BlobContentTypeImage
251 view.HasRawView = true
252 view.HasRenderedView = true
253 view.ShowingRendered = true
254
255 case ".svg":
256 view.ContentType = models.BlobContentTypeSvg
257 view.HasRawView = true
258 view.HasTextView = true
259 view.HasRenderedView = true
260 view.ShowingRendered = queryParams.Get("code") != "true"
261 if resp.Content != nil {
262 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
263 view.Contents = string(bytes)
264 view.Lines = countLines(view.Contents)
265 }
266
267 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
268 view.ContentType = models.BlobContentTypeVideo
269 view.HasRawView = true
270 view.HasRenderedView = true
271 view.ShowingRendered = true
272 }
273
274 return view
275 }
276
277 // otherwise, we are dealing with text content
278 view.HasRawView = true
279 view.HasTextView = true
280
281 if resp.Content != nil {
282 view.Contents = *resp.Content
283 view.Lines = countLines(view.Contents)
284 }
285
286 // with text, we may be dealing with markdown
287 format := markup.GetFormat(resp.Path)
288 if format == markup.FormatMarkdown {
289 view.ContentType = models.BlobContentTypeMarkup
290 view.HasRenderedView = true
291 view.ShowingRendered = queryParams.Get("code") != "true"
292 }
293
294 return view
295}
296
297func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string {
298 scheme := "http"
299 if !config.Core.Dev {
300 scheme = "https"
301 }
302
303 repoName := repo.RepoIdentifier()
304 baseURL := &url.URL{
305 Scheme: scheme,
306 Host: repo.Knot,
307 Path: "/xrpc/sh.tangled.repo.blob",
308 }
309 query := baseURL.Query()
310 query.Set("repo", repoName)
311 query.Set("ref", ref)
312 query.Set("path", filePath)
313 query.Set("raw", "true")
314 baseURL.RawQuery = query.Encode()
315 blobURL := baseURL.String()
316
317 if !config.Core.Dev {
318 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL)
319 }
320 return blobURL
321}
322
323// safeBinaryMIMETypes is an explicit allowlist of binary content types that
324// are safe to serve inline. SVG is intentionally absent: it supports embedded
325// scripts and would enable XSS if a malicious knot returned one.
326var safeBinaryMIMETypes = map[string]bool{
327 "image/png": true,
328 "image/jpeg": true,
329 "image/gif": true,
330 "image/webp": true,
331 "image/avif": true,
332 "video/mp4": true,
333 "video/webm": true,
334 "video/ogg": true,
335}
336
337func safeBinaryMIMEType(mediaType string) bool {
338 return safeBinaryMIMETypes[mediaType]
339}
340
341func isTextualMimeType(mimeType string) bool {
342 textualTypes := []string{
343 "application/json",
344 "application/xml",
345 "application/yaml",
346 "application/x-yaml",
347 "application/toml",
348 "application/javascript",
349 "application/ecmascript",
350 "message/",
351 }
352 return slices.Contains(textualTypes, mimeType)
353}
354
355// TODO: dedup with strings
356func countLines(content string) int {
357 if content == "" {
358 return 0
359 }
360
361 count := strings.Count(content, "\n")
362
363 if !strings.HasSuffix(content, "\n") {
364 count++
365 }
366
367 return count
368}