Monorepo for Tangled
at 696ec39733bcacc8f77bdb64d12217a23eb19234 335 lines 8.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 "time" 13 14 "tangled.org/core/api/tangled" 15 "tangled.org/core/appview/config" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/pages" 19 "tangled.org/core/appview/pages/markup" 20 "tangled.org/core/appview/reporesolver" 21 xrpcclient "tangled.org/core/appview/xrpcclient" 22 "tangled.org/core/types" 23 24 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 "github.com/go-chi/chi/v5" 26 "github.com/go-git/go-git/v5/plumbing" 27) 28 29// the content can be one of the following: 30// 31// - code : text | | raw 32// - markup : text | rendered | raw 33// - svg : text | rendered | raw 34// - png : | rendered | raw 35// - video : | rendered | raw 36// - submodule : | rendered | 37// - rest : | | 38func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 39 l := rp.logger.With("handler", "RepoBlob") 40 41 f, err := rp.repoResolver.Resolve(r) 42 if err != nil { 43 l.Error("failed to get repo and knot", "err", err) 44 return 45 } 46 47 ref := chi.URLParam(r, "ref") 48 ref, _ = url.PathUnescape(ref) 49 50 filePath := chi.URLParam(r, "*") 51 filePath, _ = url.PathUnescape(filePath) 52 53 scheme := "http" 54 if !rp.config.Core.Dev { 55 scheme = "https" 56 } 57 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 58 xrpcc := &indigoxrpc.Client{ 59 Host: host, 60 } 61 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 62 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 63 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 64 l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 65 rp.pages.Error503(w) 66 return 67 } 68 69 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 70 71 // Use XRPC response directly instead of converting to internal types 72 var breadcrumbs [][]string 73 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 74 if filePath != "" { 75 for idx, elem := range strings.Split(filePath, "/") { 76 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 77 } 78 } 79 80 // Create the blob view 81 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 82 83 user := rp.oauth.GetMultiAccountUser(r) 84 85 // Get email to DID mapping for commit author 86 var emails []string 87 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 88 emails = append(emails, resp.LastCommit.Author.Email) 89 } 90 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 91 if err != nil { 92 l.Error("failed to get email to did mapping", "err", err) 93 emailToDidMap = make(map[string]string) 94 } 95 96 var lastCommitInfo *types.LastCommitInfo 97 if resp.LastCommit != nil { 98 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 99 lastCommitInfo = &types.LastCommitInfo{ 100 Hash: plumbing.NewHash(resp.LastCommit.Hash), 101 Message: resp.LastCommit.Message, 102 When: when, 103 } 104 } 105 106 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 107 LoggedInUser: user, 108 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 109 BreadCrumbs: breadcrumbs, 110 BlobView: blobView, 111 EmailToDid: emailToDidMap, 112 LastCommitInfo: lastCommitInfo, 113 RepoBlob_Output: resp, 114 }) 115} 116 117func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 118 l := rp.logger.With("handler", "RepoBlobRaw") 119 120 f, err := rp.repoResolver.Resolve(r) 121 if err != nil { 122 l.Error("failed to get repo and knot", "err", err) 123 w.WriteHeader(http.StatusBadRequest) 124 return 125 } 126 127 ref := chi.URLParam(r, "ref") 128 ref, _ = url.PathUnescape(ref) 129 130 filePath := chi.URLParam(r, "*") 131 filePath, _ = url.PathUnescape(filePath) 132 133 scheme := "http" 134 if !rp.config.Core.Dev { 135 scheme = "https" 136 } 137 repo := f.DidSlashRepo() 138 baseURL := &url.URL{ 139 Scheme: scheme, 140 Host: f.Knot, 141 Path: "/xrpc/sh.tangled.repo.blob", 142 } 143 query := baseURL.Query() 144 query.Set("repo", repo) 145 query.Set("ref", ref) 146 query.Set("path", filePath) 147 query.Set("raw", "true") 148 baseURL.RawQuery = query.Encode() 149 blobURL := baseURL.String() 150 req, err := http.NewRequest("GET", blobURL, nil) 151 if err != nil { 152 l.Error("failed to create request", "err", err) 153 return 154 } 155 156 // forward the If-None-Match header 157 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 158 req.Header.Set("If-None-Match", clientETag) 159 } 160 client := &http.Client{} 161 162 resp, err := client.Do(req) 163 if err != nil { 164 l.Error("failed to reach knotserver", "err", err) 165 rp.pages.Error503(w) 166 return 167 } 168 169 defer resp.Body.Close() 170 171 // forward 304 not modified 172 if resp.StatusCode == http.StatusNotModified { 173 w.WriteHeader(http.StatusNotModified) 174 return 175 } 176 177 if resp.StatusCode != http.StatusOK { 178 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 179 w.WriteHeader(resp.StatusCode) 180 _, _ = io.Copy(w, resp.Body) 181 return 182 } 183 184 contentType := resp.Header.Get("Content-Type") 185 body, err := io.ReadAll(resp.Body) 186 if err != nil { 187 l.Error("error reading response body from knotserver", "err", err) 188 w.WriteHeader(http.StatusInternalServerError) 189 return 190 } 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 207// NewBlobView creates a BlobView from the XRPC response 208func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, 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, repo, 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 = countLines(view.Contents) 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 = countLines(view.Contents) 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 282func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 283 scheme := "http" 284 if !config.Core.Dev { 285 scheme = "https" 286 } 287 288 repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 289 baseURL := &url.URL{ 290 Scheme: scheme, 291 Host: repo.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 306} 307 308func isTextualMimeType(mimeType string) bool { 309 textualTypes := []string{ 310 "application/json", 311 "application/xml", 312 "application/yaml", 313 "application/x-yaml", 314 "application/toml", 315 "application/javascript", 316 "application/ecmascript", 317 "message/", 318 } 319 return slices.Contains(textualTypes, mimeType) 320} 321 322// TODO: dedup with strings 323func countLines(content string) int { 324 if content == "" { 325 return 0 326 } 327 328 count := strings.Count(content, "\n") 329 330 if !strings.HasSuffix(content, "\n") { 331 count++ 332 } 333 334 return count 335}