Skip to content
quarry Artifact [33e2f35c28]
Login

Artifact [33e2f35c28]

Artifact 33e2f35c28eae06296b97b9bec36f004e87e4a9d3d6f528697288f984e0667dd:


// 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"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"regexp"
	"strconv"
	"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")
	// Pikchr writes a shape's paint as a style attribute and a label's
	// weight, slant and size as attributes. Each is matched on its own
	// value: a property with no matcher is not "allowed" but DROPPED,
	// because the sanitizer has no built-in rule for SVG paint, and
	// that left every shape at the SVG default (solid black, no stroke)
	// and every label at one weight and size.
	paint := regexp.MustCompile(`^(none|currentColor|rgb\([0-9., ]+\)|#[0-9a-fA-F]{3,8}|[a-zA-Z]+)$`)
	p.AllowStyles("fill", "stroke").Matching(paint).
		OnElements("path", "polygon", "circle", "ellipse", "text", "svg")
	p.AllowStyles("stroke-width").Matching(regexp.MustCompile(`^[0-9.]+$`)).
		OnElements("path", "polygon", "circle", "ellipse")
	p.AllowStyles("stroke-dasharray").Matching(regexp.MustCompile(`^[0-9., ]+$`)).
		OnElements("path", "polygon", "circle", "ellipse")
	p.AllowStyles("font-size").Matching(regexp.MustCompile(`^(initial|[0-9.]+(%|px|em)?)$`)).
		OnElements("text", "svg")
	p.AllowAttrs("font-weight").Matching(regexp.MustCompile(`^(normal|bold|[1-9]00)$`)).OnElements("text")
	p.AllowAttrs("font-style").Matching(regexp.MustCompile(`^(normal|italic)$`)).OnElements("text")
	p.AllowAttrs("font-size").Matching(regexp.MustCompile(`^[0-9.]+(%|px|em)?$`)).OnElements("text")
	p.AllowAttrs("transform").Matching(regexp.MustCompile(`^rotate\(-?[0-9.]+(,? ?-?[0-9.]+,? ?-?[0-9.]+)?\)$`)).OnElements("text")

	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, string(Figure([]byte(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(Figure(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)
}

// svgWidth reads a rendered diagram's drawn width from its viewBox.
var svgWidth = regexp.MustCompile(`(?i)viewBox="[0-9.-]+ [0-9.-]+ ([0-9.]+) [0-9.]+"`)

// Figure frames a sanitized diagram for the page. A diagram is drawn
// for a desktop column, and on a phone it neither fits nor can be read
// once shrunk to fit: 730px squeezed into 340 puts its small labels
// near 5px. So the frame lets it shrink to the column but no further
// than 60% of its drawn width (the stylesheet reads that width from
// --pk-w), and past that it scrolls inside the frame, never the page.
// Tapping it opens it alone at full size (CSS :target, no script), and
// the back button closes it. The id is the diagram's own hash, so two
// posts on one page never share one and a link to it is stable.
func Figure(svg []byte) []byte {
	sum := sha256.Sum256(svg)
	id := "pk-" + hex.EncodeToString(sum[:5])
	style := ""
	if m := svgWidth.FindSubmatch(svg); m != nil {
		if w, err := strconv.ParseFloat(string(m[1]), 64); err == nil && w > 0 {
			style = fmt.Sprintf(` style="--pk-w:%.0fpx"`, w)
		}
	}
	return []byte(`<figure class="pikchr-fig" id="` + id + `"` + style + `>` +
		`<a class="pikchr-open" href="#` + id + `" title="Open the diagram full size">` + string(svg) + `</a>` +
		`<a class="pikchr-close" href="#_" title="Close">&times;</a></figure>`)
}