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