Monorepo for Tangled tangled.org

appview/repo: rework blob handler to use models.BlobView

Signed-off-by: oppiliappan <me@oppi.li>

oppi.li 391ae167 297e62a3

verified
Changed files
+152 -65
appview
pages
templates
repo
+8 -1
appview/pages/templates/repo/index.html
··· 35 35 {{ end }} 36 36 37 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 38 + <details class="group -my-4 -m-6 mb-4"> 39 39 <summary class="flex gap-[1px] h-4 scale-y-50 hover:scale-y-100 origin-top group-open:scale-y-100 transition-all hover:cursor-pointer overflow-hidden rounded-t"> 40 40 {{ range $value := .Languages }} 41 41 <div ··· 129 129 {{ $icon := "folder" }} 130 130 {{ $iconStyle := "size-4 fill-current" }} 131 131 132 + {{ if .IsSubmodule }} 133 + {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 + {{ $icon = "folder-input" }} 135 + {{ $iconStyle = "size-4" }} 136 + {{ end }} 137 + 132 138 {{ if .IsFile }} 133 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 140 {{ $icon = "file" }} 135 141 {{ $iconStyle = "size-4" }} 136 142 {{ end }} 143 + 137 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 145 <div class="flex items-center gap-2"> 139 146 {{ i $icon $iconStyle "flex-shrink-0" }}
+8
appview/pages/templates/repo/tree.html
··· 59 59 {{ $icon := "folder" }} 60 60 {{ $iconStyle := "size-4 fill-current" }} 61 61 62 + {{ if .IsSubmodule }} 63 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 64 + {{ $icon = "folder-input" }} 65 + {{ $iconStyle = "size-4" }} 66 + {{ end }} 67 + 62 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 63 70 {{ $icon = "file" }} 64 71 {{ $iconStyle = "size-4" }} 65 72 {{ end }} 73 + 66 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 75 <div class="flex items-center gap-2"> 68 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+136 -64
appview/repo/blob.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "encoding/base64" 4 5 "fmt" 5 6 "io" 6 7 "net/http" ··· 10 11 "strings" 11 12 12 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 13 16 "tangled.org/core/appview/pages" 14 17 "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 15 19 xrpcclient "tangled.org/core/appview/xrpcclient" 16 20 17 21 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 22 "github.com/go-chi/chi/v5" 19 23 ) 20 24 25 + // the content can be one of the following: 26 + // 27 + // - code : text | | raw 28 + // - markup : text | rendered | raw 29 + // - svg : text | rendered | raw 30 + // - png : | rendered | raw 31 + // - video : | rendered | raw 32 + // - submodule : | rendered | 33 + // - rest : | | 21 34 func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 22 35 l := rp.logger.With("handler", "RepoBlob") 36 + 23 37 f, err := rp.repoResolver.Resolve(r) 24 38 if err != nil { 25 39 l.Error("failed to get repo and knot", "err", err) 26 40 return 27 41 } 42 + 28 43 ref := chi.URLParam(r, "ref") 29 44 ref, _ = url.PathUnescape(ref) 45 + 30 46 filePath := chi.URLParam(r, "*") 31 47 filePath, _ = url.PathUnescape(filePath) 48 + 32 49 scheme := "http" 33 50 if !rp.config.Core.Dev { 34 51 scheme = "https" ··· 44 61 rp.pages.Error503(w) 45 62 return 46 63 } 64 + 47 65 // Use XRPC response directly instead of converting to internal types 48 66 var breadcrumbs [][]string 49 67 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) ··· 52 70 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 53 71 } 54 72 } 55 - showRendered := false 56 - renderToggle := false 57 - if markup.GetFormat(resp.Path) == markup.FormatMarkdown { 58 - renderToggle = true 59 - showRendered = r.URL.Query().Get("code") != "true" 60 - } 61 - var unsupported bool 62 - var isImage bool 63 - var isVideo bool 64 - var contentSrc string 65 - if resp.IsBinary != nil && *resp.IsBinary { 66 - ext := strings.ToLower(filepath.Ext(resp.Path)) 67 - switch ext { 68 - case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp": 69 - isImage = true 70 - case ".mp4", ".webm", ".ogg", ".mov", ".avi": 71 - isVideo = true 72 - default: 73 - unsupported = true 74 - } 75 - // fetch the raw binary content using sh.tangled.repo.blob xrpc 76 - repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 77 - baseURL := &url.URL{ 78 - Scheme: scheme, 79 - Host: f.Knot, 80 - Path: "/xrpc/sh.tangled.repo.blob", 81 - } 82 - query := baseURL.Query() 83 - query.Set("repo", repoName) 84 - query.Set("ref", ref) 85 - query.Set("path", filePath) 86 - query.Set("raw", "true") 87 - baseURL.RawQuery = query.Encode() 88 - blobURL := baseURL.String() 89 - contentSrc = blobURL 90 - if !rp.config.Core.Dev { 91 - contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL) 92 - } 93 - } 94 - lines := 0 95 - if resp.IsBinary == nil || !*resp.IsBinary { 96 - lines = strings.Count(resp.Content, "\n") + 1 97 - } 98 - var sizeHint uint64 99 - if resp.Size != nil { 100 - sizeHint = uint64(*resp.Size) 101 - } else { 102 - sizeHint = uint64(len(resp.Content)) 103 - } 73 + 74 + // Create the blob view 75 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 76 + 104 77 user := rp.oauth.GetUser(r) 105 - // Determine if content is binary (dereference pointer) 106 - isBinary := false 107 - if resp.IsBinary != nil { 108 - isBinary = *resp.IsBinary 109 - } 78 + 110 79 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 111 80 LoggedInUser: user, 112 81 RepoInfo: f.RepoInfo(user), 113 82 BreadCrumbs: breadcrumbs, 114 - ShowRendered: showRendered, 115 - RenderToggle: renderToggle, 116 - Unsupported: unsupported, 117 - IsImage: isImage, 118 - IsVideo: isVideo, 119 - ContentSrc: contentSrc, 83 + BlobView: blobView, 120 84 RepoBlob_Output: resp, 121 - Contents: resp.Content, 122 - Lines: lines, 123 - SizeHint: sizeHint, 124 - IsBinary: isBinary, 125 85 }) 126 86 } 127 87 128 88 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 129 89 l := rp.logger.With("handler", "RepoBlobRaw") 90 + 130 91 f, err := rp.repoResolver.Resolve(r) 131 92 if err != nil { 132 93 l.Error("failed to get repo and knot", "err", err) 133 94 w.WriteHeader(http.StatusBadRequest) 134 95 return 135 96 } 97 + 136 98 ref := chi.URLParam(r, "ref") 137 99 ref, _ = url.PathUnescape(ref) 100 + 138 101 filePath := chi.URLParam(r, "*") 139 102 filePath, _ = url.PathUnescape(filePath) 103 + 140 104 scheme := "http" 141 105 if !rp.config.Core.Dev { 142 106 scheme = "https" ··· 159 123 l.Error("failed to create request", "err", err) 160 124 return 161 125 } 126 + 162 127 // forward the If-None-Match header 163 128 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 164 129 req.Header.Set("If-None-Match", clientETag) 165 130 } 166 131 client := &http.Client{} 132 + 167 133 resp, err := client.Do(req) 168 134 if err != nil { 169 135 l.Error("failed to reach knotserver", "err", err) 170 136 rp.pages.Error503(w) 171 137 return 172 138 } 139 + 173 140 defer resp.Body.Close() 141 + 174 142 // forward 304 not modified 175 143 if resp.StatusCode == http.StatusNotModified { 176 144 w.WriteHeader(http.StatusNotModified) 177 145 return 178 146 } 147 + 179 148 if resp.StatusCode != http.StatusOK { 180 149 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 181 150 w.WriteHeader(resp.StatusCode) 182 151 _, _ = io.Copy(w, resp.Body) 183 152 return 184 153 } 154 + 185 155 contentType := resp.Header.Get("Content-Type") 186 156 body, err := io.ReadAll(resp.Body) 187 157 if err != nil { ··· 189 159 w.WriteHeader(http.StatusInternalServerError) 190 160 return 191 161 } 162 + 192 163 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 193 164 // serve all textual content as text/plain 194 165 w.Header().Set("Content-Type", "text/plain; charset=utf-8") ··· 202 173 w.Write([]byte("unsupported content type")) 203 174 return 204 175 } 176 + } 177 + 178 + // NewBlobView creates a BlobView from the XRPC response 179 + func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string, queryParams url.Values) models.BlobView { 180 + view := models.BlobView{ 181 + Contents: "", 182 + Lines: 0, 183 + } 184 + 185 + // Set size 186 + if resp.Size != nil { 187 + view.SizeHint = uint64(*resp.Size) 188 + } else if resp.Content != nil { 189 + view.SizeHint = uint64(len(*resp.Content)) 190 + } 191 + 192 + if resp.Submodule != nil { 193 + view.ContentType = models.BlobContentTypeSubmodule 194 + view.HasRenderedView = true 195 + view.ContentSrc = resp.Submodule.Url 196 + return view 197 + } 198 + 199 + // Determine if binary 200 + if resp.IsBinary != nil && *resp.IsBinary { 201 + view.ContentSrc = generateBlobURL(config, f, ref, filePath) 202 + ext := strings.ToLower(filepath.Ext(resp.Path)) 203 + 204 + switch ext { 205 + case ".jpg", ".jpeg", ".png", ".gif", ".webp": 206 + view.ContentType = models.BlobContentTypeImage 207 + view.HasRawView = true 208 + view.HasRenderedView = true 209 + view.ShowingRendered = true 210 + 211 + case ".svg": 212 + view.ContentType = models.BlobContentTypeSvg 213 + view.HasRawView = true 214 + view.HasTextView = true 215 + view.HasRenderedView = true 216 + view.ShowingRendered = queryParams.Get("code") != "true" 217 + if resp.Content != nil { 218 + bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 219 + view.Contents = string(bytes) 220 + view.Lines = strings.Count(view.Contents, "\n") + 1 221 + } 222 + 223 + case ".mp4", ".webm", ".ogg", ".mov", ".avi": 224 + view.ContentType = models.BlobContentTypeVideo 225 + view.HasRawView = true 226 + view.HasRenderedView = true 227 + view.ShowingRendered = true 228 + } 229 + 230 + return view 231 + } 232 + 233 + // otherwise, we are dealing with text content 234 + view.HasRawView = true 235 + view.HasTextView = true 236 + 237 + if resp.Content != nil { 238 + view.Contents = *resp.Content 239 + view.Lines = strings.Count(view.Contents, "\n") + 1 240 + } 241 + 242 + // with text, we may be dealing with markdown 243 + format := markup.GetFormat(resp.Path) 244 + if format == markup.FormatMarkdown { 245 + view.ContentType = models.BlobContentTypeMarkup 246 + view.HasRenderedView = true 247 + view.ShowingRendered = queryParams.Get("code") != "true" 248 + } 249 + 250 + return view 251 + } 252 + 253 + func generateBlobURL(config *config.Config, f *reporesolver.ResolvedRepo, ref, filePath string) string { 254 + scheme := "http" 255 + if !config.Core.Dev { 256 + scheme = "https" 257 + } 258 + 259 + repoName := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 260 + baseURL := &url.URL{ 261 + Scheme: scheme, 262 + Host: f.Knot, 263 + Path: "/xrpc/sh.tangled.repo.blob", 264 + } 265 + query := baseURL.Query() 266 + query.Set("repo", repoName) 267 + query.Set("ref", ref) 268 + query.Set("path", filePath) 269 + query.Set("raw", "true") 270 + baseURL.RawQuery = query.Encode() 271 + blobURL := baseURL.String() 272 + 273 + if !config.Core.Dev { 274 + return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 275 + } 276 + return blobURL 205 277 } 206 278 207 279 func isTextualMimeType(mimeType string) bool {