From e99c34b8722f07cb92a6e89ef5c500f92be5eecd Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Sun, 31 May 2026 08:08:20 +0000 Subject: [PATCH] appview: use `git.getEntry` over `repo.blob` Signed-off-by: Seongmin Lee --- appview/models/repo.go | 45 +++++++++++++++------------------------------ appview/pages/pages.go | 5 +++-- appview/pages/templates/repo/blob.html | 28 ++++++++++++++-------------- appview/repo/blob.go | 159 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------- knotmirror/xrpc/git_get_blob.go | 5 ++++- 5 file(s) changed, 161 insertion(s)(+), 81 deletion(s)(-) diff --git a/appview/models/repo.go b/appview/models/repo.go --- a/appview/models/repo.go +++ b/appview/models/repo.go @@ -210,6 +210,7 @@ BlobContentTypeImage BlobContentTypeSvg BlobContentTypeVideo BlobContentTypeSubmodule + BlobContentTypeOther ) func (ty BlobContentType) IsCode() bool { return ty == BlobContentTypeCode } @@ -218,40 +219,24 @@ func (ty BlobContentType) IsImage() bool { return ty == BlobContentTypeImage } func (ty BlobContentType) IsSvg() bool { return ty == BlobContentTypeSvg } func (ty BlobContentType) IsVideo() bool { return ty == BlobContentTypeVideo } func (ty BlobContentType) IsSubmodule() bool { return ty == BlobContentTypeSubmodule } +func (ty BlobContentType) HasTextView() bool { + return ty == BlobContentTypeCode || ty == BlobContentTypeMarkup || ty == BlobContentTypeSvg +} +func (ty BlobContentType) HasRenderedView() bool { + return ty != BlobContentTypeCode && ty != BlobContentTypeOther +} +func (ty BlobContentType) HasRawView() bool { + return ty != BlobContentTypeSubmodule +} type BlobView struct { - HasTextView bool // can show as code/text - HasRenderedView bool // can show rendered (markup/image/video/submodule) - HasRawView bool // can download raw (everything except submodule) - FileTooLarge bool // file too large (ignored for image files) - - // current display mode - ShowingRendered bool // currently in rendered mode - // content type flags ContentType BlobContentType // Content data - Contents string - ContentSrc string // URL for media files - Lines int - SizeHint uint64 -} - -// if both views are available, then show a toggle between them -func (b BlobView) ShowToggle() bool { - return b.HasTextView && b.HasRenderedView -} - -func (b BlobView) IsUnsupported() bool { - // no view available, only raw - return !(b.HasRenderedView || b.HasTextView) -} - -func (b BlobView) ShowingText() bool { - return !b.ShowingRendered -} - -func (b BlobView) ShowCopy() bool { - return b.ContentType.IsCode() || b.ContentType.IsMarkup() || b.ContentType.IsSvg() || b.ContentType.IsImage() + ContentSrc string // URL to raw content + Contents string // textual content + FileTooLarge bool // textual content is too large + Lines int // line count of textual content + SizeHint uint64 } diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1092,9 +1092,10 @@ type RepoBlobParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo - Active string + Active string // always "overview" BreadCrumbs [][]string - BlobView models.BlobView + BlobView models.BlobView // TODO: expose this struct + ShowRendered bool EmailToDid map[string]string LastCommitInfo *types.LastCommitInfo Ref string diff --git a/appview/pages/templates/repo/blob.html b/appview/pages/templates/repo/blob.html --- a/appview/pages/templates/repo/blob.html +++ b/appview/pages/templates/repo/blob.html @@ -33,7 +33,7 @@
at {{ .Ref }} - {{ if .BlobView.ShowingText }} + {{ if (and .BlobView.ContentType.HasTextView (not .ShowRendered)) }} {{ .BlobView.Lines }} lines {{ end }} @@ -43,24 +43,24 @@ {{ byteFmt .BlobView.SizeHint }} {{ end }} - {{ if .BlobView.HasRawView }} + {{ if .BlobView.ContentType.HasRawView }} View raw {{ end }} - {{ if .BlobView.ShowCopy }} + {{ if (or .BlobView.ContentType.HasTextView .BlobView.ContentType.IsImage) }} {{ template "repo/fragments/copyFileButton" (dict "Url" (printf "/%s/raw/%s/%s" .RepoInfo.FullName (pathEscape .Ref) .Path)) }} {{ end }} - {{ if .BlobView.ShowToggle }} + {{ if (and .BlobView.ContentType.HasTextView .BlobView.ContentType.HasRenderedView) }} - - View {{ if .BlobView.ShowingRendered }}code{{ else }}rendered{{ end }} + + View {{ if .ShowRendered }}code{{ else }}rendered{{ end }} {{ end }} - {{ if .BlobView.ShowingText }} + {{ if (and .BlobView.ContentType.HasTextView (not .ShowRendered)) }}
{{ else if .BlobView.ContentType.IsSvg }}
- {{ if .BlobView.ShowingRendered }} + {{ if .ShowRendered }}
{{ .Path }}View raw..

{{ else if .BlobView.ContentType.IsMarkup }}
- {{ if .BlobView.ShowingRendered }} + {{ if .ShowRendered }}
{{ .BlobView.Contents | readme }}
{{ else }}
{{ code .BlobView.Contents .Path | escapeHtml }}
@@ -128,6 +124,10 @@ {{ else if .BlobView.ContentType.IsCode }}
{{ code .BlobView.Contents .Path | escapeHtml }}
+ {{ else }} +

+ Previews are not supported for this file type. +

{{ end }} {{ template "fragments/multiline-select" }} {{ template "repo/fragments/copyFileScript" }} diff --git a/appview/repo/blob.go b/appview/repo/blob.go --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -3,6 +3,8 @@ import ( "encoding/base64" "fmt" + "io" + "mime" "net/http" "net/url" "path/filepath" @@ -19,20 +21,25 @@ "tangled.org/core/appview/reporesolver" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/types" + "github.com/bluesky-social/indigo/util" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" ) +// maxBlobSize bounds inline text content; larger blobs are marked too large. +const maxBlobSize = 1 << 20 // 1MiB + // the content can be one of the following: // // - code : text | | raw // - markup : text | rendered | raw // - svg : text | rendered | raw -// - png : | rendered | raw +// - image : | rendered | raw // - video : | rendered | raw // - submodule : | rendered | -// - rest : | | +// - rest : | | raw func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoBlob") @@ -48,29 +55,125 @@ filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) + l = l.With("ref", ref, "path", filePath) + + ctx := r.Context() + xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoDid) + resp, err := tangled.GitTempGetEntry(ctx, xrpcc, filePath, ref, f.RepoDid) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { - l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err) + l.Error("failed to call XRPC git.getEntry", "xrpcerr", xrpcerr, "err", err) rp.pages.Error503(w) return } - ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) - - // Use XRPC response directly instead of converting to internal types var breadcrumbs [][]string - breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) + breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", reporesolver.GetBaseRepoPath(r, f), url.PathEscape(ref))}) if filePath != "" { for idx, elem := range strings.Split(filePath, "/") { breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) } } - // Create the blob view - blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) + blobView, err := func() (models.BlobView, error) { + mode, err := filemode.New(resp.Mode) + if err != nil { + mode = filemode.Regular + } + + if mode == filemode.Submodule { + if resp.Submodule == nil { + return models.BlobView{}, fmt.Errorf("submodule info is missing") + } + return models.BlobView{ + ContentType: models.BlobContentTypeSubmodule, + ContentSrc: resp.Submodule.Url, + }, nil + } + + blobUrl := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) + blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl, nil) + if err != nil { + return models.BlobView{}, err + } + blobResp, err := util.RobustHTTPClient().Do(blobReq) + if err != nil { + return models.BlobView{}, err + } + defer blobResp.Body.Close() + + if blobResp.StatusCode != http.StatusOK { + return models.BlobView{}, fmt.Errorf("blob fetch failed: status %d", blobResp.StatusCode) + } + + // inspect content-type header + // - text/plain -> Code / Markup + // - image/svg -> Svg + // - image/* -> Image + // - video/* -> Video + // - */* -> Other + mediaType, _, _ := mime.ParseMediaType(blobResp.Header.Get("Content-Type")) + var contentType models.BlobContentType + switch { + case mediaType == "image/svg+xml": + contentType = models.BlobContentTypeSvg + case strings.HasPrefix(mediaType, "text/"): + if markup.GetFormat(filePath) == markup.FormatMarkdown { + contentType = models.BlobContentTypeMarkup + } else { + contentType = models.BlobContentTypeCode + } + case strings.HasPrefix(mediaType, "image/"): + contentType = models.BlobContentTypeImage + case strings.HasPrefix(mediaType, "video/"): + contentType = models.BlobContentTypeVideo + default: + contentType = models.BlobContentTypeOther + } + + // only text-viewable content is read inline; others stream via ContentSrc + if !contentType.HasTextView() { + return models.BlobView{ + ContentType: contentType, + ContentSrc: blobUrl, + FileTooLarge: false, + Contents: "", + Lines: 0, + SizeHint: uint64(max(blobResp.ContentLength, 0)), + }, nil + } - user := rp.oauth.GetMultiAccountUser(r) + // skip large blobs + if blobResp.ContentLength > maxBlobSize || blobResp.ContentLength < 0 { + return models.BlobView{ + ContentType: contentType, + ContentSrc: blobUrl, + FileTooLarge: true, + SizeHint: uint64(max(blobResp.ContentLength, 0)), + }, nil + } + + // just in case, check the size again + content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize)) + if err != nil { + return models.BlobView{}, err + } + + contentStr := string(content) + return models.BlobView{ + ContentType: contentType, + ContentSrc: blobUrl, + Contents: contentStr, + FileTooLarge: false, + Lines: countLines(contentStr), + SizeHint: uint64(max(blobResp.ContentLength, 0)), + }, nil + }() + if err != nil { + l.Error("failed to render blob", "err", err) + rp.pages.Error503(w) + return + } // Get email to DID mapping for commit author var emails []string @@ -85,9 +188,9 @@ } var lastCommitInfo *types.LastCommitInfo if resp.LastCommit != nil { - when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) + when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When) lastCommitInfo = &types.LastCommitInfo{ - Hash: plumbing.NewHash(resp.LastCommit.Hash), + Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)), Message: resp.LastCommit.Message, When: when, } @@ -98,6 +201,7 @@ lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) } } + user := rp.oauth.GetMultiAccountUser(r) rp.pages.RepoBlob(w, pages.RepoBlobParams{ LoggedInUser: user, RepoInfo: rp.repoResolver.GetRepoInfo(r, user), @@ -105,8 +209,9 @@ BreadCrumbs: breadcrumbs, BlobView: blobView, EmailToDid: emailToDidMap, LastCommitInfo: lastCommitInfo, - Ref: resp.Ref, - Path: resp.Path, + ShowRendered: r.URL.Query().Get("code") != "true", + Ref: ref, + Path: filePath, }) } @@ -126,7 +231,7 @@ filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) - blobURL := generateBlobURL(rp.config, f, ref, filePath) + blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) w.Header().Set("Cache-Control", "public, no-cache") http.Redirect(w, r, blobURL, http.StatusFound) @@ -148,29 +253,21 @@ } if resp.Submodule != nil { view.ContentType = models.BlobContentTypeSubmodule - view.HasRenderedView = true view.ContentSrc = resp.Submodule.Url return view } // Determine if binary if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { - view.ContentSrc = generateBlobURL(config, repo, ref, filePath) + view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath) ext := strings.ToLower(filepath.Ext(resp.Path)) switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": view.ContentType = models.BlobContentTypeImage - view.HasRawView = true - view.HasRenderedView = true - view.ShowingRendered = true case ".svg": view.ContentType = models.BlobContentTypeSvg - view.HasRawView = true - view.HasTextView = true - view.HasRenderedView = true - view.ShowingRendered = queryParams.Get("code") != "true" if resp.Content != nil { bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) view.Contents = string(bytes) @@ -179,17 +276,12 @@ } case ".mp4", ".webm", ".ogg", ".mov", ".avi": view.ContentType = models.BlobContentTypeVideo - view.HasRawView = true - view.HasRenderedView = true - view.ShowingRendered = true } return view } // otherwise, we are dealing with text content - view.HasRawView = true - view.HasTextView = true if resp.Content != nil { view.Contents = *resp.Content @@ -200,21 +292,20 @@ // with text, we may be dealing with markdown format := markup.GetFormat(resp.Path) if format == markup.FormatMarkdown { view.ContentType = models.BlobContentTypeMarkup - view.HasRenderedView = true - view.ShowingRendered = queryParams.Get("code") != "true" } return view } -func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { +func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string { query := url.Values{} query.Set("repo", repo.RepoDid) query.Set("ref", ref) query.Set("path", filePath) - blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) + blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode()) return blobURL + // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath) } // TODO: dedup with strings diff --git a/knotmirror/xrpc/git_get_blob.go b/knotmirror/xrpc/git_get_blob.go --- a/knotmirror/xrpc/git_get_blob.go +++ b/knotmirror/xrpc/git_get_blob.go @@ -7,6 +7,7 @@ "io" "net/http" "path/filepath" "slices" + "strconv" "strings" "github.com/bluesky-social/indigo/atproto/atclient" @@ -57,8 +58,10 @@ return } defer reader.Close() + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + // default to octet-stream for large blobs - if size > 1000*1000 { // 1MB + if size > 1024*1024 { // 1MiB w.Header().Set("Content-Type", "application/octet-stream") if _, err := io.Copy(w, reader); err != nil { l.Error("failed to serve the blob", "err", err) -- tangled.sh