diff --git a/blobstore/blobstore.go b/blobstore/blobstore.go --- a/blobstore/blobstore.go +++ b/blobstore/blobstore.go @@ -9,5 +9,6 @@ ) type BlobStore interface { + GetBlobUrl(ctx context.Context, did syntax.DID, cid cid.Cid) (string, error) GetBlob(ctx context.Context, did syntax.DID, cid cid.Cid) (io.ReadCloser, error) } diff --git a/blobstore/pds.go b/blobstore/pds.go --- a/blobstore/pds.go +++ b/blobstore/pds.go @@ -20,10 +20,12 @@ return &Pds{dir} } -func (s *Pds) GetBlob(ctx context.Context, did syntax.DID, cid cid.Cid) (io.ReadCloser, error) { +var _ BlobStore = new(Pds) + +func (s *Pds) GetBlobUrl(ctx context.Context, did syntax.DID, cid cid.Cid) (string, error) { id, err := s.dir.LookupDID(ctx, did) if err != nil { - return nil, err + return "", err } url, _ := url.Parse(fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob", id.PDSEndpoint())) @@ -32,7 +34,16 @@ q.Set("cid", cid.String()) url.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil) + return url.String(), nil +} + +func (s *Pds) GetBlob(ctx context.Context, did syntax.DID, cid cid.Cid) (io.ReadCloser, error) { + url, err := s.GetBlobUrl(ctx, did, cid) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err diff --git a/blobstore/porxie.go b/blobstore/porxie.go --- a/blobstore/porxie.go +++ b/blobstore/porxie.go @@ -18,8 +18,14 @@ return &Porxie{url} } +var _ BlobStore = new(Porxie) + +func (s *Porxie) GetBlobUrl(ctx context.Context, did syntax.DID, cid cid.Cid) (string, error) { + return fmt.Sprintf("%s/%s/%s", s.url, did, cid), nil +} + func (s *Porxie) GetBlob(ctx context.Context, did syntax.DID, cid cid.Cid) (io.ReadCloser, error) { - url := fmt.Sprintf("%s/%s/%s", s.url, did, cid) + url, _ := s.GetBlobUrl(ctx, did, cid) resp, err := http.Get(url) if err != nil { return nil, err diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -294,6 +294,11 @@ sanitized := rctx.SanitizeDefault(htmlString) return template.HTML(sanitized) }, + "markdown2": func(owner syntax.DID, text string) template.HTML { + htmlString := p.markdown.RenderMarkdownBody(owner, text) + sanitized := p.rctx.SanitizeDefault(htmlString) + return template.HTML(sanitized) + }, "code": func(content, path string) string { var style *chroma.Style = styles.Get("catpuccin-latte") formatter := chromahtml.New( diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -26,6 +26,7 @@ "tangled.org/core/appview/pages/markup" "tangled.org/core/appview/pages/repoinfo" "tangled.org/core/appview/pagination" + "tangled.org/core/blobstore" "tangled.org/core/idresolver" "tangled.org/core/types" @@ -50,9 +51,11 @@ templateDir string // Path to templates on disk for dev mode rctx *markup.RenderContext logger *slog.Logger + + markdown *markup.MarkdownRenderer } -func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, logger *slog.Logger) *Pages { +func NewPages(config *config.Config, res *idresolver.Resolver, database *db.DB, rdb *cache.Cache, blobStore blobstore.BlobStore, logger *slog.Logger) *Pages { // initialized with safe defaults, can be overridden per use rctx := &markup.RenderContext{ IsDev: config.Core.Dev, @@ -60,7 +63,6 @@ CamoUrl: config.Camo.Host, CamoSecret: config.Camo.SharedSecret, Sanitizer: markup.NewSanitizer(), - Files: Files, } p := &Pages{ @@ -75,6 +77,7 @@ rdb: rdb, templateDir: "appview/pages", logger: logger, + markdown: markup.NewMarkdownRenderer(config, blobStore), } if p.dev { @@ -1278,8 +1281,13 @@ return p.executePlain("repo/pulls/fragments/pullComposeHost", w, params) } -func (p *Pages) MarkdownPreviewFragment(w io.Writer, body string) error { - return p.executePlain("fragments/markdownPreview", w, body) +type MarkdownPreviewFragmentParams struct { + LoggedInUser *oauth.MultiAccountUser + Content string +} + +func (p *Pages) MarkdownPreviewFragment(w io.Writer, params MarkdownPreviewFragmentParams) error { + return p.executePlain("fragments/markdownPreview", w, params) } type EditPullParams struct { diff --git a/appview/state/markup.go b/appview/state/markup.go --- a/appview/state/markup.go +++ b/appview/state/markup.go @@ -1,8 +1,14 @@ package state -import "net/http" +import ( + "net/http" + + "tangled.org/core/appview/pages" +) func (s *State) MarkdownPreview(w http.ResponseWriter, r *http.Request) { - body := r.FormValue("body") - s.pages.MarkdownPreviewFragment(w, body) + s.pages.MarkdownPreviewFragment(w, pages.MarkdownPreviewFragmentParams{ + LoggedInUser: s.oauth.GetMultiAccountUser(r), + Content: r.FormValue("body"), + }) } diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -109,7 +109,14 @@ return nil, fmt.Errorf("failed to create posthog client: %w", err) } - pages := pages.NewPages(config, res, d, rdb, log.SubLogger(logger, "pages")) + var blobStore blobstore.BlobStore + if config.Porxie.Url != "" { + blobStore = blobstore.NewPorxieBlobStore(config.Porxie.Url) + } else { + blobStore = blobstore.NewPdsBlobStore(res.Directory()) + } + + pages := pages.NewPages(config, res, d, rdb, blobStore, log.SubLogger(logger, "pages")) oauth, err := oauth.New(config, posthog, d, enforcer, res, log.SubLogger(logger, "oauth")) if err != nil { return nil, fmt.Errorf("failed to start oauth handler: %w", err) @@ -119,13 +126,6 @@ repoResolver := reporesolver.New(config, enforcer, d, rdb) mentionsResolver := mentions.New(config, res, d, log.SubLogger(logger, "mentionsResolver")) - - var blobStore blobstore.BlobStore - if config.Porxie.Url != "" { - blobStore = blobstore.NewPorxieBlobStore(config.Porxie.Url) - } else { - blobStore = blobstore.NewPdsBlobStore(res.Directory()) - } jc, err := jetstream.NewJetstreamClient( config.Jetstream.Endpoint, diff --git a/cmd/blog/main.go b/cmd/blog/main.go --- a/cmd/blog/main.go +++ b/cmd/blog/main.go @@ -54,7 +54,7 @@ func makePages(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*pages.Pages, error) { resolver := idresolver.DefaultResolver(cfg.Plc.PLCURL) - return pages.NewPages(cfg, resolver, nil, nil, logger), nil + return pages.NewPages(cfg, resolver, nil, nil, nil, logger), nil } func runBuild(ctx context.Context, logger *slog.Logger) error { diff --git a/appview/pages/markup/markdown2.go b/appview/pages/markup/markdown2.go new file mode 100644 --- /dev/null +++ b/appview/pages/markup/markdown2.go @@ -0,0 +1,197 @@ +package markup + +import ( + "bytes" + "context" + "fmt" + "io" + "net/url" + "strings" + + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/ipfs/go-cid" + "github.com/yuin/goldmark" + emoji "github.com/yuin/goldmark-emoji" + highlighting "github.com/yuin/goldmark-highlighting/v2" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer/html" + callout "gitlab.com/staticnoise/goldmark-callout" + "go.abhg.dev/goldmark/mermaid" + htmlparse "golang.org/x/net/html" + "tangled.org/core/appview/config" + textension "tangled.org/core/appview/pages/markup/extension" + "tangled.org/core/blobstore" +) + +// make more pure markdown renderer. + +type MarkdownRenderer struct { + CamoUrl string + CamoSecret string + Hostname string + blobStore blobstore.BlobStore + markdown goldmark.Markdown +} + +func NewMarkdownRenderer(cfg *config.Config, blobStore blobstore.BlobStore) *MarkdownRenderer { + markdown := goldmark.New( + goldmark.WithExtensions( + extension.GFM, + &mermaid.Extender{ + RenderMode: mermaid.RenderModeClient, + NoScript: true, + }, + highlighting.NewHighlighting( + highlighting.WithFormatOptions( + chromahtml.Standalone(false), + chromahtml.WithClasses(true), + ), + highlighting.WithCustomStyle(styles.Get("catppuccin-latte")), + ), + extension.NewFootnote( + extension.WithFootnoteIDPrefix([]byte("footnote")), + ), + callout.CalloutExtention, + textension.AtExt, + textension.NewTangledLinkExt(cfg.Core.AppviewHost), + emoji.Emoji, + ), + goldmark.WithParserOptions( + parser.WithAutoHeadingID(), + ), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) + return &MarkdownRenderer{ + CamoUrl: cfg.Camo.Host, + CamoSecret: cfg.Camo.SharedSecret, + Hostname: cfg.Core.AppviewHost, + blobStore: blobStore, + markdown: markdown, + } +} + +type RenderBodyContext struct { + OwnerDid syntax.DID // Content owner DID. Used to make blob links +} + +type RenderGitBlobContext struct { + RepoDid syntax.DID + CurrentRev string + CurrentDir string +} + +func (m *MarkdownRenderer) RenderMarkdownBody(owner syntax.DID, source string) string { + var buf bytes.Buffer + if err := m.markdown.Convert([]byte(source), &buf); err != nil { + return source + } + + rctx := RenderBodyContext{ + OwnerDid: owner, + } + + var processed strings.Builder + if err := m.postProcess( + strings.NewReader(buf.String()), + &processed, + func(n *htmlparse.Node) { m.visitNode(&rctx, n) }, + ); err != nil { + return source + } + + return processed.String() +} + +func (m *MarkdownRenderer) postProcess(input io.Reader, output io.Writer, processFn func(*htmlparse.Node)) error { + node, err := htmlparse.Parse(io.MultiReader( + strings.NewReader(""), + input, + strings.NewReader(""), + )) + if err != nil { + return fmt.Errorf("failed to parse html: %w", err) + } + + if node.Type == htmlparse.DocumentNode { + node = node.FirstChild + } + + processFn(node) + + newNodes := make([]*htmlparse.Node, 0, 5) + + if node.Data == "html" { + node = node.FirstChild + for node != nil && node.Data != "body" { + node = node.NextSibling + } + } + if node != nil { + if node.Data == "body" { + child := node.FirstChild + for child != nil { + newNodes = append(newNodes, child) + child = child.NextSibling + } + } else { + newNodes = append(newNodes, node) + } + } + + for _, node := range newNodes { + if err := htmlparse.Render(output, node); err != nil { + return fmt.Errorf("failed to render processed html: %w", err) + } + } + + return nil +} + +func (m *MarkdownRenderer) visitNode(rctx *RenderBodyContext, node *htmlparse.Node) { + switch node.Type { + case htmlparse.ElementNode: + switch node.Data { + case "img", "source": + for i, attr := range node.Attr { + if attr.Key != "src" { + continue + } + + if rawCid, found := strings.CutPrefix(attr.Val, "blob://"); found { + cid, err := cid.Parse(rawCid) + if err != nil { + continue // skip invalid cid + } + blobUrl, err := m.blobStore.GetBlobUrl(context.TODO(), rctx.OwnerDid, cid) + if err != nil { + continue + } + attr.Val = blobUrl + node.Attr[i] = attr + continue + } + + src, err := url.Parse(attr.Val) + if err != nil { + continue // skip invalid url + } + + if src.IsAbs() { + camoUrl, _ := url.Parse(m.CamoUrl) + if camoUrl != nil && src.Host != m.Hostname && src.Host != camoUrl.Host { + attr.Val = GenerateCamoURL(m.CamoUrl, m.CamoSecret, attr.Val) + node.Attr[i] = attr + } + } + } + } + + for n := node.FirstChild; n != nil; n = n.NextSibling { + m.visitNode(rctx, n) + } + default: + } +} diff --git a/appview/pages/templates/fragments/markdownPreview.html b/appview/pages/templates/fragments/markdownPreview.html --- a/appview/pages/templates/fragments/markdownPreview.html +++ b/appview/pages/templates/fragments/markdownPreview.html @@ -1,7 +1,7 @@ {{ define "fragments/markdownPreview" }} {{ if . }}
- {{ . | markdown }} + {{ markdown2 (did .LoggedInUser.Did) .Content }}
{{ else }}
Nothing to preview.
diff --git a/appview/pages/templates/fragments/comment/commentBody.html b/appview/pages/templates/fragments/comment/commentBody.html --- a/appview/pages/templates/fragments/comment/commentBody.html +++ b/appview/pages/templates/fragments/comment/commentBody.html @@ -1,7 +1,7 @@ {{ define "fragments/comment/commentBody" }}
{{ if not .Comment.Deleted }} -
{{ .Comment.Body.Text | markdown }}
+
{{ markdown2 .Comment.Did .Comment.Body.Text }}
{{ template "repo/fragments/reactions" (dict "Reactions" .Reactions "UserReacted" .UserReacted