forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 7.9 kB view raw
1package repo 2 3import ( 4 "encoding/base64" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "path/filepath" 10 "slices" 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 : | | 34func (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" 52 } 53 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 54 xrpcc := &indigoxrpc.Client{ 55 Host: host, 56 } 57 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 58 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 59 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 60 l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 61 rp.pages.Error503(w) 62 return 63 } 64 65 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 66 67 // Use XRPC response directly instead of converting to internal types 68 var breadcrumbs [][]string 69 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 70 if filePath != "" { 71 for idx, elem := range strings.Split(filePath, "/") { 72 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 73 } 74 } 75 76 // Create the blob view 77 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 78 79 user := rp.oauth.GetUser(r) 80 81 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 82 LoggedInUser: user, 83 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 84 BreadCrumbs: breadcrumbs, 85 BlobView: blobView, 86 RepoBlob_Output: resp, 87 }) 88} 89 90func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 91 l := rp.logger.With("handler", "RepoBlobRaw") 92 93 f, err := rp.repoResolver.Resolve(r) 94 if err != nil { 95 l.Error("failed to get repo and knot", "err", err) 96 w.WriteHeader(http.StatusBadRequest) 97 return 98 } 99 100 ref := chi.URLParam(r, "ref") 101 ref, _ = url.PathUnescape(ref) 102 103 filePath := chi.URLParam(r, "*") 104 filePath, _ = url.PathUnescape(filePath) 105 106 scheme := "http" 107 if !rp.config.Core.Dev { 108 scheme = "https" 109 } 110 repo := f.DidSlashRepo() 111 baseURL := &url.URL{ 112 Scheme: scheme, 113 Host: f.Knot, 114 Path: "/xrpc/sh.tangled.repo.blob", 115 } 116 query := baseURL.Query() 117 query.Set("repo", repo) 118 query.Set("ref", ref) 119 query.Set("path", filePath) 120 query.Set("raw", "true") 121 baseURL.RawQuery = query.Encode() 122 blobURL := baseURL.String() 123 req, err := http.NewRequest("GET", blobURL, nil) 124 if err != nil { 125 l.Error("failed to create request", "err", err) 126 return 127 } 128 129 // forward the If-None-Match header 130 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 131 req.Header.Set("If-None-Match", clientETag) 132 } 133 client := &http.Client{} 134 135 resp, err := client.Do(req) 136 if err != nil { 137 l.Error("failed to reach knotserver", "err", err) 138 rp.pages.Error503(w) 139 return 140 } 141 142 defer resp.Body.Close() 143 144 // forward 304 not modified 145 if resp.StatusCode == http.StatusNotModified { 146 w.WriteHeader(http.StatusNotModified) 147 return 148 } 149 150 if resp.StatusCode != http.StatusOK { 151 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 152 w.WriteHeader(resp.StatusCode) 153 _, _ = io.Copy(w, resp.Body) 154 return 155 } 156 157 contentType := resp.Header.Get("Content-Type") 158 body, err := io.ReadAll(resp.Body) 159 if err != nil { 160 l.Error("error reading response body from knotserver", "err", err) 161 w.WriteHeader(http.StatusInternalServerError) 162 return 163 } 164 165 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 166 // serve all textual content as text/plain 167 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 168 w.Write(body) 169 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 170 // serve images and videos with their original content type 171 w.Header().Set("Content-Type", contentType) 172 w.Write(body) 173 } else { 174 w.WriteHeader(http.StatusUnsupportedMediaType) 175 w.Write([]byte("unsupported content type")) 176 return 177 } 178} 179 180// NewBlobView creates a BlobView from the XRPC response 181func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 182 view := models.BlobView{ 183 Contents: "", 184 Lines: 0, 185 } 186 187 // Set size 188 if resp.Size != nil { 189 view.SizeHint = uint64(*resp.Size) 190 } else if resp.Content != nil { 191 view.SizeHint = uint64(len(*resp.Content)) 192 } 193 194 if resp.Submodule != nil { 195 view.ContentType = models.BlobContentTypeSubmodule 196 view.HasRenderedView = true 197 view.ContentSrc = resp.Submodule.Url 198 return view 199 } 200 201 // Determine if binary 202 if resp.IsBinary != nil && *resp.IsBinary { 203 view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 204 ext := strings.ToLower(filepath.Ext(resp.Path)) 205 206 switch ext { 207 case ".jpg", ".jpeg", ".png", ".gif", ".webp": 208 view.ContentType = models.BlobContentTypeImage 209 view.HasRawView = true 210 view.HasRenderedView = true 211 view.ShowingRendered = true 212 213 case ".svg": 214 view.ContentType = models.BlobContentTypeSvg 215 view.HasRawView = true 216 view.HasTextView = true 217 view.HasRenderedView = true 218 view.ShowingRendered = queryParams.Get("code") != "true" 219 if resp.Content != nil { 220 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 221 view.Contents = string(bytes) 222 view.Lines = strings.Count(view.Contents, "\n") + 1 223 } 224 225 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 226 view.ContentType = models.BlobContentTypeVideo 227 view.HasRawView = true 228 view.HasRenderedView = true 229 view.ShowingRendered = true 230 } 231 232 return view 233 } 234 235 // otherwise, we are dealing with text content 236 view.HasRawView = true 237 view.HasTextView = true 238 239 if resp.Content != nil { 240 view.Contents = *resp.Content 241 view.Lines = strings.Count(view.Contents, "\n") + 1 242 } 243 244 // with text, we may be dealing with markdown 245 format := markup.GetFormat(resp.Path) 246 if format == markup.FormatMarkdown { 247 view.ContentType = models.BlobContentTypeMarkup 248 view.HasRenderedView = true 249 view.ShowingRendered = queryParams.Get("code") != "true" 250 } 251 252 return view 253} 254 255func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 256 scheme := "http" 257 if !config.Core.Dev { 258 scheme = "https" 259 } 260 261 repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 262 baseURL := &url.URL{ 263 Scheme: scheme, 264 Host: repo.Knot, 265 Path: "/xrpc/sh.tangled.repo.blob", 266 } 267 query := baseURL.Query() 268 query.Set("repo", repoName) 269 query.Set("ref", ref) 270 query.Set("path", filePath) 271 query.Set("raw", "true") 272 baseURL.RawQuery = query.Encode() 273 blobURL := baseURL.String() 274 275 if !config.Core.Dev { 276 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 277 } 278 return blobURL 279} 280 281func isTextualMimeType(mimeType string) bool { 282 textualTypes := []string{ 283 "application/json", 284 "application/xml", 285 "application/yaml", 286 "application/x-yaml", 287 "application/toml", 288 "application/javascript", 289 "application/ecmascript", 290 "message/", 291 } 292 return slices.Contains(textualTypes, mimeType) 293}