forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package repo 2 3import ( 4 "fmt" 5 "io" 6 "net/http" 7 "net/url" 8 "path/filepath" 9 "slices" 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 21func (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" 35 } 36 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 37 xrpcc := &indigoxrpc.Client{ 38 Host: host, 39 } 40 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 41 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 42 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 43 l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 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))}) 50 if filePath != "" { 51 for idx, elem := range strings.Split(filePath, "/") { 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 128func (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" 143 } 144 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Repo.Name) 145 baseURL := &url.URL{ 146 Scheme: scheme, 147 Host: f.Knot, 148 Path: "/xrpc/sh.tangled.repo.blob", 149 } 150 query := baseURL.Query() 151 query.Set("repo", repo) 152 query.Set("ref", ref) 153 query.Set("path", filePath) 154 query.Set("raw", "true") 155 baseURL.RawQuery = query.Encode() 156 blobURL := baseURL.String() 157 req, err := http.NewRequest("GET", blobURL, nil) 158 if err != nil { 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 { 188 l.Error("error reading response body from knotserver", "err", err) 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") 195 w.Write(body) 196 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 197 // serve images and videos with their original content type 198 w.Header().Set("Content-Type", contentType) 199 w.Write(body) 200 } else { 201 w.WriteHeader(http.StatusUnsupportedMediaType) 202 w.Write([]byte("unsupported content type")) 203 return 204 } 205} 206 207func isTextualMimeType(mimeType string) bool { 208 textualTypes := []string{ 209 "application/json", 210 "application/xml", 211 "application/yaml", 212 "application/x-yaml", 213 "application/toml", 214 "application/javascript", 215 "application/ecmascript", 216 "message/", 217 } 218 return slices.Contains(textualTypes, mimeType) 219}