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