Artifact 727389d23db40bc39a5fb82d38daefcb92a86b04d7092d11f31d3166d94ec98a:
- File
internal/markup/pikchr.go
— part of check-in
[1d680372e5]
at
2026-08-16 13:44:53
on branch trunk
— markup: map fossil site-relative links; theme-neutral pikchr ink
Two bugs found by browsing real content on a phone.
Imported wiki/technote/forum bodies are full of fossil's own routes (libfossil's wiki alone: 500 /finfo, 211 /dir, 105 /timeline, 23 /doc). Quarry passed those hrefs through verbatim, so libfossil's home page offered a README.md link that went nowhere. Resolver grows ResolvePath: /doc/<ver>/<path> maps to quarry's file route, /info to the artifact, /timeline /wiki /forum /technote to their pages; a verb quarry has no page for drops the link and keeps the label, since a dead link is worse than plain text. file.get now lets zit resolve symbolic versions (trunk, tip, tags) instead of demanding a hash — that is what /doc links use, and zit is the authority on names. The href rewrite runs BEFORE reference resolution so the two passes never see each other's output.
Pikchr hardcodes rgb(0,0,0), which is invisible on quarry's dark theme — the DOM assertions all passed while the diagram was unreadable, and only a screenshot caught it. Its default ink now becomes currentColor so a diagram follows the page theme; author-specified colours are untouched. Fossil solves this with a server-side --dark-mode flag, which cannot work for a CSS-themed page. (user: dmestas, size: 5686) [annotate] [blame] [check-ins using]
// 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) }