Monorepo for Tangled tangled.org
4

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