Monorepo for Tangled tangled.org
6

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 return models.BlobView{ 149 ContentType: contentType, 150 ContentSrc: blobUrl, 151 FileTooLarge: true, 152 SizeHint: uint64(max(blobResp.ContentLength, 0)), 153 }, nil 154 } 155 156 // just in case, check the size again 157 content, err := io.ReadAll(io.LimitReader(blobResp.Body, maxBlobSize)) 158 if err != nil { 159 return models.BlobView{}, err 160 } 161 162 contentStr := string(content) 163 return models.BlobView{ 164 ContentType: contentType, 165 ContentSrc: blobUrl, 166 Contents: contentStr, 167 FileTooLarge: false, 168 Lines: countLines(contentStr), 169 SizeHint: uint64(max(blobResp.ContentLength, 0)), 170 }, nil 171 }() 172 if err != nil { 173 l.Error("failed to render blob", "err", err) 174 rp.pages.Error503(w) 175 return 176 } 177 178 // Get email to DID mapping for commit author 179 var emails []string 180 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 181 emails = append(emails, resp.LastCommit.Author.Email) 182 } 183 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 184 if err != nil { 185 l.Error("failed to get email to did mapping", "err", err) 186 emailToDidMap = make(map[string]string) 187 } 188 189 var lastCommitInfo *types.LastCommitInfo 190 if resp.LastCommit != nil { 191 when, _ := time.Parse(time.RFC3339, resp.LastCommit.Committer.When) 192 lastCommitInfo = &types.LastCommitInfo{ 193 Hash: plumbing.NewHash(derefString(resp.LastCommit.Hash)), 194 Message: resp.LastCommit.Message, 195 When: when, 196 } 197 if resp.LastCommit.Author != nil { 198 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 199 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 200 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 201 } 202 } 203 204 user := rp.oauth.GetMultiAccountUser(r) 205 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 206 BaseParams: pages.BaseParamsFromContext(r.Context()), 207 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 208 BreadCrumbs: breadcrumbs, 209 BlobView: blobView, 210 EmailToDid: emailToDidMap, 211 LastCommitInfo: lastCommitInfo, 212 ShowRendered: r.URL.Query().Get("code") != "true", 213 Ref: ref, 214 Path: filePath, 215 }) 216} 217 218func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 219 l := rp.logger.With("handler", "RepoBlobRaw") 220 221 f, err := rp.repoResolver.Resolve(r) 222 if err != nil { 223 l.Error("failed to get repo and knot", "err", err) 224 w.WriteHeader(http.StatusBadRequest) 225 return 226 } 227 228 ref := chi.URLParam(r, "ref") 229 ref, _ = url.PathUnescape(ref) 230 231 filePath := chi.URLParam(r, "*") 232 filePath, _ = url.PathUnescape(filePath) 233 234 blobURL := generateBlobURL(rp.config.KnotMirror.Url, f, ref, filePath) 235 236 w.Header().Set("Cache-Control", "public, no-cache") 237 http.Redirect(w, r, blobURL, http.StatusFound) 238} 239 240// NewBlobView creates a BlobView from the XRPC response 241func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 242 view := models.BlobView{ 243 Contents: "", 244 Lines: 0, 245 } 246 247 // Set size 248 if resp.Size != nil { 249 view.SizeHint = uint64(*resp.Size) 250 } else if resp.Content != nil { 251 view.SizeHint = uint64(len(*resp.Content)) 252 } 253 254 if resp.Submodule != nil { 255 view.ContentType = models.BlobContentTypeSubmodule 256 view.ContentSrc = resp.Submodule.Url 257 return view 258 } 259 260 // Determine if binary 261 if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { 262 view.ContentSrc = generateBlobURL(config.KnotMirror.Url, repo, ref, filePath) 263 ext := strings.ToLower(filepath.Ext(resp.Path)) 264 265 switch ext { 266 case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".avif", ".jxl", ".heic", ".heif": 267 view.ContentType = models.BlobContentTypeImage 268 269 case ".svg": 270 view.ContentType = models.BlobContentTypeSvg 271 if resp.Content != nil { 272 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 273 view.Contents = string(bytes) 274 view.Lines = countLines(view.Contents) 275 } 276 277 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 278 view.ContentType = models.BlobContentTypeVideo 279 } 280 281 return view 282 } 283 284 // otherwise, we are dealing with text content 285 286 if resp.Content != nil { 287 view.Contents = *resp.Content 288 view.Lines = countLines(view.Contents) 289 } 290 291 // with text, we may be dealing with markdown 292 format := markup.GetFormat(resp.Path) 293 if format == markup.FormatMarkdown { 294 view.ContentType = models.BlobContentTypeMarkup 295 } 296 297 return view 298} 299 300func generateBlobURL(knotmirror string, repo *models.Repo, ref, filePath string) string { 301 query := url.Values{} 302 query.Set("repo", repo.RepoDid) 303 query.Set("ref", ref) 304 query.Set("path", filePath) 305 306 blobURL := fmt.Sprintf("%s/xrpc/%s?%s", knotmirror, tangled.GitTempGetBlobNSID, query.Encode()) 307 return blobURL 308 // return path.Join("/", repo.RepoDid, url.PathEscape(ref), filePath) 309} 310 311// TODO: dedup with strings 312func countLines(content string) int { 313 if content == "" { 314 return 0 315 } 316 317 count := strings.Count(content, "\n") 318 319 if !strings.HasSuffix(content, "\n") { 320 count++ 321 } 322 323 return count 324}