Files
hotdog-cms/internal/site/markdown.go
T

207 lines
6.8 KiB
Go

package site
import (
"bytes"
"fmt"
"html/template"
"net/url"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// Markdown renders page bodies. Raw HTML is dropped unless the site turns it
// on, and even then it passes a sanitizer, so a page can carry a <figure> or a
// <details> but not a <script> or an onclick.
type Markdown struct {
trust bool
md goldmark.Markdown
sanitize *bluemonday.Policy
emailOff bool
newTab bool
host string // this site's host, for telling external links apart
media map[string]*MediaPicture // static/media/, by main file address
imageSizes string
}
// NewMarkdown builds the renderer for a site's settings. host is the site's
// own host name, so links elsewhere can be told apart.
func NewMarkdown(c MarkdownConfig, host string) *Markdown {
exts := []goldmark.Extender{extension.Table, extension.Strikethrough, extension.TaskList, extension.Footnote}
if c.AutolinkOn() {
exts = append(exts, extension.Linkify)
}
rendererOpts := []goldmark.Option{
goldmark.WithExtensions(exts...),
goldmark.WithParserOptions(parser.WithAutoHeadingID(), parser.WithAttribute()),
}
if !c.TrustHTML {
// {#id .class} after a heading is handy; {style=…} or {onclick=…}
// would be raw HTML by another name.
rendererOpts = append(rendererOpts, goldmark.WithParserOptions(parser.WithASTTransformers(util.Prioritized(idClassOnly{}, 1000))))
}
m := &Markdown{trust: c.TrustHTML, emailOff: c.EmailOff, newTab: c.ExternalLinks == "new_tab", host: strings.ToLower(host), imageSizes: c.ImageSizes}
if m.imageSizes == "" {
m.imageSizes = defaultImageSizes
}
if c.TrustHTML {
rendererOpts = append(rendererOpts, goldmark.WithRendererOptions(html.WithUnsafe()))
} else if c.UnsafeHTML {
rendererOpts = append(rendererOpts, goldmark.WithRendererOptions(html.WithUnsafe()))
p := bluemonday.UGCPolicy()
p.AllowAttrs("class", "id").Globally()
p.AllowElements("figure", "figcaption", "details", "summary", "picture", "source", "video", "audio")
p.AllowAttrs("srcset", "sizes", "type", "media").OnElements("source", "img")
p.AllowAttrs("controls", "poster", "src", "preload", "muted", "loop", "playsinline").OnElements("video", "audio")
p.AllowAttrs("loading", "decoding", "width", "height").OnElements("img")
m.sanitize = p
}
m.md = goldmark.New(rendererOpts...)
return m
}
// idClassOnly drops every Markdown attribute but id and class.
type idClassOnly struct{}
func (idClassOnly) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
attrs := n.Attributes()
if len(attrs) == 0 {
return ast.WalkContinue, nil
}
var keep []ast.Attribute
for _, a := range attrs {
if k := string(a.Name); k == "id" || k == "class" {
keep = append(keep, a)
}
}
n.RemoveAttributes()
for _, a := range keep {
n.SetAttribute(a.Name, a.Value)
}
return ast.WalkContinue, nil
})
}
// protectAddresses puts Cloudflare's email_off markers around everything its
// Email Address Obfuscation would rewrite: mailto links, and email addresses
// and fediverse handles (@user@host, which look like addresses to it) in text.
// Without them a reader sees "[email protected]" where the address was.
func protectAddresses(h []byte) []byte {
h = mailtoRe.ReplaceAll(h, []byte("<!--email_off-->$0<!--/email_off-->"))
var b bytes.Buffer
off := false
for _, seg := range tagSplitRe.FindAllIndex(h, -1) {
part := h[seg[0]:seg[1]]
switch {
case bytes.Equal(part, []byte("<!--email_off-->")):
off = true
b.Write(part)
case bytes.Equal(part, []byte("<!--/email_off-->")):
off = false
b.Write(part)
case part[0] == '<' || off:
b.Write(part)
default:
b.Write(addressRe.ReplaceAll(part, []byte("<!--email_off-->$0<!--/email_off-->")))
}
}
return b.Bytes()
}
var (
tagSplitRe = regexp.MustCompile(`<!--.*?-->|<[^>]*>|[^<]+`)
addressRe = regexp.MustCompile(`@?[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`)
mailtoRe = regexp.MustCompile(`<a href="mailto:[^"]*"[^>]*>.*?</a>`)
anchorRe = regexp.MustCompile(`<a\s[^>]*?href="([^"]+)"[^>]*>`)
)
// External reports whether a link leads off this site: an absolute http(s)
// URL to another host. www. and the bare host count as the same site.
func (m *Markdown) External(href string) bool {
u, err := url.Parse(href)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return false
}
h := strings.TrimPrefix(strings.ToLower(u.Hostname()), "www.")
return h != strings.TrimPrefix(m.host, "www.")
}
// Inline renders a short piece of Markdown, such as a subtitle with a code
// span in it, without the paragraph around it.
func (m *Markdown) Inline(src []byte) (template.HTML, error) {
h, err := m.Render(src)
if err != nil {
return "", err
}
s := strings.TrimSpace(string(h))
if strings.HasPrefix(s, "<p>") && strings.HasSuffix(s, "</p>") && strings.Count(s, "<p>") == 1 {
s = s[3 : len(s)-4]
}
return template.HTML(s), nil
}
// HTML takes a body written as HTML rather than Markdown (front matter
// format: html), applying the same rule raw HTML in Markdown gets: passed
// through when the site trusts its authors, sanitized when it allows raw HTML,
// refused otherwise.
func (m *Markdown) HTML(src []byte) (template.HTML, error) {
switch {
case m.trust:
case m.sanitize != nil:
src = m.sanitize.SanitizeBytes(src)
default:
return "", fmt.Errorf("format: html needs markdown.trust_html or markdown.unsafe_html in site.yaml")
}
out := m.pictures(src)
if m.newTab {
out = m.markNewTab(out)
}
return template.HTML(out), nil
}
func (m *Markdown) markNewTab(out []byte) []byte {
return anchorRe.ReplaceAllFunc(out, func(a []byte) []byte {
sub := anchorRe.FindSubmatch(a)
if bytes.Contains(bytes.ToLower(a), []byte("target=")) || !m.External(string(sub[1])) {
return a
}
return append(a[:len(a)-1:len(a)-1], []byte(` target="_blank" rel="noopener noreferrer">`)...)
})
}
// Render turns Markdown into HTML that is safe to place in a page.
func (m *Markdown) Render(src []byte) (template.HTML, error) {
var buf bytes.Buffer
if err := m.md.Convert(src, &buf); err != nil {
return "", err
}
out := buf.Bytes()
if m.sanitize != nil {
out = m.sanitize.SanitizeBytes(out)
}
// After the sanitizer: the picture markup is ours, not the author's.
out = m.pictures(out)
if m.emailOff {
out = protectAddresses(out)
}
if m.newTab {
out = m.markNewTab(out)
}
// Safe by construction: goldmark escapes text and, without WithUnsafe,
// omits raw HTML; with it, the sanitizer above has had the last word.
return template.HTML(out), nil
}