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