forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
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.OwnerDid(), f.Repo.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 // 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))}) 68 if filePath != "" { 69 for idx, elem := range strings.Split(filePath, "/") { 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 88func (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" 107 } 108 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 109 baseURL := &url.URL{ 110 Scheme: scheme, 111 Host: f.Knot, 112 Path: "/xrpc/sh.tangled.repo.blob", 113 } 114 query := baseURL.Query() 115 query.Set("repo", repo) 116 query.Set("ref", ref) 117 query.Set("path", filePath) 118 query.Set("raw", "true") 119 baseURL.RawQuery = query.Encode() 120 blobURL := baseURL.String() 121 req, err := http.NewRequest("GET", blobURL, nil) 122 if err != nil { 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 { 158 l.Error("error reading response body from knotserver", "err", err) 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") 166 w.Write(body) 167 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 168 // serve images and videos with their original content type 169 w.Header().Set("Content-Type", contentType) 170 w.Write(body) 171 } else { 172 w.WriteHeader(http.StatusUnsupportedMediaType) 173 w.Write([]byte("unsupported content type")) 174 return 175 } 176} 177 178// NewBlobView creates a BlobView from the XRPC response 179func 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 253func 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 279func isTextualMimeType(mimeType string) bool { 280 textualTypes := []string{ 281 "application/json", 282 "application/xml", 283 "application/yaml", 284 "application/x-yaml", 285 "application/toml", 286 "application/javascript", 287 "application/ecmascript", 288 "message/", 289 } 290 return slices.Contains(textualTypes, mimeType) 291}