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 "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 "tangled.org/core/types"
22 xrpcclient "tangled.org/core/xrpc/xrpcclient"
23
24 "github.com/bluesky-social/indigo/util"
25 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
26 "github.com/go-chi/chi/v5"
27 enry "github.com/go-enry/go-enry/v2"
28 "github.com/go-git/go-git/v5/plumbing"
29 "github.com/go-git/go-git/v5/plumbing/filemode"
30)
31
32// maxBlobSize bounds inline text content; larger blobs are marked too large.
33const maxBlobSize = 1 << 20 // 1MiB
34
35// the content can be one of the following:
36//
37// - code : text | | raw
38// - markup : text | rendered | raw
39// - svg : text | rendered | raw
40// - image : | rendered | raw
41// - video : | rendered | raw
42// - submodule : | rendered |
43// - rest : | | raw
44func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) {
45 l := rp.logger.With("handler", "RepoBlob")
46
47 f, err := rp.repoResolver.Resolve(r)
48 if err != nil {
49 l.Error("failed to get repo and knot", "err", err)
50 return
51 }
52
53 ref := chi.URLParam(r, "ref")
54 ref, _ = url.PathUnescape(ref)
55
56 filePath := chi.URLParam(r, "*")
57 filePath, _ = url.PathUnescape(filePath)
58
59 l = l.With("ref", ref, "path", filePath)
60
61 ctx := r.Context()
62
63 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url}
64 resp, err := tangled.GitTempGetEntry(ctx, xrpcc, filePath, ref, f.RepoDid)
65 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
66 l.Error("failed to call XRPC git.getEntry", "xrpcerr", xrpcerr, "err", err)
67 rp.pages.Error503(w)
68 return
69 }
70
71 var breadcrumbs [][]string
72 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", reporesolver.GetBaseRepoPath(r, f), 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 blobView, err := func() (models.BlobView, error) {
80 mode, err := filemode.New(resp.Mode)
81 if err != nil {
82 mode = filemode.Regular
83 }
84
85 if mode == filemode.Submodule {
86 if resp.Submodule == nil {
87 return models.BlobView{}, fmt.Errorf("submodule info is missing")
88 }
89 return models.BlobView{
90 ContentType: models.BlobContentTypeSubmodule,
91 ContentSrc: resp.Submodule.Url,
92 }, nil
93 }
94
95 blobUrl := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath)
96 blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl, nil)
97 if err != nil {
98 return models.BlobView{}, err
99 }
100 blobResp, err := util.RobustHTTPClient().Do(blobReq)
101 if err != nil {
102 return models.BlobView{}, err
103 }
104 defer blobResp.Body.Close()
105
106 if blobResp.StatusCode != http.StatusOK {
107 return models.BlobView{}, fmt.Errorf("blob fetch failed: status %d", blobResp.StatusCode)
108 }
109
110 // inspect content-type header
111 // - text/plain -> Code / Markup
112 // - image/svg -> Svg
113 // - image/* -> Image
114 // - video/* -> Video
115 // - */* -> Other
116 mediaType, _, _ := mime.ParseMediaType(blobResp.Header.Get("Content-Type"))
117 var contentType models.BlobContentType
118 switch {
119 case mediaType == "image/svg+xml":
120 contentType = models.BlobContentTypeSvg
121 case strings.HasPrefix(mediaType, "text/"):
122 if markup.GetFormat(filePath) == markup.FormatMarkdown {
123 contentType = models.BlobContentTypeMarkup
124 } else {
125 contentType = models.BlobContentTypeCode
126 }
127 case strings.HasPrefix(mediaType, "image/"):
128 contentType = models.BlobContentTypeImage
129 case strings.HasPrefix(mediaType, "video/"):
130 contentType = models.BlobContentTypeVideo
131 default:
132 contentType = models.BlobContentTypeOther
133 }
134
135 // only text-viewable content is read inline; others stream via ContentSrc
136 if !contentType.HasTextView() {
137 return models.BlobView{
138 ContentType: contentType,
139 ContentSrc: blobUrl,
140 FileTooLarge: false,
141 Contents: "",
142 Lines: 0,
143 SizeHint: uint64(resp.Size),
144 }, nil
145 }
146
147 // skip large blobs
148 if resp.Size > maxBlobSize {
149 return models.BlobView{
150 ContentType: contentType,
151 ContentSrc: blobUrl,
152 FileTooLarge: true,
153 SizeHint: uint64(resp.Size),
154 }, nil
155 }
156
157 // just in case, ensure the size again
158 content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize))
159 if err != nil {
160 return models.BlobView{}, err
161 }
162
163 contentStr := string(content)
164 return models.BlobView{
165 ContentType: contentType,
166 ContentSrc: blobUrl,
167 Contents: contentStr,
168 FileTooLarge: false,
169 Lines: countLines(contentStr),
170 SizeHint: uint64(resp.Size),
171 }, nil
172 }()
173 if err != nil {
174 l.Error("failed to render blob", "err", err)
175 rp.pages.Error503(w)
176 return
177 }
178
179 // Get email to DID mapping for commit author
180 var emails []string
181 if resp.LastCommit != nil && resp.LastCommit.Author != nil {
182 emails = append(emails, resp.LastCommit.Author.Email)
183 }
184 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
185 if err != nil {
186 l.Error("failed to get email to did mapping", "err", err)
187 emailToDidMap = make(map[string]string)
188 }
189
190 var lastCommitInfo *types.LastCommitInfo
191 if resp.LastCommit != nil {
192 when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When)
193 lastCommitInfo = &types.LastCommitInfo{
194 Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)),
195 Message: resp.LastCommit.Message,
196 When: when,
197 }
198 if resp.LastCommit.Author != nil {
199 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name
200 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email
201 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When)
202 }
203 }
204
205 baseName := filepath.Base(filePath)
206 lang, ok := enry.GetLanguageByExtension(baseName)
207 if !ok {
208 lang, ok = enry.GetLanguageByFilename(baseName)
209 }
210 if !ok && blobView.Contents != "" {
211 lang = enry.GetLanguage(baseName, []byte(blobView.Contents))
212 }
213 if group := enry.GetLanguageGroup(lang); group != "" {
214 lang = group
215 }
216
217 user := rp.oauth.GetMultiAccountUser(r)
218 rp.pages.RepoBlob(w, pages.RepoBlobParams{
219 BaseParams: pages.BaseParamsFromContext(r.Context()),
220 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
221 BreadCrumbs: breadcrumbs,
222 BlobView: blobView,
223 EmailToDid: emailToDidMap,
224 LastCommitInfo: lastCommitInfo,
225 ShowRendered: r.URL.Query().Get("code") != "true",
226 Ref: ref,
227 Path: filePath,
228 Language: lang,
229 })
230}
231
232func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
233 l := rp.logger.With("handler", "RepoBlobRaw")
234
235 f, err := rp.repoResolver.Resolve(r)
236 if err != nil {
237 l.Error("failed to get repo and knot", "err", err)
238 w.WriteHeader(http.StatusBadRequest)
239 return
240 }
241
242 ref := chi.URLParam(r, "ref")
243 ref, _ = url.PathUnescape(ref)
244
245 filePath := chi.URLParam(r, "*")
246 filePath, _ = url.PathUnescape(filePath)
247
248 blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath)
249
250 if r.URL.Query().Get("download") == "1" {
251 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, blobURL, nil)
252 if err != nil {
253 w.WriteHeader(http.StatusInternalServerError)
254 return
255 }
256 resp, err := util.RobustHTTPClient().Do(req)
257 if err != nil || resp.StatusCode != http.StatusOK {
258 w.WriteHeader(http.StatusBadGateway)
259 return
260 }
261 defer resp.Body.Close()
262
263 filename := filepath.Base(filePath)
264 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
265 w.Header().Set("Content-Type", resp.Header.Get("Content-Type"))
266 w.Header().Set("Cache-Control", "public, no-cache")
267 io.Copy(w, resp.Body)
268 return
269 }
270
271 w.Header().Set("Cache-Control", "public, no-cache")
272 http.Redirect(w, r, blobURL, http.StatusFound)
273}
274
275// NewBlobView creates a BlobView from the XRPC response
276func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
277 view := models.BlobView{
278 Contents: "",
279 Lines: 0,
280 }
281
282 // Set size
283 if resp.Size != nil {
284 view.SizeHint = uint64(*resp.Size)
285 } else if resp.Content != nil {
286 view.SizeHint = uint64(len(*resp.Content))
287 }
288
289 if resp.Submodule != nil {
290 view.ContentType = models.BlobContentTypeSubmodule
291 view.ContentSrc = resp.Submodule.Url
292 return view
293 }
294
295 // Determine if binary
296 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) {
297 view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath)
298 ext := strings.ToLower(filepath.Ext(resp.Path))
299
300 switch ext {
301 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif":
302 view.ContentType = models.BlobContentTypeImage
303
304 case ".svg":
305 view.ContentType = models.BlobContentTypeSvg
306 if resp.Content != nil {
307 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
308 view.Contents = string(bytes)
309 view.Lines = countLines(view.Contents)
310 }
311
312 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
313 view.ContentType = models.BlobContentTypeVideo
314 }
315
316 return view
317 }
318
319 // otherwise, we are dealing with text content
320
321 if resp.Content != nil {
322 view.Contents = *resp.Content
323 view.Lines = countLines(view.Contents)
324 }
325
326 // with text, we may be dealing with markdown
327 format := markup.GetFormat(resp.Path)
328 if format == markup.FormatMarkdown {
329 view.ContentType = models.BlobContentTypeMarkup
330 }
331
332 return view
333}
334
335func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string {
336 query := url.Values{}
337 query.Set("repo", repo.RepoDid)
338 query.Set("ref", ref)
339 query.Set("path", filePath)
340
341 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode())
342 return blobURL
343 // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath)
344}
345
346// TODO: dedup with strings
347func countLines(content string) int {
348 if content == "" {
349 return 0
350 }
351
352 count := strings.Count(content, "\n")
353
354 if !strings.HasSuffix(content, "\n") {
355 count++
356 }
357
358 return count
359}