// pikchr.go renders Pikchr diagrams to sanitized inline SVG — the third
// markup Fossil dispatches on (text/x-pikchr), and the ```pikchr fence
// inside markdown.
//
// SVG is a real XSS surface (script, event handlers, foreignObject,
// javascript: in xlink:href), so the diagram does NOT ride the general
// document policy. It gets its own narrow allow-list, derived by
// enumerating what the Pikchr module actually emits — six elements and a
// closed set of geometry attributes, none of them script-bearing. That
// is defence in depth: even if Pikchr's own label escaping had a hole,
// nothing outside this vocabulary survives.
package markup
import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
"strings"
"github.com/microcosm-cc/bluemonday"
"craftdesign.group/quarry/internal/pikchr"
)
// svgPolicy admits exactly Pikchr's output vocabulary. Deliberately
// absent: script, foreignObject, use, image, a, and every on* handler —
// none of which Pikchr emits, so refusing them costs nothing.
var svgPolicy = func() *bluemonday.Policy {
p := bluemonday.NewPolicy()
p.AllowElements("svg", "path", "polygon", "circle", "ellipse", "text")
number := regexp.MustCompile(`^-?[0-9.]+$`)
// Geometry and path data: digits, separators and path commands only.
p.AllowAttrs("x", "y", "cx", "cy", "r", "rx", "ry").Matching(number).
OnElements("svg", "path", "polygon", "circle", "ellipse", "text")
p.AllowAttrs("d", "points").Matching(regexp.MustCompile(`^[MmLlHhVvCcSsQqTtAaZz0-9eE,.\s+-]*$`)).
OnElements("path", "polygon")
p.AllowAttrs("viewBox").Matching(regexp.MustCompile(`^[0-9.\s-]+$`)).OnElements("svg")
p.AllowAttrs("width", "height").Matching(number).OnElements("svg")
p.AllowAttrs("xmlns").Matching(regexp.MustCompile(`^http://www\.w3\.org/2000/svg$`)).OnElements("svg")
// Presentation: a fixed set of paint properties, plus the text
// alignment hints and the class Pikchr tags its root with.
p.AllowAttrs("fill").Matching(regexp.MustCompile(`^(none|currentColor|rgb\([0-9, ]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$`)).
OnElements("path", "polygon", "circle", "ellipse", "text", "svg")
p.AllowAttrs("text-anchor").Matching(regexp.MustCompile(`^(start|middle|end)$`)).OnElements("text")
p.AllowAttrs("dominant-baseline").Matching(regexp.MustCompile(`^[a-z-]+$`)).OnElements("text")
p.AllowAttrs("class").Matching(regexp.MustCompile(`^[a-zA-Z0-9 _-]{0,64}$`)).
OnElements("svg", "path", "polygon", "circle", "ellipse", "text")
p.AllowAttrs("data-pikchr-date").Matching(regexp.MustCompile(`^[0-9T:.Z -]{0,32}$`)).OnElements("svg")
p.AllowStyles("fill", "stroke", "stroke-width", "stroke-dasharray", "font-size", "font-weight").
OnElements("path", "polygon", "circle", "ellipse", "text", "svg")
return p
}()
// RenderPikchr compiles Pikchr source to a sanitized inline SVG. A
// syntax error (or an unavailable module) comes back as an error; the
// caller decides how to show it, which is always as escaped text rather
// than a broken page.
func RenderPikchr(src []byte) ([]byte, error) {
svg, err := pikchr.Render(src)
if err != nil {
return nil, err
}
return svgPolicy.SanitizeBytes(themeNeutral(svg)), nil
}
// pikchrBlack matches Pikchr's hardcoded default ink.
var pikchrBlack = regexp.MustCompile(`rgb\(0, ?0, ?0\)`)
// themeNeutral swaps Pikchr's default black for currentColor so a
// diagram inherits the page's text colour. Quarry's pages follow
// prefers-color-scheme, and the server cannot know the reader's theme
// at render time — black ink would be invisible on the dark theme
// (Fossil solves the same problem with a server-side --dark-mode flag,
// which cannot work for a CSS-themed page). Author-specified colours
// are left exactly as written.
func themeNeutral(svg []byte) []byte {
return pikchrBlack.ReplaceAll(svg, []byte("currentColor"))
}
// pikchrFence matches a ```pikchr fenced block — how a diagram is
// embedded in a markdown document (fossil supports the same fence).
var pikchrFence = regexp.MustCompile("(?s)```pikchr[ \t]*\r?\n(.*?)```")
// The placeholder must be plain alphanumerics: an HTML parser rewrites
// control bytes (a NUL becomes U+FFFD), so a marker built from them does
// not survive sanitization. A per-render nonce keeps user text from
// colliding with it.
func pikchrHold(nonce string, i int) string {
return fmt.Sprintf("qpikchr%sx%dx", nonce, i)
}
// svgBlock matches one inline SVG element and its contents. Ungreedy,
// so two diagrams in a document are two matches rather than one span
// that swallows everything between them.
var svgBlock = regexp.MustCompile(`(?is)<svg\b[^>]*>.*?</svg>`)
// SanitizeDoc is Sanitize for repository-authored HTML that may carry
// PRE-RENDERED diagrams.
//
// The document policy admits no <svg> at all, and should not: an
// embedded doc is content somebody else wrote, and SVG is a real XSS
// surface (script, event handlers, foreignObject, javascript: in
// xlink:href). So a document that shipped its diagrams as baked SVG —
// rather than as a ```pikchr fence quarry compiles itself — had them
// silently removed, which is what zit's submanifest guide showed:
// prose about diagrams, and no diagrams.
//
// Each SVG is lifted out, run through the SAME narrow policy a diagram
// quarry compiled gets, and put back. That policy is an allow-list of
// six elements and a closed set of geometry attributes, so output that
// really is Pikchr's survives untouched and anything else is stripped
// to nothing. The gate does not move; what changes is that a diagram
// is no longer refused for arriving already drawn.
func SanitizeDoc(in string) string {
if !svgBlock.MatchString(in) {
return Sanitize(in)
}
var buf [8]byte
rand.Read(buf[:])
nonce := hex.EncodeToString(buf[:])
var kept []string
held := svgBlock.ReplaceAllStringFunc(in, func(m string) string {
clean := strings.TrimSpace(svgPolicy.Sanitize(m))
// A diagram the policy empties is one that was not a diagram.
// It leaves nothing behind rather than an empty frame.
if clean == "" {
return ""
}
kept = append(kept, clean)
return pikchrHold(nonce, len(kept)-1)
})
return string(restorePikchrFences([]byte(Sanitize(held)), kept, nonce))
}
// liftPikchrFences replaces each ```pikchr block with a placeholder,
// returning the rendered diagrams (or an escaped-source fallback when a
// diagram will not compile) and the nonce that keys them.
func liftPikchrFences(src []byte) ([]byte, []string, string) {
if !pikchrFence.Match(src) {
return src, nil, ""
}
var buf [8]byte
rand.Read(buf[:])
nonce := hex.EncodeToString(buf[:])
var out []string
replaced := pikchrFence.ReplaceAllFunc(src, func(m []byte) []byte {
body := pikchrFence.FindSubmatch(m)[1]
svg, err := RenderPikchr(body)
if err != nil {
out = append(out, `<pre>`+escapeHTML(string(body))+`</pre>`+
`<p><small>pikchr: `+escapeHTML(err.Error())+`</small></p>`)
} else {
out = append(out, string(svg))
}
return []byte(pikchrHold(nonce, len(out)-1))
})
return replaced, out, nonce
}
// restorePikchrFences swaps the diagrams back in. A placeholder sits
// alone in its paragraph, so the wrapping <p> is unwrapped to keep the
// SVG a block element rather than phrasing content inside a paragraph.
func restorePikchrFences(html []byte, diagrams []string, nonce string) []byte {
s := string(html)
for i, d := range diagrams {
h := pikchrHold(nonce, i)
s = strings.ReplaceAll(s, "<p>"+h+"</p>", d)
s = strings.ReplaceAll(s, h, d)
}
return []byte(s)
}