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