From 391ae167f7091545e1c7feb0f53df73cd80e4034 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Tue, 11 Nov 2025 11:23:41 +0000 Subject: [PATCH] appview/repo: rework blob handler to use models.BlobView Change-Id: lqyxyyrozyxsooxktqxllvtztmomykso Signed-off-by: oppiliappan --- appview/pages/templates/repo/index.html | 9 +- appview/pages/templates/repo/tree.html | 8 + appview/repo/blob.go | 200 ++++++++++++++++-------- 3 files changed, 152 insertions(+), 65 deletions(-) diff --git a/appview/pages/templates/repo/index.html b/appview/pages/templates/repo/index.html index 6ce13417..372a565d 100644 --- a/appview/pages/templates/repo/index.html +++ b/appview/pages/templates/repo/index.html @@ -35,7 +35,7 @@ {{ end }} {{ define "repoLanguages" }} -
+
{{ range $value := .Languages }}
{{ i $icon $iconStyle "flex-shrink-0" }} diff --git a/appview/pages/templates/repo/tree.html b/appview/pages/templates/repo/tree.html index 3d82cba5..587baa00 100644 --- a/appview/pages/templates/repo/tree.html +++ b/appview/pages/templates/repo/tree.html @@ -59,10 +59,18 @@ {{ $icon := "folder" }} {{ $iconStyle := "size-4 fill-current" }} + {{ if .IsSubmodule }} + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} + {{ $icon = "folder-input" }} + {{ $iconStyle = "size-4" }} + {{ end }} + {{ if .IsFile }} + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} {{ $icon = "file" }} {{ $iconStyle = "size-4" }} {{ end }} +
{{ i $icon $iconStyle "flex-shrink-0" }} diff --git a/appview/repo/blob.go b/appview/repo/blob.go index 9aebcb1b..481753bb 100644 --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -1,6 +1,7 @@ package repo import ( + "encoding/base64" "fmt" "io" "net/http" @@ -10,25 +11,41 @@ import ( "strings" "tangled.org/core/api/tangled" + "tangled.org/core/appview/config" + "tangled.org/core/appview/models" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup" + "tangled.org/core/appview/reporesolver" xrpcclient "tangled.org/core/appview/xrpcclient" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" ) +// the content can be one of the following: +// +// - code : text | | raw +// - markup : text | rendered | raw +// - svg : text | rendered | raw +// - png : | rendered | raw +// - video : | rendered | raw +// - submodule : | rendered | +// - rest : | | func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoBlob") + f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return } + ref := chi.URLParam(r, "ref") ref, _ = url.PathUnescape(ref) + filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) + scheme := "http" if !rp.config.Core.Dev { scheme = "https" @@ -44,6 +61,7 @@ func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { rp.pages.Error503(w) return } + // Use XRPC response directly instead of converting to internal types var breadcrumbs [][]string breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) @@ -52,91 +70,37 @@ func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) } } - showRendered := false - renderToggle := false - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { - renderToggle = true - showRendered = r.URL.Query().Get("code") != "true" - } - var unsupported bool - var isImage bool - var isVideo bool - var contentSrc string - if resp.IsBinary != nil && *resp.IsBinary { - ext := strings.ToLower(filepath.Ext(resp.Path)) - switch ext { - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": - isImage = true - case ".mp4", ".webm", ".ogg", ".mov", ".avi": - isVideo = true - default: - unsupported = true - } - // fetch the raw binary content using sh.tangled.repo.blob xrpc - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) - baseURL := &url.URL{ - Scheme: scheme, - Host: f.Knot, - Path: "/xrpc/sh.tangled.repo.blob", - } - query := baseURL.Query() - query.Set("repo", repoName) - query.Set("ref", ref) - query.Set("path", filePath) - query.Set("raw", "true") - baseURL.RawQuery = query.Encode() - blobURL := baseURL.String() - contentSrc = blobURL - if !rp.config.Core.Dev { - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) - } - } - lines := 0 - if resp.IsBinary == nil || !*resp.IsBinary { - lines = strings.Count(resp.Content, "\n") + 1 - } - var sizeHint uint64 - if resp.Size != nil { - sizeHint = uint64(*resp.Size) - } else { - sizeHint = uint64(len(resp.Content)) - } + + // Create the blob view + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) + user := rp.oauth.GetUser(r) - // Determine if content is binary (dereference pointer) - isBinary := false - if resp.IsBinary != nil { - isBinary = *resp.IsBinary - } + rp.pages.RepoBlob(w, pages.RepoBlobParams{ LoggedInUser: user, RepoInfo: f.RepoInfo(user), BreadCrumbs: breadcrumbs, - ShowRendered: showRendered, - RenderToggle: renderToggle, - Unsupported: unsupported, - IsImage: isImage, - IsVideo: isVideo, - ContentSrc: contentSrc, + BlobView: blobView, RepoBlob_Output: resp, - Contents: resp.Content, - Lines: lines, - SizeHint: sizeHint, - IsBinary: isBinary, }) } func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "RepoBlobRaw") + f, err := rp.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) w.WriteHeader(http.StatusBadRequest) return } + ref := chi.URLParam(r, "ref") ref, _ = url.PathUnescape(ref) + filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) + scheme := "http" if !rp.config.Core.Dev { scheme = "https" @@ -159,29 +123,35 @@ func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { l.Error("failed to create request", "err", err) return } + // forward the If-None-Match header if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { req.Header.Set("If-None-Match", clientETag) } client := &http.Client{} + resp, err := client.Do(req) if err != nil { l.Error("failed to reach knotserver", "err", err) rp.pages.Error503(w) return } + defer resp.Body.Close() + // forward 304 not modified if resp.StatusCode == http.StatusNotModified { w.WriteHeader(http.StatusNotModified) return } + if resp.StatusCode != http.StatusOK { l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) w.WriteHeader(resp.StatusCode) _, _ = io.Copy(w, resp.Body) return } + contentType := resp.Header.Get("Content-Type") body, err := io.ReadAll(resp.Body) if err != nil { @@ -189,6 +159,7 @@ func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) return } + if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { // serve all textual content as text/plain w.Header().Set("Content-Type", "text/plain; charset=utf-8") @@ -204,6 +175,107 @@ func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { } } +// NewBlobView creates a BlobView from the XRPC response +func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { + view := models.BlobView{ + Contents: "", + Lines: 0, + } + + // Set size + if resp.Size != nil { + view.SizeHint = uint64(*resp.Size) + } else if resp.Content != nil { + view.SizeHint = uint64(len(*resp.Content)) + } + + 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 { + view.ContentSrc = generateBlobURL(config, f, ref, filePath) + ext := strings.ToLower(filepath.Ext(resp.Path)) + + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp": + 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) + view.Lines = strings.Count(view.Contents, "\n") + 1 + } + + 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 + view.Lines = strings.Count(view.Contents, "\n") + 1 + } + + // 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, f *reporesolver.ResolvedRepo, ref, filePath string) string { + scheme := "http" + if !config.Core.Dev { + scheme = "https" + } + + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) + baseURL := &url.URL{ + Scheme: scheme, + Host: f.Knot, + Path: "/xrpc/sh.tangled.repo.blob", + } + query := baseURL.Query() + query.Set("repo", repoName) + query.Set("ref", ref) + query.Set("path", filePath) + query.Set("raw", "true") + baseURL.RawQuery = query.Encode() + blobURL := baseURL.String() + + if !config.Core.Dev { + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) + } + return blobURL +} + func isTextualMimeType(mimeType string) bool { textualTypes := []string{ "application/json", -- 2.43.0