A vibe coded tangled fork which supports pijul.
at master 367 lines 9.8 kB view raw
1package repo 2 3import ( 4 "encoding/base64" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "path/filepath" 10 "slices" 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 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 25 "github.com/go-chi/chi/v5" 26 "github.com/go-git/go-git/v5/plumbing" 27) 28 29// the content can be one of the following: 30// 31// - code : text | | raw 32// - markup : text | rendered | raw 33// - svg : text | rendered | raw 34// - png : | rendered | raw 35// - video : | rendered | raw 36// - submodule : | rendered | 37// - rest : | | 38func (rp *Repo) Blob(w http.ResponseWriter, r *http.Request) { 39 l := rp.logger.With("handler", "RepoBlob") 40 41 f, err := rp.repoResolver.Resolve(r) 42 if err != nil { 43 l.Error("failed to get repo and knot", "err", err) 44 return 45 } 46 47 ref := chi.URLParam(r, "ref") 48 ref, _ = url.PathUnescape(ref) 49 50 filePath := chi.URLParam(r, "*") 51 filePath, _ = url.PathUnescape(filePath) 52 53 scheme := "http" 54 if !rp.config.Core.Dev { 55 scheme = "https" 56 } 57 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 58 xrpcc := &indigoxrpc.Client{ 59 Host: host, 60 } 61 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 62 63 var resp *tangled.RepoBlob_Output 64 if f.IsPijul() { 65 pResp, err := tangled.RepoPijulBlob(r.Context(), xrpcc, ref, filePath, repo) 66 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 67 l.Error("failed to call XRPC repo.pijulBlob", "err", xrpcerr) 68 rp.pages.Error503(w) 69 return 70 } 71 resp = &tangled.RepoBlob_Output{ 72 Path: pResp.Path, 73 Content: pResp.Contents, 74 IsBinary: &pResp.Is_binary, 75 } 76 } else { 77 var err error 78 resp, err = tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, repo) 79 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 80 l.Error("failed to call XRPC repo.blob", "err", xrpcerr) 81 rp.pages.Error503(w) 82 return 83 } 84 } 85 86 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 87 88 // Use XRPC response directly instead of converting to internal types 89 var breadcrumbs [][]string 90 breadcrumbs = append(breadcrumbs, []string{f.Name, fmt.Sprintf("/%s/tree/%s", ownerSlashRepo, url.PathEscape(ref))}) 91 if filePath != "" { 92 for idx, elem := range strings.Split(filePath, "/") { 93 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], url.PathEscape(elem))}) 94 } 95 } 96 97 // Create the blob view 98 blobView := NewBlobView(resp, rp.config, f, ref, filePath, r.URL.Query()) 99 100 user := rp.oauth.GetMultiAccountUser(r) 101 102 // Get email to DID mapping for commit author 103 var emails []string 104 if resp.LastCommit != nil && resp.LastCommit.Author != nil { 105 emails = append(emails, resp.LastCommit.Author.Email) 106 } 107 emailToDidMap, err := db.GetEmailToDid(rp.db, emails, true) 108 if err != nil { 109 l.Error("failed to get email to did mapping", "err", err) 110 emailToDidMap = make(map[string]string) 111 } 112 113 var lastCommitInfo *types.LastCommitInfo 114 if resp.LastCommit != nil { 115 when, _ := time.Parse(time.RFC3339, resp.LastCommit.When) 116 lastCommitInfo = &types.LastCommitInfo{ 117 Hash: plumbing.NewHash(resp.LastCommit.Hash), 118 Message: resp.LastCommit.Message, 119 When: when, 120 } 121 if resp.LastCommit.Author != nil { 122 lastCommitInfo.Author.Name = resp.LastCommit.Author.Name 123 lastCommitInfo.Author.Email = resp.LastCommit.Author.Email 124 lastCommitInfo.Author.When, _ = time.Parse(time.RFC3339, resp.LastCommit.Author.When) 125 } 126 } 127 128 rp.pages.RepoBlob(w, pages.RepoBlobParams{ 129 LoggedInUser: user, 130 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 131 BreadCrumbs: breadcrumbs, 132 BlobView: blobView, 133 EmailToDid: emailToDidMap, 134 LastCommitInfo: lastCommitInfo, 135 RepoBlob_Output: resp, 136 }) 137} 138 139func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) { 140 l := rp.logger.With("handler", "RepoBlobRaw") 141 142 f, err := rp.repoResolver.Resolve(r) 143 if err != nil { 144 l.Error("failed to get repo and knot", "err", err) 145 w.WriteHeader(http.StatusBadRequest) 146 return 147 } 148 149 ref := chi.URLParam(r, "ref") 150 ref, _ = url.PathUnescape(ref) 151 152 filePath := chi.URLParam(r, "*") 153 filePath, _ = url.PathUnescape(filePath) 154 155 scheme := "http" 156 if !rp.config.Core.Dev { 157 scheme = "https" 158 } 159 repo := f.DidSlashRepo() 160 xrpcPath := "/xrpc/sh.tangled.repo.blob" 161 if f.IsPijul() { 162 xrpcPath = "/xrpc/sh.tangled.repo.pijulBlob" 163 } 164 baseURL := &url.URL{ 165 Scheme: scheme, 166 Host: f.Knot, 167 Path: xrpcPath, 168 } 169 query := baseURL.Query() 170 query.Set("repo", repo) 171 if f.IsPijul() { 172 query.Set("channel", ref) 173 } else { 174 query.Set("ref", ref) 175 } 176 query.Set("path", filePath) 177 if !f.IsPijul() { 178 query.Set("raw", "true") 179 } 180 baseURL.RawQuery = query.Encode() 181 blobURL := baseURL.String() 182 req, err := http.NewRequest("GET", blobURL, nil) 183 if err != nil { 184 l.Error("failed to create request", "err", err) 185 return 186 } 187 188 // forward the If-None-Match header 189 if clientETag := r.Header.Get("If-None-Match"); clientETag != "" { 190 req.Header.Set("If-None-Match", clientETag) 191 } 192 client := &http.Client{} 193 194 resp, err := client.Do(req) 195 if err != nil { 196 l.Error("failed to reach knotserver", "err", err) 197 rp.pages.Error503(w) 198 return 199 } 200 201 defer resp.Body.Close() 202 203 // forward 304 not modified 204 if resp.StatusCode == http.StatusNotModified { 205 w.WriteHeader(http.StatusNotModified) 206 return 207 } 208 209 if resp.StatusCode != http.StatusOK { 210 l.Error("knotserver returned non-OK status for raw blob", "url", blobURL, "statuscode", resp.StatusCode) 211 w.WriteHeader(resp.StatusCode) 212 _, _ = io.Copy(w, resp.Body) 213 return 214 } 215 216 contentType := resp.Header.Get("Content-Type") 217 body, err := io.ReadAll(resp.Body) 218 if err != nil { 219 l.Error("error reading response body from knotserver", "err", err) 220 w.WriteHeader(http.StatusInternalServerError) 221 return 222 } 223 224 if strings.HasPrefix(contentType, "text/") || isTextualMimeType(contentType) { 225 // serve all textual content as text/plain 226 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 227 w.Write(body) 228 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") { 229 // serve images and videos with their original content type 230 w.Header().Set("Content-Type", contentType) 231 w.Write(body) 232 } else { 233 w.WriteHeader(http.StatusUnsupportedMediaType) 234 w.Write([]byte("unsupported content type")) 235 return 236 } 237} 238 239// NewBlobView creates a BlobView from the XRPC response 240func NewBlobView(resp *tangled.RepoBlob_Output, config *config.Config, repo *models.Repo, ref, filePath string, queryParams url.Values) models.BlobView { 241 view := models.BlobView{ 242 Contents: "", 243 Lines: 0, 244 } 245 246 // Set size 247 if resp.Size != nil { 248 view.SizeHint = uint64(*resp.Size) 249 } else if resp.Content != nil { 250 view.SizeHint = uint64(len(*resp.Content)) 251 } 252 253 if resp.Submodule != nil { 254 view.ContentType = models.BlobContentTypeSubmodule 255 view.HasRenderedView = true 256 view.ContentSrc = resp.Submodule.Url 257 return view 258 } 259 260 // Determine if binary 261 if resp.IsBinary != nil && *resp.IsBinary { 262 view.ContentSrc = generateBlobURL(config, 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 view.HasRawView = true 269 view.HasRenderedView = true 270 view.ShowingRendered = true 271 272 case ".svg": 273 view.ContentType = models.BlobContentTypeSvg 274 view.HasRawView = true 275 view.HasTextView = true 276 view.HasRenderedView = true 277 view.ShowingRendered = queryParams.Get("code") != "true" 278 if resp.Content != nil { 279 bytes, _ := base64.StdEncoding.DecodeString(*resp.Content) 280 view.Contents = string(bytes) 281 view.Lines = countLines(view.Contents) 282 } 283 284 case ".mp4", ".webm", ".ogg", ".mov", ".avi": 285 view.ContentType = models.BlobContentTypeVideo 286 view.HasRawView = true 287 view.HasRenderedView = true 288 view.ShowingRendered = true 289 } 290 291 return view 292 } 293 294 // otherwise, we are dealing with text content 295 view.HasRawView = true 296 view.HasTextView = true 297 298 if resp.Content != nil { 299 view.Contents = *resp.Content 300 view.Lines = countLines(view.Contents) 301 } 302 303 // with text, we may be dealing with markdown 304 format := markup.GetFormat(resp.Path) 305 if format == markup.FormatMarkdown { 306 view.ContentType = models.BlobContentTypeMarkup 307 view.HasRenderedView = true 308 view.ShowingRendered = queryParams.Get("code") != "true" 309 } 310 311 return view 312} 313 314func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { 315 scheme := "http" 316 if !config.Core.Dev { 317 scheme = "https" 318 } 319 320 repoName := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 321 baseURL := &url.URL{ 322 Scheme: scheme, 323 Host: repo.Knot, 324 Path: "/xrpc/sh.tangled.repo.blob", 325 } 326 query := baseURL.Query() 327 query.Set("repo", repoName) 328 query.Set("ref", ref) 329 query.Set("path", filePath) 330 query.Set("raw", "true") 331 baseURL.RawQuery = query.Encode() 332 blobURL := baseURL.String() 333 334 if !config.Core.Dev { 335 return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) 336 } 337 return blobURL 338} 339 340func isTextualMimeType(mimeType string) bool { 341 textualTypes := []string{ 342 "application/json", 343 "application/xml", 344 "application/yaml", 345 "application/x-yaml", 346 "application/toml", 347 "application/javascript", 348 "application/ecmascript", 349 "message/", 350 } 351 return slices.Contains(textualTypes, mimeType) 352} 353 354// TODO: dedup with strings 355func countLines(content string) int { 356 if content == "" { 357 return 0 358 } 359 360 count := strings.Count(content, "\n") 361 362 if !strings.HasSuffix(content, "\n") { 363 count++ 364 } 365 366 return count 367}