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

merged
opened by oppi.li targeting master from push-lqyxyyrozyxs
Changed files
+152 -65
appview
pages
templates
repo
+8 -1
appview/pages/templates/repo/index.html
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 - <details class="group -m-6 mb-4"> 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 {{ range $value := .Languages }} 41 <div ··· 129 {{ $icon := "folder" }} 130 {{ $iconStyle := "size-4 fill-current" }} 131 132 {{ if .IsFile }} 133 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 134 {{ $icon = "file" }} 135 {{ $iconStyle = "size-4" }} 136 {{ end }} 137 <a href="{{ $link }}" class="{{ $linkstyle }}"> 138 <div class="flex items-center gap-2"> 139 {{ i $icon $iconStyle "flex-shrink-0" }}
··· 35 {{ end }} 36 37 {{ define "repoLanguages" }} 38 + <details class="group -my-4 -m-6 mb-4"> 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 {{ range $value := .Languages }} 41 <div ··· 129 {{ $icon := "folder" }} 130 {{ $iconStyle := "size-4 fill-current" }} 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 + 138 {{ if .IsFile }} 139 {{ $link = printf "/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) .Name }} 140 {{ $icon = "file" }} 141 {{ $iconStyle = "size-4" }} 142 {{ end }} 143 + 144 <a href="{{ $link }}" class="{{ $linkstyle }}"> 145 <div class="flex items-center gap-2"> 146 {{ i $icon $iconStyle "flex-shrink-0" }}
+8
appview/pages/templates/repo/tree.html
··· 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 61 62 {{ if .IsFile }} 63 {{ $icon = "file" }} 64 {{ $iconStyle = "size-4" }} 65 {{ end }} 66 <a href="{{ $link }}" class="{{ $linkstyle }}"> 67 <div class="flex items-center gap-2"> 68 {{ i $icon $iconStyle "flex-shrink-0" }}
··· 59 {{ $icon := "folder" }} 60 {{ $iconStyle := "size-4 fill-current" }} 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 + 68 {{ if .IsFile }} 69 + {{ $link = printf "/%s/%s/%s/%s/%s" $.RepoInfo.FullName "blob" (urlquery $.Ref) $.TreePath .Name }} 70 {{ $icon = "file" }} 71 {{ $iconStyle = "size-4" }} 72 {{ end }} 73 + 74 <a href="{{ $link }}" class="{{ $linkstyle }}"> 75 <div class="flex items-center gap-2"> 76 {{ i $icon $iconStyle "flex-shrink-0" }}
+136 -64
appview/repo/blob.go
··· 1 package repo 2 3 import ( 4 "fmt" 5 "io" 6 "net/http" ··· 10 "strings" 11 12 "tangled.org/core/api/tangled" 13 "tangled.org/core/appview/pages" 14 "tangled.org/core/appview/pages/markup" 15 xrpcclient "tangled.org/core/appview/xrpcclient" 16 17 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 "github.com/go-chi/chi/v5" 19 ) 20 21 func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 22 l := rp.logger.With("handler", "RepoBlob") 23 f, err := rp.repoResolver.Resolve(r) 24 if err != nil { 25 l.Error("failed to get repo and knot", "err", err) 26 return 27 } 28 ref := chi.URLParam(r, "ref") 29 ref, _ = url.PathUnescape(ref) 30 filePath := chi.URLParam(r, "*") 31 filePath, _ = url.PathUnescape(filePath) 32 scheme := "http" 33 if !rp.config.Core.Dev { 34 scheme = "https" ··· 44 rp.pages.Error503(w) 45 return 46 } 47 // Use XRPC response directly instead of converting to internal types 48 var breadcrumbs [][]string 49 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) ··· 52 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 53 } 54 } 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 - } 104 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 - } 110 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 111 LoggedInUser: user, 112 RepoInfo: f.RepoInfo(user), 113 BreadCrumbs: breadcrumbs, 114 - ShowRendered: showRendered, 115 - RenderToggle: renderToggle, 116 - Unsupported: unsupported, 117 - IsImage: isImage, 118 - IsVideo: isVideo, 119 - ContentSrc: contentSrc, 120 RepoBlob_Output: resp, 121 - Contents: resp.Content, 122 - Lines: lines, 123 - SizeHint: sizeHint, 124 - IsBinary: isBinary, 125 }) 126 } 127 128 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 129 l := rp.logger.With("handler", "RepoBlobRaw") 130 f, err := rp.repoResolver.Resolve(r) 131 if err != nil { 132 l.Error("failed to get repo and knot", "err", err) 133 w.WriteHeader(http.StatusBadRequest) 134 return 135 } 136 ref := chi.URLParam(r, "ref") 137 ref, _ = url.PathUnescape(ref) 138 filePath := chi.URLParam(r, "*") 139 filePath, _ = url.PathUnescape(filePath) 140 scheme := "http" 141 if !rp.config.Core.Dev { 142 scheme = "https" ··· 159 l.Error("failed to create request", "err", err) 160 return 161 } 162 // forward the If-None-Match header 163 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 164 req.Header.Set("If-None-Match", clientETag) 165 } 166 client := &http.Client{} 167 resp, err := client.Do(req) 168 if err != nil { 169 l.Error("failed to reach knotserver", "err", err) 170 rp.pages.Error503(w) 171 return 172 } 173 defer resp.Body.Close() 174 // forward 304 not modified 175 if resp.StatusCode == http.StatusNotModified { 176 w.WriteHeader(http.StatusNotModified) 177 return 178 } 179 if resp.StatusCode != http.StatusOK { 180 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 181 w.WriteHeader(resp.StatusCode) 182 _, _ = io.Copy(w, resp.Body) 183 return 184 } 185 contentType := resp.Header.Get("Content-Type") 186 body, err := io.ReadAll(resp.Body) 187 if err != nil { ··· 189 w.WriteHeader(http.StatusInternalServerError) 190 return 191 } 192 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 193 // serve all textual content as text/plain 194 w.Header().Set("Content-Type", "text/plain; charset=utf-8") ··· 204 } 205 } 206 207 func isTextualMimeType(mimeType string) bool { 208 textualTypes := []string{ 209 "application/json",
··· 1 package repo 2 3 import ( 4 + "encoding/base64" 5 "fmt" 6 "io" 7 "net/http" ··· 11 "strings" 12 13 "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/config" 15 + "tangled.org/core/appview/models" 16 "tangled.org/core/appview/pages" 17 "tangled.org/core/appview/pages/markup" 18 + "tangled.org/core/appview/reporesolver" 19 xrpcclient "tangled.org/core/appview/xrpcclient" 20 21 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 22 "github.com/go-chi/chi/v5" 23 ) 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 : | | 34 func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 35 l := rp.logger.With("handler", "RepoBlob") 36 + 37 f, err := rp.repoResolver.Resolve(r) 38 if err != nil { 39 l.Error("failed to get repo and knot", "err", err) 40 return 41 } 42 + 43 ref := chi.URLParam(r, "ref") 44 ref, _ = url.PathUnescape(ref) 45 + 46 filePath := chi.URLParam(r, "*") 47 filePath, _ = url.PathUnescape(filePath) 48 + 49 scheme := "http" 50 if !rp.config.Core.Dev { 51 scheme = "https" ··· 61 rp.pages.Error503(w) 62 return 63 } 64 + 65 // Use XRPC response directly instead of converting to internal types 66 var breadcrumbs [][]string 67 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), url.PathEscape(ref))}) ··· 70 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 71 } 72 } 73 + 74 + // Create the blob view 75 + blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 76 + 77 user := rp.oauth.GetUser(r) 78 + 79 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 80 LoggedInUser: user, 81 RepoInfo: f.RepoInfo(user), 82 BreadCrumbs: breadcrumbs, 83 + BlobView: blobView, 84 RepoBlob_Output: resp, 85 }) 86 } 87 88 func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 89 l := rp.logger.With("handler", "RepoBlobRaw") 90 + 91 f, err := rp.repoResolver.Resolve(r) 92 if err != nil { 93 l.Error("failed to get repo and knot", "err", err) 94 w.WriteHeader(http.StatusBadRequest) 95 return 96 } 97 + 98 ref := chi.URLParam(r, "ref") 99 ref, _ = url.PathUnescape(ref) 100 + 101 filePath := chi.URLParam(r, "*") 102 filePath, _ = url.PathUnescape(filePath) 103 + 104 scheme := "http" 105 if !rp.config.Core.Dev { 106 scheme = "https" ··· 123 l.Error("failed to create request", "err", err) 124 return 125 } 126 + 127 // forward the If-None-Match header 128 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 129 req.Header.Set("If-None-Match", clientETag) 130 } 131 client := &http.Client{} 132 + 133 resp, err := client.Do(req) 134 if err != nil { 135 l.Error("failed to reach knotserver", "err", err) 136 rp.pages.Error503(w) 137 return 138 } 139 + 140 defer resp.Body.Close() 141 + 142 // forward 304 not modified 143 if resp.StatusCode == http.StatusNotModified { 144 w.WriteHeader(http.StatusNotModified) 145 return 146 } 147 + 148 if resp.StatusCode != http.StatusOK { 149 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 150 w.WriteHeader(resp.StatusCode) 151 _, _ = io.Copy(w, resp.Body) 152 return 153 } 154 + 155 contentType := resp.Header.Get("Content-Type") 156 body, err := io.ReadAll(resp.Body) 157 if err != nil { ··· 159 w.WriteHeader(http.StatusInternalServerError) 160 return 161 } 162 + 163 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 164 // serve all textual content as text/plain 165 w.Header().Set("Content-Type", "text/plain; charset=utf-8") ··· 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 277 + } 278 + 279 func isTextualMimeType(mimeType string) bool { 280 textualTypes := []string{ 281 "application/json",