Monorepo for Tangled tangled.org
1

Configure Feed

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

core / appview / repo / blob.go
10 kB 359 lines
1package repo 2 3import ( 4 "encoding/base64" 5 "fmt" 6 "io" 7 "mime" 8 "net/http" 9 "net/url" 10 "path/filepath" 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 "tangled.org/core/types" 22 xrpcclient "tangled.org/core/xrpc/xrpcclient" 23 24 "github.com/bluesky-social/indigo/util" 25 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 26 "github.com/go-chi/chi/v5" 27 enry "github.com/go-enry/go-enry/v2" 28 "github.com/go-git/go-git/v5/plumbing" 29 "github.com/go-git/go-git/v5/plumbing/filemode" 30) 31 32// maxBlobSize bounds inline text content; larger blobs are marked too large. 33const maxBlobSize = 1 << 20 // 1MiB 34 35// the content can be one of the following: 36// 37// - code : text | | raw 38// - markup : text | rendered | raw 39// - svg : text | rendered | raw 40// - image : | rendered | raw 41// - video : | rendered | raw 42// - submodule : | rendered | 43// - rest : | | raw 44func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 45 l := rp.logger.With("handler", "RepoBlob") 46 47 f, err := rp.repoResolver.Resolve(r) 48 if err != nil { 49 l.Error("failed to get repo and knot", "err", err) 50 return 51 } 52 53 ref := chi.URLParam(r, "ref") 54 ref, _ = url.PathUnescape(ref) 55 56 filePath := chi.URLParam(r, "*") 57 filePath, _ = url.PathUnescape(filePath) 58 59 l = l.With("ref", ref, "path", filePath) 60 61 ctx := r.Context() 62 63 xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} 64 resp, err := tangled.GitTempGetEntry(ctx, xrpcc, filePath, ref, f.RepoDid) 65 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 66 l.Error("failed to call XRPC git.getEntry", "xrpcerr", xrpcerr, "err", err) 67 rp.pages.Error503(w) 68 return 69 } 70 71 var breadcrumbs [][]string 72 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", reporesolver.GetBaseRepoPath(r, f), 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 blobView, err := func() (models.BlobView, error) { 80 mode, err := filemode.New(resp.Mode) 81 if err != nil { 82 mode = filemode.Regular 83 } 84 85 if mode == filemode.Submodule { 86 if resp.Submodule == nil { 87 return models.BlobView{}, fmt.Errorf("submodule info is missing") 88 } 89 return models.BlobView{ 90 ContentType: models.BlobContentTypeSubmodule, 91 ContentSrc: resp.Submodule.Url, 92 }, nil 93 } 94 95 blobUrl := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) 96 blobReq, err := http.NewRequestWithContext(ctx, http.MethodGet, blobUrl, nil) 97 if err != nil { 98 return models.BlobView{}, err 99 } 100 blobResp, err := util.RobustHTTPClient().Do(blobReq) 101 if err != nil { 102 return models.BlobView{}, err 103 } 104 defer blobResp.Body.Close() 105 106 if blobResp.StatusCode != http.StatusOK { 107 return models.BlobView{}, fmt.Errorf("blob fetch failed: status %d", blobResp.StatusCode) 108 } 109 110 // inspect content-type header 111 // - text/plain -> Code / Markup 112 // - image/svg -> Svg 113 // - image/* -> Image 114 // - video/* -> Video 115 // - */* -> Other 116 mediaType, _, _ := mime.ParseMediaType(blobResp.Header.Get("Content-Type")) 117 var contentType models.BlobContentType 118 switch { 119 case mediaType == "image/svg+xml": 120 contentType = models.BlobContentTypeSvg 121 case strings.HasPrefix(mediaType, "text/"): 122 if markup.GetFormat(filePath) == markup.FormatMarkdown { 123 contentType = models.BlobContentTypeMarkup 124 } else { 125 contentType = models.BlobContentTypeCode 126 } 127 case strings.HasPrefix(mediaType, "image/"): 128 contentType = models.BlobContentTypeImage 129 case strings.HasPrefix(mediaType, "video/"): 130 contentType = models.BlobContentTypeVideo 131 default: 132 contentType = models.BlobContentTypeOther 133 } 134 135 // only text-viewable content is read inline; others stream via ContentSrc 136 if !contentType.HasTextView() { 137 return models.BlobView{ 138 ContentType: contentType, 139 ContentSrc: blobUrl, 140 FileTooLarge: false, 141 Contents: "", 142 Lines: 0, 143 SizeHint: uint64(resp.Size), 144 }, nil 145 } 146 147 // skip large blobs 148 if resp.Size > maxBlobSize { 149 return models.BlobView{ 150 ContentType: contentType, 151 ContentSrc: blobUrl, 152 FileTooLarge: true, 153 SizeHint: uint64(resp.Size), 154 }, nil 155 } 156 157 // just in case, ensure the size again 158 content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize)) 159 if err != nil { 160 return models.BlobView{}, err 161 } 162 163 contentStr := string(content) 164 return models.BlobView{ 165 ContentType: contentType, 166 ContentSrc: blobUrl, 167 Contents: contentStr, 168 FileTooLarge: false, 169 Lines: countLines(contentStr), 170 SizeHint: uint64(resp.Size), 171 }, nil 172 }() 173 if err != nil { 174 l.Error("failed to render blob", "err", err) 175 rp.pages.Error503(w) 176 return 177 } 178 179 // Get email to DID mapping for commit author 180 var emails []string 181 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 182 emails = append(emails, resp.LastCommit.Author.Email) 183 } 184 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 185 if err != nil { 186 l.Error("failed to get email to did mapping", "err", err) 187 emailToDidMap = make(map[string]string) 188 } 189 190 var lastCommitInfo *types.LastCommitInfo 191 if resp.LastCommit != nil { 192 when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When) 193 lastCommitInfo = &types.LastCommitInfo{ 194 Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)), 195 Message: resp.LastCommit.Message, 196 When: when, 197 } 198 if resp.LastCommit.Author != nil { 199 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 200 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 201 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 202 } 203 } 204 205 baseName := filepath.Base(filePath) 206 lang, ok := enry.GetLanguageByExtension(baseName) 207 if !ok { 208 lang, ok = enry.GetLanguageByFilename(baseName) 209 } 210 if !ok && blobView.Contents != "" { 211 lang = enry.GetLanguage(baseName, []byte(blobView.Contents)) 212 } 213 if group := enry.GetLanguageGroup(lang); group != "" { 214 lang = group 215 } 216 217 user := rp.oauth.GetMultiAccountUser(r) 218 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 219 BaseParams: pages.BaseParamsFromContext(r.Context()), 220 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 221 BreadCrumbs: breadcrumbs, 222 BlobView: blobView, 223 EmailToDid: emailToDidMap, 224 LastCommitInfo: lastCommitInfo, 225 ShowRendered: r.URL.Query().Get("code") != "true", 226 Ref: ref, 227 Path: filePath, 228 Language: lang, 229 }) 230} 231 232func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 233 l := rp.logger.With("handler", "RepoBlobRaw") 234 235 f, err := rp.repoResolver.Resolve(r) 236 if err != nil { 237 l.Error("failed to get repo and knot", "err", err) 238 w.WriteHeader(http.StatusBadRequest) 239 return 240 } 241 242 ref := chi.URLParam(r, "ref") 243 ref, _ = url.PathUnescape(ref) 244 245 filePath := chi.URLParam(r, "*") 246 filePath, _ = url.PathUnescape(filePath) 247 248 blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) 249 250 if r.URL.Query().Get("download") == "1" { 251 req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, blobURL, nil) 252 if err != nil { 253 w.WriteHeader(http.StatusInternalServerError) 254 return 255 } 256 resp, err := util.RobustHTTPClient().Do(req) 257 if err != nil || resp.StatusCode != http.StatusOK { 258 w.WriteHeader(http.StatusBadGateway) 259 return 260 } 261 defer resp.Body.Close() 262 263 filename := filepath.Base(filePath) 264 w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename)) 265 w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) 266 w.Header().Set("Cache-Control", "public, no-cache") 267 io.Copy(w, resp.Body) 268 return 269 } 270 271 w.Header().Set("Cache-Control", "public, no-cache") 272 http.Redirect(w, r, blobURL, http.StatusFound) 273} 274 275// NewBlobView creates a BlobView from the XRPC response 276func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 277 view := models.BlobView{ 278 Contents: "", 279 Lines: 0, 280 } 281 282 // Set size 283 if resp.Size != nil { 284 view.SizeHint = uint64(*resp.Size) 285 } else if resp.Content != nil { 286 view.SizeHint = uint64(len(*resp.Content)) 287 } 288 289 if resp.Submodule != nil { 290 view.ContentType = models.BlobContentTypeSubmodule 291 view.ContentSrc = resp.Submodule.Url 292 return view 293 } 294 295 // Determine if binary 296 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { 297 view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath) 298 ext := strings.ToLower(filepath.Ext(resp.Path)) 299 300 switch ext { 301 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 302 view.ContentType = models.BlobContentTypeImage 303 304 case ".svg": 305 view.ContentType = models.BlobContentTypeSvg 306 if resp.Content != nil { 307 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 308 view.Contents = string(bytes) 309 view.Lines = countLines(view.Contents) 310 } 311 312 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 313 view.ContentType = models.BlobContentTypeVideo 314 } 315 316 return view 317 } 318 319 // otherwise, we are dealing with text content 320 321 if resp.Content != nil { 322 view.Contents = *resp.Content 323 view.Lines = countLines(view.Contents) 324 } 325 326 // with text, we may be dealing with markdown 327 format := markup.GetFormat(resp.Path) 328 if format == markup.FormatMarkdown { 329 view.ContentType = models.BlobContentTypeMarkup 330 } 331 332 return view 333} 334 335func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string { 336 query := url.Values{} 337 query.Set("repo", repo.RepoDid) 338 query.Set("ref", ref) 339 query.Set("path", filePath) 340 341 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode()) 342 return blobURL 343 // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath) 344} 345 346// TODO: dedup with strings 347func countLines(content string) int { 348 if content == "" { 349 return 0 350 } 351 352 count := strings.Count(content, "\n") 353 354 if !strings.HasSuffix(content, "\n") { 355 count++ 356 } 357 358 return count 359}