Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

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

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

authored by oppi.li and committed by

Tangled 272a0aa3 496e8e02

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