This repository has no description
0

Configure Feed

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

core / appview / repo / blob.go
10 kB 368 lines
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 scheme := "http" 55 if !rp.config.Core.Dev { 56 scheme = "https" 57 } 58 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 59 xrpcc := &indigoxrpc.Client{ 60 Host: host, 61 } 62 resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoIdentifier()) 63 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 64 l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err) 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 if resp.LastCommit.Author != nil { 105 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 106 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 107 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 108 } 109 } 110 111 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 112 LoggedInUser: user, 113 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 114 BreadCrumbs: breadcrumbs, 115 BlobView: blobView, 116 EmailToDid: emailToDidMap, 117 LastCommitInfo: lastCommitInfo, 118 RepoBlob_Output: resp, 119 }) 120} 121 122func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 123 l := rp.logger.With("handler", "RepoBlobRaw") 124 125 f, err := rp.repoResolver.Resolve(r) 126 if err != nil { 127 l.Error("failed to get repo and knot", "err", err) 128 w.WriteHeader(http.StatusBadRequest) 129 return 130 } 131 132 ref := chi.URLParam(r, "ref") 133 ref, _ = url.PathUnescape(ref) 134 135 filePath := chi.URLParam(r, "*") 136 filePath, _ = url.PathUnescape(filePath) 137 138 scheme := "http" 139 if !rp.config.Core.Dev { 140 scheme = "https" 141 } 142 repo := f.RepoIdentifier() 143 baseURL := &url.URL{ 144 Scheme: scheme, 145 Host: f.Knot, 146 Path: "/xrpc/sh.tangled.repo.blob", 147 } 148 query := baseURL.Query() 149 query.Set("repo", repo) 150 query.Set("ref", ref) 151 query.Set("path", filePath) 152 query.Set("raw", "true") 153 baseURL.RawQuery = query.Encode() 154 blobURL := baseURL.String() 155 req, err := http.NewRequest("GET", blobURL, nil) 156 if err != nil { 157 l.Error("failed to create request", "err", err) 158 return 159 } 160 161 // forward the If-None-Match header 162 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 163 req.Header.Set("If-None-Match", clientETag) 164 } 165 client := &http.Client{} 166 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 174 defer resp.Body.Close() 175 176 // forward 304 not modified 177 if resp.StatusCode == http.StatusNotModified { 178 w.WriteHeader(http.StatusNotModified) 179 return 180 } 181 182 if resp.StatusCode != http.StatusOK { 183 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 184 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 185 w.WriteHeader(resp.StatusCode) 186 return 187 } 188 189 contentType := resp.Header.Get("Content-Type") 190 body, err := io.ReadAll(resp.Body) 191 if err != nil { 192 l.Error("error reading response body from knotserver", "err", err) 193 w.WriteHeader(http.StatusInternalServerError) 194 return 195 } 196 197 // Normalize to bare media type before classification; strips parameters 198 // (e.g. "; charset=utf-8") and prevents bypass attempts like 199 // "image/svg+xml; innocent=param". A parse error yields an empty string 200 // which falls through to the 415 default — the safe outcome. 201 mediaType, _, _ := mime.ParseMediaType(contentType) 202 203 // Prevent browser sniffing regardless of branch taken below. 204 w.Header().Set("X-Content-Type-Options", "nosniff") 205 206 switch { 207 case strings.HasPrefix(mediaType, "text/") || isTextualMimeType(mediaType): 208 // Serve all textual content as plain text so the browser never 209 // interprets knot-supplied markup or scripts. 210 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 211 w.Write(body) 212 case safeBinaryMIMEType(mediaType): 213 // Use the normalized type, never the raw knot-supplied string. 214 w.Header().Set("Content-Type", mediaType) 215 w.Write(body) 216 default: 217 w.WriteHeader(http.StatusUnsupportedMediaType) 218 w.Write([]byte("unsupported content type")) 219 } 220} 221 222// NewBlobView creates a BlobView from the XRPC response 223func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 224 view := models.BlobView{ 225 Contents: "", 226 Lines: 0, 227 } 228 229 // Set size 230 if resp.Size != nil { 231 view.SizeHint = uint64(*resp.Size) 232 } else if resp.Content != nil { 233 view.SizeHint = uint64(len(*resp.Content)) 234 } 235 236 if resp.Submodule != nil { 237 view.ContentType = models.BlobContentTypeSubmodule 238 view.HasRenderedView = true 239 view.ContentSrc = resp.Submodule.Url 240 return view 241 } 242 243 // Determine if binary 244 if resp.IsBinary != nil && *resp.IsBinary { 245 view.ContentSrc = generateBlobURL(config, repo, ref, filePath) 246 ext := strings.ToLower(filepath.Ext(resp.Path)) 247 248 switch ext { 249 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 250 view.ContentType = models.BlobContentTypeImage 251 view.HasRawView = true 252 view.HasRenderedView = true 253 view.ShowingRendered = true 254 255 case ".svg": 256 view.ContentType = models.BlobContentTypeSvg 257 view.HasRawView = true 258 view.HasTextView = true 259 view.HasRenderedView = true 260 view.ShowingRendered = queryParams.Get("code") != "true" 261 if resp.Content != nil { 262 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 263 view.Contents = string(bytes) 264 view.Lines = countLines(view.Contents) 265 } 266 267 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 268 view.ContentType = models.BlobContentTypeVideo 269 view.HasRawView = true 270 view.HasRenderedView = true 271 view.ShowingRendered = true 272 } 273 274 return view 275 } 276 277 // otherwise, we are dealing with text content 278 view.HasRawView = true 279 view.HasTextView = true 280 281 if resp.Content != nil { 282 view.Contents = *resp.Content 283 view.Lines = countLines(view.Contents) 284 } 285 286 // with text, we may be dealing with markdown 287 format := markup.GetFormat(resp.Path) 288 if format == markup.FormatMarkdown { 289 view.ContentType = models.BlobContentTypeMarkup 290 view.HasRenderedView = true 291 view.ShowingRendered = queryParams.Get("code") != "true" 292 } 293 294 return view 295} 296 297func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 298 scheme := "http" 299 if !config.Core.Dev { 300 scheme = "https" 301 } 302 303 repoName := repo.RepoIdentifier() 304 baseURL := &url.URL{ 305 Scheme: scheme, 306 Host: repo.Knot, 307 Path: "/xrpc/sh.tangled.repo.blob", 308 } 309 query := baseURL.Query() 310 query.Set("repo", repoName) 311 query.Set("ref", ref) 312 query.Set("path", filePath) 313 query.Set("raw", "true") 314 baseURL.RawQuery = query.Encode() 315 blobURL := baseURL.String() 316 317 if !config.Core.Dev { 318 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 319 } 320 return blobURL 321} 322 323// safeBinaryMIMETypes is an explicit allowlist of binary content types that 324// are safe to serve inline. SVG is intentionally absent: it supports embedded 325// scripts and would enable XSS if a malicious knot returned one. 326var safeBinaryMIMETypes = map[string]bool{ 327 "image/png": true, 328 "image/jpeg": true, 329 "image/gif": true, 330 "image/webp": true, 331 "image/avif": true, 332 "video/mp4": true, 333 "video/webm": true, 334 "video/ogg": true, 335} 336 337func safeBinaryMIMEType(mediaType string) bool { 338 return safeBinaryMIMETypes[mediaType] 339} 340 341func isTextualMimeType(mimeType string) bool { 342 textualTypes := []string{ 343 "application/json", 344 "application/xml", 345 "application/yaml", 346 "application/x-yaml", 347 "application/toml", 348 "application/javascript", 349 "application/ecmascript", 350 "message/", 351 } 352 return slices.Contains(textualTypes, mimeType) 353} 354 355// TODO: dedup with strings 356func countLines(content string) int { 357 if content == "" { 358 return 0 359 } 360 361 count := strings.Count(content, "\n") 362 363 if !strings.HasSuffix(content, "\n") { 364 count++ 365 } 366 367 return count 368}