Skip to content
quarry Artifact [727389d23d]
Login

Artifact [727389d23d]

Artifact 727389d23db40bc39a5fb82d38daefcb92a86b04d7092d11f31d3166d94ec98a:


// 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)
}

// 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)
}