From 8dcd952d9f73241addbf27cfb95eae85b8c94688 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Fri, 01 May 2026 12:19:30 +0000 Subject: [PATCH] appview,knotmirror: prefer knotmirror to load blobs also use octet-stream for blobs larger than 1MB to avoid OOM Signed-off-by: Seongmin Lee --- api/tangled/repoblob.go | 3 ++- appview/config/config.go | 4 ++++ appview/models/repo.go | 1 + appview/repo/blob.go | 69 +++++++++++++++++---------------------------------------------------- knotmirror/xrpc/git_get_blob.go | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- knotmirror/xrpc/repo_blob.go | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ knotmirror/xrpc/xrpc.go | 1 + lexicons/repo/blob.json | 3 +++ appview/pages/markup/markdown.go | 41 ++++++++--------------------------------- appview/pages/templates/repo/blob.html | 4 ++++ 10 file(s) changed, 262 insertion(s)(+), 89 deletion(s)(-) diff --git a/api/tangled/repoblob.go b/api/tangled/repoblob.go --- a/api/tangled/repoblob.go +++ b/api/tangled/repoblob.go @@ -30,7 +30,8 @@ // content: File content (base64 encoded for binary files) Content *string `json:"content,omitempty" cborgen:"content,omitempty"` // encoding: Content encoding - Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` + Encoding *string `json:"encoding,omitempty" cborgen:"encoding,omitempty"` + FileTooLarge *bool `json:"fileTooLarge,omitempty" cborgen:"fileTooLarge,omitempty"` // isBinary: Whether the file is binary IsBinary *bool `json:"isBinary,omitempty" cborgen:"isBinary,omitempty"` LastCommit *RepoBlob_LastCommit `json:"lastCommit,omitempty" cborgen:"lastCommit,omitempty"` diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -73,6 +73,10 @@ SharedSecret string `env:"SHARED_SECRET"` } +func (c *CamoConfig) Enabled() bool { + return c.SharedSecret != "" +} + type AvatarConfig struct { Host string `env:"HOST, default=https://avatar.tangled.sh"` SharedSecret string `env:"SHARED_SECRET"` diff --git a/appview/models/repo.go b/appview/models/repo.go --- a/appview/models/repo.go +++ b/appview/models/repo.go @@ -144,6 +144,7 @@ HasTextView bool // can show as code/text HasRenderedView bool // can show rendered (markup/image/video/submodule) HasRawView bool // can download raw (everything except submodule) + FileTooLarge bool // file too large (ignored for image files) // current display mode ShowingRendered bool // currently in rendered mode diff --git a/appview/repo/blob.go b/appview/repo/blob.go --- a/appview/repo/blob.go +++ b/appview/repo/blob.go @@ -51,15 +51,8 @@ filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - host := fmt.Sprintf("%s://%s", scheme, f.Knot) - xrpcc := &indigoxrpc.Client{ - Host: host, - } - resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoIdentifier()) + xrpcc := &indigoxrpc.Client{Host: rp.config.KnotMirror.Url} + resp, err := tangled.RepoBlob(r.Context(), xrpcc, filePath, false, ref, f.RepoAt().String()) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { l.Error("failed to call XRPC repo.blob", "xrpcerr", xrpcerr, "err", err) rp.pages.Error503(w) @@ -135,23 +128,8 @@ filePath := chi.URLParam(r, "*") filePath, _ = url.PathUnescape(filePath) - scheme := "http" - if !rp.config.Core.Dev { - scheme = "https" - } - repo := f.RepoIdentifier() - baseURL := &url.URL{ - Scheme: scheme, - Host: f.Knot, - Path: "/xrpc/sh.tangled.repo.blob", - } - query := baseURL.Query() - query.Set("repo", repo) - query.Set("ref", ref) - query.Set("path", filePath) - query.Set("raw", "true") - baseURL.RawQuery = query.Encode() - blobURL := baseURL.String() + blobURL := generateBlobURL(rp.config, f, ref, filePath) + req, err := http.NewRequest("GET", blobURL, nil) if err != nil { l.Error("failed to create request", "err", err) @@ -187,12 +165,6 @@ } contentType := resp.Header.Get("Content-Type") - body, err := io.ReadAll(resp.Body) - if err != nil { - l.Error("error reading response body from knotserver", "err", err) - w.WriteHeader(http.StatusInternalServerError) - return - } // Normalize to bare media type before classification; strips parameters // (e.g. "; charset=utf-8") and prevents bypass attempts like @@ -208,14 +180,18 @@ // Serve all textual content as plain text so the browser never // interprets knot-supplied markup or scripts. w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Write(body) - case safeBinaryMIMEType(mediaType): + case safeBinaryMIMEType(mediaType) || contentType == "application/octet-stream": // Use the normalized type, never the raw knot-supplied string. w.Header().Set("Content-Type", mediaType) - w.Write(body) default: w.WriteHeader(http.StatusUnsupportedMediaType) w.Write([]byte("unsupported content type")) + return + } + if _, err := io.Copy(w, resp.Body); err != nil { + l.Error("error streaming knotmirror response", "err", err) + w.WriteHeader(http.StatusInternalServerError) + return } } @@ -241,7 +217,7 @@ } // Determine if binary - if resp.IsBinary != nil && *resp.IsBinary { + if (resp.IsBinary != nil && *resp.IsBinary) || (resp.FileTooLarge != nil && *resp.FileTooLarge) { view.ContentSrc = generateBlobURL(config, repo, ref, filePath) ext := strings.ToLower(filepath.Ext(resp.Path)) @@ -295,26 +271,15 @@ } func generateBlobURL(config *config.Config, repo *models.Repo, ref, filePath string) string { - scheme := "http" - if !config.Core.Dev { - scheme = "https" - } - - repoName := repo.RepoIdentifier() - baseURL := &url.URL{ - Scheme: scheme, - Host: repo.Knot, - Path: "/xrpc/sh.tangled.repo.blob", - } - query := baseURL.Query() - query.Set("repo", repoName) + query := url.Values{} + query.Set("repo", string(repo.RepoAt())) query.Set("ref", ref) query.Set("path", filePath) query.Set("raw", "true") - baseURL.RawQuery = query.Encode() - blobURL := baseURL.String() - if !config.Core.Dev { + blobURL := fmt.Sprintf("%s/xrpc/%s?%s", config.KnotMirror.Url, tangled.GitTempGetBlobNSID, query.Encode()) + + if config.Camo.Enabled() { return markup.GenerateCamoURL(config.Camo.Host, config.Camo.SharedSecret, blobURL) } return blobURL diff --git a/knotmirror/xrpc/git_get_blob.go b/knotmirror/xrpc/git_get_blob.go --- a/knotmirror/xrpc/git_get_blob.go +++ b/knotmirror/xrpc/git_get_blob.go @@ -2,10 +2,13 @@ import ( "context" + "crypto/sha256" "fmt" "io" "net/http" + "path/filepath" "slices" + "strings" "github.com/bluesky-social/indigo/atproto/atclient" "github.com/bluesky-social/indigo/atproto/syntax" @@ -51,10 +54,56 @@ } defer reader.Close() - w.Header().Set("Content-Type", "application/octet-stream") - if _, err := io.Copy(w, reader); err != nil { - l.Error("failed to serve the blob", "err", err) + // default to octet-stream for large blobs + if file.Size > 1000*1000 { // 1MB + w.Header().Set("Content-Type", "application/octet-stream") + if _, err := io.Copy(w, reader); err != nil { + l.Error("failed to serve the blob", "err", err) + } + return } + + contents, err := io.ReadAll(reader) + if err != nil { + l.Error("failed to read blob content", "err", err) + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"}) + return + } + + mimeType := http.DetectContentType(contents) + // override MIME types for formats that http.DetectContentType does not recognize + switch filepath.Ext(path) { + case ".svg": + mimeType = "image/svg+xml" + case ".avif": + mimeType = "image/avif" + case ".jxl": + mimeType = "image/jxl" + case ".heic", ".heif": + mimeType = "image/heif" + } + + switch { + case strings.HasPrefix(mimeType, "image/"), strings.HasPrefix(mimeType, "video/"): + eTag := fmt.Sprintf("\"%x\"", sha256.Sum256(contents)) + if clientETag := r.Header.Get("If-None-Match"); clientETag == eTag { + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", eTag) + w.Header().Set("Content-Type", mimeType) + + case strings.HasPrefix(mimeType, "text/") || isTextualMimeType(mimeType): + w.Header().Set("Cache-Control", "public, no-cache") + // seve all text content as text/plain + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + + default: + l.Error("attempted to serve disallowed file type", "mimetype", mimeType) + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InvalidRequest", Message: "only image, video, and text files can be accessed directly"}) + return + } + w.Write(contents) } func (x *Xrpc) getFile(ctx context.Context, repo syntax.ATURI, ref, path string) (*object.File, error) { diff --git a/knotmirror/xrpc/repo_blob.go b/knotmirror/xrpc/repo_blob.go new file mode 100644 --- /dev/null +++ b/knotmirror/xrpc/repo_blob.go @@ -0,0 +1,170 @@ +package xrpc + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/syntax" + "tangled.org/core/api/tangled" + "tangled.org/core/knotserver/git" +) + +// TODO(boltless): rewrite lexicon in new NSID +func (x *Xrpc) RepoBlob(w http.ResponseWriter, r *http.Request) { + var ( + repoQuery = r.URL.Query().Get("repo") + ref = r.URL.Query().Get("ref") // ref can be empty (git.Open handles this) + path = r.URL.Query().Get("path") + ) + + repo, err := syntax.ParseATURI(repoQuery) + if err != nil || repo.RecordKey() == "" { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: fmt.Sprintf("repo parameter invalid: %s", repoQuery)}) + return + } + + l := x.logger.With("repo", repo, "ref", ref, "path", path) + + if path == "" { + writeJson(w, http.StatusBadRequest, atclient.ErrorBody{Name: "BadRequest", Message: "missing path parameter"}) + return + } + + gr, err := x.getRepo(r.Context(), repo, ref) + if err != nil { + l.Warn("local mirror failed, trying proxy", "err", err) + if x.proxyToKnot(w, r, repo) { + return + } + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) + return + } + + // first check if this path is a submodule + submodule, err := gr.Submodule(path) + if err != nil { + // this is okay, continue and try to treat it as a regular file + } else { + writeJson(w, http.StatusOK, tangled.RepoBlob_Output{ + Ref: ref, + Path: path, + Submodule: &tangled.RepoBlob_Submodule{ + Name: submodule.Name, + Url: submodule.URL, + Branch: &submodule.Branch, + }, + }) + return + } + + file, err := x.getFile(r.Context(), repo, ref, path) + if err != nil { + l.Warn("local mirror failed, trying proxy", "err", err) + if x.proxyToKnot(w, r, repo) { + return + } + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to get blob"}) + return + } + + if file.Size > 1000*1000 { // 1MB + fileTooLarge := true + writeJson(w, http.StatusOK, tangled.RepoBlob_Output{ + Ref: ref, + Path: path, + Size: &file.Size, + FileTooLarge: &fileTooLarge, + }) + return + } + + reader, err := file.Reader() + if err != nil { + l.Error("failed to read blob", "err", err) + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"}) + return + } + contents, err := io.ReadAll(reader) + if err != nil { + l.Error("failed to read blob content", "err", err) + writeJson(w, http.StatusInternalServerError, atclient.ErrorBody{Name: "InternalServerError", Message: "failed to read the blob"}) + return + } + + mimeType := http.DetectContentType(contents) + // override MIME types for formats that http.DetectContentType does not recognize + switch filepath.Ext(path) { + case ".svg": + mimeType = "image/svg+xml" + case ".avif": + mimeType = "image/avif" + case ".jxl": + mimeType = "image/jxl" + case ".heic", ".heif": + mimeType = "image/heif" + } + + isBinary := !(strings.HasPrefix(mimeType, "text/") || isTextualMimeType(mimeType)) + + // include content for text blob or svg + var content *string + if !isBinary { + content = new(string) + *content = string(contents) + } else if filepath.Ext(path) == ".svg" { + content = new(string) + *content = base64.StdEncoding.EncodeToString(contents) + } + + response := tangled.RepoBlob_Output{ + Ref: ref, + Path: path, + Size: &file.Size, + IsBinary: &isBinary, + Content: content, + } + + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + + lastCommit, err := gr.LastCommitFile(ctx, path) + if err == nil && lastCommit != nil { + response.LastCommit = &tangled.RepoBlob_LastCommit{ + Hash: lastCommit.Hash.String(), + Message: lastCommit.Message, + When: lastCommit.When.Format(time.RFC3339), + } + + // try to get author information + commit, err := gr.Commit(lastCommit.Hash) + if err == nil { + response.LastCommit.Author = &tangled.RepoBlob_Signature{ + Name: commit.Author.Name, + Email: commit.Author.Email, + } + } + } + + writeJson(w, http.StatusOK, response) +} + +func (x *Xrpc) getRepo(ctx context.Context, repo syntax.ATURI, ref string) (*git.GitRepo, error) { + repoPath, err := x.makeRepoPath(ctx, repo) + if err != nil { + return nil, fmt.Errorf("resolving repo at-uri: %w", err) + } + + gr, err := git.Open(repoPath, ref) + if err != nil { + return nil, fmt.Errorf("opening git repo: %w", err) + } + + return gr, nil +} diff --git a/knotmirror/xrpc/xrpc.go b/knotmirror/xrpc/xrpc.go --- a/knotmirror/xrpc/xrpc.go +++ b/knotmirror/xrpc/xrpc.go @@ -55,6 +55,7 @@ r.Get("/"+tangled.GitTempListCommitsNSID, x.ListCommits) r.Get("/"+tangled.GitTempListLanguagesNSID, x.ListLanguages) r.Get("/"+tangled.GitTempListTagsNSID, x.ListTags) + r.Get("/"+tangled.RepoBlobNSID, x.RepoBlob) r.Post("/"+tangled.SyncRequestCrawlNSID, x.RequestCrawl) return r diff --git a/lexicons/repo/blob.json b/lexicons/repo/blob.json --- a/lexicons/repo/blob.json +++ b/lexicons/repo/blob.json @@ -80,6 +80,9 @@ "lastCommit": { "type": "ref", "ref": "#lastCommit" + }, + "fileTooLarge": { + "type": "boolean" } } } diff --git a/appview/pages/markup/markdown.go b/appview/pages/markup/markdown.go --- a/appview/pages/markup/markdown.go +++ b/appview/pages/markup/markdown.go @@ -25,7 +25,6 @@ "go.abhg.dev/goldmark/mermaid" htmlparse "golang.org/x/net/html" - "tangled.org/core/api/tangled" textension "tangled.org/core/appview/pages/markup/extension" "tangled.org/core/appview/pages/repoinfo" ) @@ -177,6 +176,8 @@ switch node.Type { case htmlparse.ElementNode: switch node.Data { + case "a": + // TODO: transform `./` or `/` links to tree link case "img", "source": for i, attr := range node.Attr { if attr.Key != "src" { @@ -185,8 +186,8 @@ camoUrl, _ := url.Parse(ctx.CamoUrl) dstUrl, _ := url.Parse(attr.Val) - if dstUrl.Host != camoUrl.Host { - attr.Val = ctx.imageFromKnotTransformer(attr.Val) + if camoUrl != nil && dstUrl != nil && dstUrl.Host != camoUrl.Host { + attr.Val = ctx.imageToRawTransformer(attr.Val) attr.Val = ctx.camoImageLinkTransformer(attr.Val) node.Attr[i] = attr } @@ -224,18 +225,13 @@ case *ast.Heading: a.rctx.anchorHeadingTransformer(n) case *ast.Link: + // TODO: run this on HTML transformation instead a.rctx.relativeLinkTransformer(n) - case *ast.Image: - a.rctx.imageFromKnotAstTransformer(n) - a.rctx.camoImageLinkAstTransformer(n) } case RendererTypeDefault: switch n := n.(type) { case *ast.Heading: a.rctx.anchorHeadingTransformer(n) - case *ast.Image: - a.rctx.imageFromKnotAstTransformer(n) - a.rctx.camoImageLinkAstTransformer(n) } } @@ -257,36 +253,15 @@ link.Destination = []byte(newPath) } -func (rctx *RenderContext) imageFromKnotTransformer(dst string) string { +func (rctx *RenderContext) imageToRawTransformer(dst string) string { if isAbsoluteUrl(dst) { return dst } - scheme := "https" - if rctx.IsDev { - scheme = "http" - } - actualPath := rctx.actualPath(dst) - repoName := fmt.Sprintf("%s/%s", rctx.RepoInfo.OwnerDid, rctx.RepoInfo.Name) - - query := fmt.Sprintf("repo=%s&ref=%s&path=%s&raw=true", - url.QueryEscape(repoName), url.QueryEscape(rctx.RepoInfo.Ref), actualPath) - - parsedURL := &url.URL{ - Scheme: scheme, - Host: rctx.Knot, - Path: path.Join("/xrpc", tangled.RepoBlobNSID), - RawQuery: query, - } - newPath := parsedURL.String() - return newPath -} - -func (rctx *RenderContext) imageFromKnotAstTransformer(img *ast.Image) { - dst := string(img.Destination) - img.Destination = []byte(rctx.imageFromKnotTransformer(dst)) + newDest := path.Join("/", rctx.RepoInfo.FullName(), "raw", rctx.RepoInfo.Ref, actualPath) + return newDest } func (rctx *RenderContext) anchorHeadingTransformer(h *ast.Heading) { diff --git a/appview/pages/templates/repo/blob.html b/appview/pages/templates/repo/blob.html --- a/appview/pages/templates/repo/blob.html +++ b/appview/pages/templates/repo/blob.html @@ -107,6 +107,10 @@
{{ code .BlobView.Contents .Path | escapeHtml }}
{{ end }} + {{ else if .BlobView.FileTooLarge }} +

+ This file is too large to render. View raw.. +

{{ else if .BlobView.ContentType.IsMarkup }}
{{ if .BlobView.ShowingRendered }} -- tangled.sh