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(resp.Size),
143 }, nil
144 }
145
146 // skip large blobs
147 if resp.Size > maxBlobSize {
148 return models.BlobView{
149 ContentType: contentType,
150 ContentSrc: blobUrl,
151 FileTooLarge: true,
152 SizeHint: uint64(resp.Size),
153 }, nil
154 }
155
156 // just in case, ensure the size again
157 content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize))
158 if err != nil {
159 return models.BlobView{}, err
160 }
161
162 contentStr := string(content)
163 return models.BlobView{
164 ContentType: contentType,
165 ContentSrc: blobUrl,
166 Contents: contentStr,
167 FileTooLarge: false,
168 Lines: countLines(contentStr),
169 SizeHint: uint64(resp.Size),
170 }, nil
171 }()
172 if err != nil {
173 l.Error("failed to render blob", "err", err)
174 rp.pages.Error503(w)
175 return
176 }
177
178 // Get email to DID mapping for commit author
179 var emails []string
180 if resp.LastCommit != nil && resp.LastCommit.Author != nil {
181 emails = append(emails, resp.LastCommit.Author.Email)
182 }
183 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true)
184 if err != nil {
185 l.Error("failed to get email to did mapping", "err", err)
186 emailToDidMap = make(map[string]string)
187 }
188
189 var lastCommitInfo *types.LastCommitInfo
190 if resp.LastCommit != nil {
191 when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When)
192 lastCommitInfo = &types.LastCommitInfo{
193 Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)),
194 Message: resp.LastCommit.Message,
195 When: when,
196 }
197 if resp.LastCommit.Author != nil {
198 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name
199 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email
200 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When)
201 }
202 }
203
204 user := rp.oauth.GetMultiAccountUser(r)
205 rp.pages.RepoBlob(w, pages.RepoBlobParams{
206 BaseParams: pages.BaseParamsFromContext(r.Context()),
207 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
208 BreadCrumbs: breadcrumbs,
209 BlobView: blobView,
210 EmailToDid: emailToDidMap,
211 LastCommitInfo: lastCommitInfo,
212 ShowRendered: r.URL.Query().Get("code") != "true",
213 Ref: ref,
214 Path: filePath,
215 })
216}
217
218func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
219 l := rp.logger.With("handler", "RepoBlobRaw")
220
221 f, err := rp.repoResolver.Resolve(r)
222 if err != nil {
223 l.Error("failed to get repo and knot", "err", err)
224 w.WriteHeader(http.StatusBadRequest)
225 return
226 }
227
228 ref := chi.URLParam(r, "ref")
229 ref, _ = url.PathUnescape(ref)
230
231 filePath := chi.URLParam(r, "*")
232 filePath, _ = url.PathUnescape(filePath)
233
234 blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath)
235
236 w.Header().Set("Cache-Control", "public, no-cache")
237 http.Redirect(w, r, blobURL, http.StatusFound)
238}
239
240// NewBlobView creates a BlobView from the XRPC response
241func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView {
242 view := models.BlobView{
243 Contents: "",
244 Lines: 0,
245 }
246
247 // Set size
248 if resp.Size != nil {
249 view.SizeHint = uint64(*resp.Size)
250 } else if resp.Content != nil {
251 view.SizeHint = uint64(len(*resp.Content))
252 }
253
254 if resp.Submodule != nil {
255 view.ContentType = models.BlobContentTypeSubmodule
256 view.ContentSrc = resp.Submodule.Url
257 return view
258 }
259
260 // Determine if binary
261 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) {
262 view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath)
263 ext := strings.ToLower(filepath.Ext(resp.Path))
264
265 switch ext {
266 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif":
267 view.ContentType = models.BlobContentTypeImage
268
269 case ".svg":
270 view.ContentType = models.BlobContentTypeSvg
271 if resp.Content != nil {
272 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content)
273 view.Contents = string(bytes)
274 view.Lines = countLines(view.Contents)
275 }
276
277 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
278 view.ContentType = models.BlobContentTypeVideo
279 }
280
281 return view
282 }
283
284 // otherwise, we are dealing with text content
285
286 if resp.Content != nil {
287 view.Contents = *resp.Content
288 view.Lines = countLines(view.Contents)
289 }
290
291 // with text, we may be dealing with markdown
292 format := markup.GetFormat(resp.Path)
293 if format == markup.FormatMarkdown {
294 view.ContentType = models.BlobContentTypeMarkup
295 }
296
297 return view
298}
299
300func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string {
301 query := url.Values{}
302 query.Set("repo", repo.RepoDid)
303 query.Set("ref", ref)
304 query.Set("path", filePath)
305
306 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode())
307 return blobURL
308 // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath)
309}
310
311// TODO: dedup with strings
312func countLines(content string) int {
313 if content == "" {
314 return 0
315 }
316
317 count := strings.Count(content, "\n")
318
319 if !strings.HasSuffix(content, "\n") {
320 count++
321 }
322
323 return count
324}