Monorepo for Tangled tangled.org
2

Configure Feed

Select the types of activity you want to include in your feed.

1package repo 2 3import ( 4 "encoding/base64" 5 "fmt" 6 "io" 7 "mime" 8 "net/http" 9 "net/url" 10 "path/filepath" 11 "slices" 12 "strings" 13 "time" 14 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/config" 17 "tangled.org/core/appview/db" 18 "tangled.org/core/appview/models" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/appview/pages/markup" 21 "tangled.org/core/appview/reporesolver" 22 xrpcclient "tangled.org/core/appview/xrpcclient" 23 "tangled.org/core/types" 24 25 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 26 "github.com/go-chi/chi/v5" 27 "github.com/go-git/go-git/v5/plumbing" 28) 29 30// the content can be one of the following: 31// 32// - code : text | | raw 33// - markup : text | rendered | raw 34// - svg : text | rendered | raw 35// - png : | rendered | raw 36// - video : | rendered | raw 37// - submodule : | rendered | 38// - rest : | | 39func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 40 l := rp.logger.With("handler", "RepoBlob") 41 42 f, err := rp.repoResolver.Resolve(r) 43 if err != nil { 44 l.Error("failed to get repo and knot", "err", err) 45 return 46 } 47 48 ref := chi.URLParam(r, "ref") 49 ref, _ = url.PathUnescape(ref) 50 51 filePath := chi.URLParam(r, "*") 52 filePath, _ = url.PathUnescape(filePath) 53 54 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 55 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoDid) 56 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 57 l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err) 58 rp.pages.Error503(w) 59 return 60 } 61 62 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 63 64 // Use XRPC response directly instead of converting to internal types 65 var breadcrumbs [][]string 66 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 67 if filePath != "" { 68 for idx, elem := range strings.Split(filePath, "/") { 69 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 70 } 71 } 72 73 // Create the blob view 74 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 75 76 user := rp.oauth.GetMultiAccountUser(r) 77 78 // Get email to DID mapping for commit author 79 var emails []string 80 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 81 emails = append(emails, resp.LastCommit.Author.Email) 82 } 83 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 84 if err != nil { 85 l.Error("failed to get email to did mapping", "err", err) 86 emailToDidMap = make(map[string]string) 87 } 88 89 var lastCommitInfo *types.LastCommitInfo 90 if resp.LastCommit != nil { 91 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 92 lastCommitInfo = &types.LastCommitInfo{ 93 Hash: plumbing.NewHash(resp.LastCommit.Hash), 94 Message: resp.LastCommit.Message, 95 When: when, 96 } 97 if resp.LastCommit.Author != nil { 98 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 99 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 100 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 101 } 102 } 103 104 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 105 LoggedInUser: user, 106 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 107 BreadCrumbs: breadcrumbs, 108 BlobView: blobView, 109 EmailToDid: emailToDidMap, 110 LastCommitInfo: lastCommitInfo, 111 RepoBlob_Output: resp, 112 }) 113} 114 115func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 116 l := rp.logger.With("handler", "RepoBlobRaw") 117 118 f, err := rp.repoResolver.Resolve(r) 119 if err != nil { 120 l.Error("failed to get repo and knot", "err", err) 121 w.WriteHeader(http.StatusBadRequest) 122 return 123 } 124 125 ref := chi.URLParam(r, "ref") 126 ref, _ = url.PathUnescape(ref) 127 128 filePath := chi.URLParam(r, "*") 129 filePath, _ = url.PathUnescape(filePath) 130 131 blobURL := generateBlobURL(rp.config, f, ref, filePath) 132 133 req, err := http.NewRequest("GET", blobURL, nil) 134 if err != nil { 135 l.Error("failed to create request", "err", err) 136 return 137 } 138 139 // forward the If-None-Match header 140 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 141 req.Header.Set("If-None-Match", clientETag) 142 } 143 client := &http.Client{} 144 145 resp, err := client.Do(req) 146 if err != nil { 147 l.Error("failed to reach knotserver", "err", err) 148 rp.pages.Error503(w) 149 return 150 } 151 152 defer resp.Body.Close() 153 154 // forward 304 not modified 155 if resp.StatusCode == http.StatusNotModified { 156 w.WriteHeader(http.StatusNotModified) 157 return 158 } 159 160 if resp.StatusCode != http.StatusOK { 161 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 162 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 163 w.WriteHeader(resp.StatusCode) 164 return 165 } 166 167 contentType := resp.Header.Get("Content-Type") 168 169 // Normalize to bare media type before classification; strips parameters 170 // (e.g. "; charset=utf-8") and prevents bypass attempts like 171 // "image/svg+xml; innocent=param". A parse error yields an empty string 172 // which falls through to the 415 default — the safe outcome. 173 mediaType, _, _ := mime.ParseMediaType(contentType) 174 175 // Prevent browser sniffing regardless of branch taken below. 176 w.Header().Set("X-Content-Type-Options", "nosniff") 177 178 switch { 179 case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType): 180 // Serve all textual content as plain text so the browser never 181 // interprets knot-supplied markup or scripts. 182 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 183 case safeBinaryMIMEType(mediaType): 184 // Use the normalized type, never the raw knot-supplied string. 185 w.Header().Set("Content-Type", mediaType) 186 default: 187 // If mediatype is unknown or it's unsafe (e.g. SVG which allows XSS,) 188 // fallback to octet-stream 189 w.Header().Set("Content-Type", "application/octet-stream") 190 } 191 if _, err := io.Copy(w, resp.Body); err != nil { 192 l.Error("error streaming knotmirror response", "err", err) 193 w.WriteHeader(http.StatusInternalServerError) 194 return 195 } 196} 197 198// NewBlobView creates a BlobView from the XRPC response 199func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 200 view := models.BlobView{ 201 Contents: "", 202 Lines: 0, 203 } 204 205 // Set size 206 if resp.Size != nil { 207 view.SizeHint = uint64(*resp.Size) 208 } else if resp.Content != nil { 209 view.SizeHint = uint64(len(*resp.Content)) 210 } 211 212 if resp.Submodule != nil { 213 view.ContentType = models.BlobContentTypeSubmodule 214 view.HasRenderedView = true 215 view.ContentSrc = resp.Submodule.Url 216 return view 217 } 218 219 // Determine if binary 220 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { 221 view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 222 ext := strings.ToLower(filepath.Ext(resp.Path)) 223 224 switch ext { 225 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 226 view.ContentType = models.BlobContentTypeImage 227 view.HasRawView = true 228 view.HasRenderedView = true 229 view.ShowingRendered = true 230 231 case ".svg": 232 view.ContentType = models.BlobContentTypeSvg 233 view.HasRawView = true 234 view.HasTextView = true 235 view.HasRenderedView = true 236 view.ShowingRendered = queryParams.Get("code") != "true" 237 if resp.Content != nil { 238 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 239 view.Contents = string(bytes) 240 view.Lines = countLines(view.Contents) 241 } 242 243 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 244 view.ContentType = models.BlobContentTypeVideo 245 view.HasRawView = true 246 view.HasRenderedView = true 247 view.ShowingRendered = true 248 } 249 250 return view 251 } 252 253 // otherwise, we are dealing with text content 254 view.HasRawView = true 255 view.HasTextView = true 256 257 if resp.Content != nil { 258 view.Contents = *resp.Content 259 view.Lines = countLines(view.Contents) 260 } 261 262 // with text, we may be dealing with markdown 263 format := markup.GetFormat(resp.Path) 264 if format == markup.FormatMarkdown { 265 view.ContentType = models.BlobContentTypeMarkup 266 view.HasRenderedView = true 267 view.ShowingRendered = queryParams.Get("code") != "true" 268 } 269 270 return view 271} 272 273func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 274 query := url.Values{} 275 query.Set("repo", repo.RepoDid) 276 query.Set("ref", ref) 277 query.Set("path", filePath) 278 query.Set("raw", "true") 279 280 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) 281 return blobURL 282} 283 284// safeBinaryMIMETypes is an explicit allowlist of binary content types that 285// are safe to serve inline. SVG is intentionally absent: it supports embedded 286// scripts and would enable XSS if a malicious knot returned one. 287var safeBinaryMIMETypes = map[string]bool{ 288 "image/png": true, 289 "image/jpeg": true, 290 "image/gif": true, 291 "image/webp": true, 292 "image/avif": true, 293 "video/mp4": true, 294 "video/webm": true, 295 "video/ogg": true, 296} 297 298func safeBinaryMIMEType(mediaType string) bool { 299 return safeBinaryMIMETypes[mediaType] 300} 301 302func isTextualMimeType(mimeType string) bool { 303 textualTypes := []string{ 304 "application/json", 305 "application/xml", 306 "application/yaml", 307 "application/x-yaml", 308 "application/toml", 309 "application/javascript", 310 "application/ecmascript", 311 "message/", 312 } 313 return slices.Contains(textualTypes, mimeType) 314} 315 316// TODO: dedup with strings 317func countLines(content string) int { 318 if content == "" { 319 return 0 320 } 321 322 count := strings.Count(content, "\n") 323 324 if !strings.HasSuffix(content, "\n") { 325 count++ 326 } 327 328 return count 329}