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