gitmote / internal / render / render.go · history · branches
11743 bytes · raw
// Package render turns untrusted repo content into safe HTML for the browse UI:
// syntax-highlighted source (chroma) and rendered markdown (goldmark). It is
// deliberately separate from webui so the security-critical rendering — every
// byte here originates from a repo and is shown to an admin — is unit-testable
// in isolation.
//
// Two defenses guard the markdown path (task 15 safety): goldmark escapes raw
// HTML by default (WithUnsafe is never set), and bluemonday's UGCPolicy strips
// what slips through — scripts, event-handler attributes, and javascript:/data:
// URLs. chroma's class-based output is static markup (no scripts) and its
// stylesheet is generated, not repo-derived, so both are safe to embed.
package render
import (
"bytes"
"html/template"
"net/url"
"path"
"strings"
"sync"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"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/text"
"github.com/yuin/goldmark/util"
highlighting "github.com/yuin/goldmark-highlighting/v2"
mermaid "go.abhg.dev/goldmark/mermaid"
)
// MaxSize caps the input render will highlight or markdown-render. Above it the
// caller falls back to a plain <pre>: a minified or generated blob shouldn't
// turn into pathological HTML.
const MaxSize = 512 * 1024
// highlightStyle is the chroma theme shared by blob view and README code
// blocks, so the two match. Class-based output pairs it with HighlightCSS.
// darkStyle is its dark-mode counterpart, emitted under a prefers-color-scheme
// media query — same class names, so it overrides when the viewer's OS is dark.
var (
highlightStyle = styles.Get("github")
darkStyle = styles.Get("github-dark")
)
// classFormatter emits class-based (not inline-style) highlight markup, so a
// single stylesheet from HighlightCSS covers every page.
var classFormatter = chromahtml.New(chromahtml.WithClasses(true), chromahtml.TabWidth(4))
// Highlight renders code as syntax-highlighted, class-based HTML wrapped in the
// formatter's <pre>. The lexer is chosen by filename, then by content analysis,
// then plain text. Highlighting is best-effort: on any lexer/format error the
// caller falls back to a plain <pre>.
func Highlight(code []byte, filename string) (template.HTML, error) {
lexer := lexers.Match(path.Base(filename))
if lexer == nil {
lexer = lexers.Analyse(string(code))
}
if lexer == nil {
lexer = lexers.Fallback
}
it, err := lexer.Tokenise(nil, string(code))
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := classFormatter.Format(&buf, highlightStyle, it); err != nil {
return "", err
}
return template.HTML(buf.String()), nil //nolint:gosec // chroma class output is static markup
}
var highlightCSS struct {
sync.Once
css template.CSS
}
// HighlightCSS is the stylesheet paired with Highlight's class-based output, for
// the layout to include once. It is generated by chroma from a fixed theme —
// trusted, not repo-derived — and cached after the first build.
func HighlightCSS() template.CSS {
highlightCSS.Do(func() {
var buf bytes.Buffer
_ = classFormatter.WriteCSS(&buf, highlightStyle)
// The dark theme reuses the same classes, wrapped in a media query so it
// only takes effect when the viewer's OS is in dark mode.
buf.WriteString("@media (prefers-color-scheme: dark) {\n")
_ = classFormatter.WriteCSS(&buf, darkStyle)
buf.WriteString("}\n")
highlightCSS.css = template.CSS(buf.String())
})
return highlightCSS.css
}
// markdown renders CommonMark + GFM. Raw HTML is escaped (goldmark's default,
// WithUnsafe intentionally unset); fenced code is routed through chroma with the
// same class-based theme as blob view.
var markdown = goldmark.New(
goldmark.WithExtensions(
extension.GFM,
highlighting.NewHighlighting(
highlighting.WithFormatOptions(chromahtml.WithClasses(true)),
),
// A ```mermaid fence becomes <pre class="mermaid">…escaped source…</pre>
// for client-side rendering (bluemonday passes the class we already allow on
// <pre>). RenderModeClient is explicit so a stray `mmdc` on PATH can't flip
// us to server-side; NoScript because we include our own embedded, trusted
// mermaid.js from the layout — the extension's CDN <script> would be stripped
// by the sanitizer anyway.
&mermaid.Extender{RenderMode: mermaid.RenderModeClient, NoScript: true},
),
// Rewrite a rendered file's relative links/embeds into repo URLs. The
// transformer is a no-op unless the Convert call supplies a *LinkContext
// (MarkdownLinks does; plain Markdown does not).
goldmark.WithParserOptions(
parser.WithASTTransformers(util.Prioritized(linkTransformer{}, 100)),
),
)
// linkCtxKey carries the per-render *LinkContext through the parser context to
// the link transformer.
var linkCtxKey = parser.NewContextKey()
// LinkContext supplies what the markdown link rewriter needs to turn a rendered
// file's relative links and embeds into repo URLs: the repo name, the resolved
// ref, the directory the rendered file lives in (relative paths resolve against
// it), and a Stat callback reporting whether a repo-relative path is a "blob", a
// "tree", or absent (ok=false).
type LinkContext struct {
Repo string
Ref string
Dir string
Stat func(path string) (kind string, ok bool)
}
// rewrite maps one link/image destination to a repo URL, or returns it
// unchanged. External (scheme/host), site-absolute, and pure-anchor targets are
// left alone; a relative path is resolved against Dir and mapped to raw (images)
// or blob/tree (nav, by the Stat lookup). A relative path that climbs out of the
// repo, or a nav target that doesn't exist, is left as-is (visibly dangling)
// rather than rewritten into a wrong URL.
func (lc *LinkContext) rewrite(dest string, isImage bool) string {
u, err := url.Parse(dest)
if err != nil {
return dest
}
if u.Scheme != "" || u.Host != "" || u.Path == "" || path.IsAbs(u.Path) {
return dest
}
target := path.Join(lc.Dir, u.Path)
if target == ".." || strings.HasPrefix(target, "../") {
return dest
}
verb := "raw"
if !isImage {
kind, ok := lc.Stat(target)
if !ok {
return dest
}
if kind == "tree" {
verb = "tree"
} else {
verb = "blob"
}
}
out := "/" + lc.Repo + "/" + verb + "/" + lc.Ref + "/" + target
if u.RawQuery != "" {
out += "?" + u.RawQuery
}
if u.Fragment != "" {
out += "#" + u.Fragment
}
return out
}
// linkTransformer rewrites relative link and image destinations in the parsed
// markdown when the Convert call supplies a *LinkContext.
type linkTransformer struct{}
func (linkTransformer) Transform(doc *ast.Document, _ text.Reader, pc parser.Context) {
lc, ok := pc.Get(linkCtxKey).(*LinkContext)
if !ok || lc == nil {
return
}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch node := n.(type) {
case *ast.Link:
node.Destination = []byte(lc.rewrite(string(node.Destination), false))
case *ast.Image:
node.Destination = []byte(lc.rewrite(string(node.Destination), true))
}
return ast.WalkContinue, nil
})
}
// sanitizer strips dangerous constructs goldmark passes through, while allowing
// the class attribute chroma needs on the code elements it emits (so README
// code stays highlighted). UGCPolicy already forbids scripts, event handlers,
// and javascript:/data: URL schemes.
var sanitizer = func() *bluemonday.Policy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("class").OnElements("div", "pre", "code", "span")
return p
}()
// Markdown renders src to sanitized HTML. It never returns unsafe markup: even
// if goldmark errored, the (empty) output passes through the sanitizer.
func Markdown(src []byte) template.HTML {
return MarkdownLinks(src, nil)
}
// MarkdownLinks renders src like Markdown, additionally rewriting the file's
// relative links and embeds into repo URLs via lc (see LinkContext). A nil lc
// disables rewriting — it is exactly Markdown.
func MarkdownLinks(src []byte, lc *LinkContext) template.HTML {
var buf bytes.Buffer
pc := parser.NewContext()
if lc != nil {
pc.Set(linkCtxKey, lc)
}
if err := markdown.Convert(src, &buf, parser.WithContext(pc)); err != nil {
return ""
}
return template.HTML(sanitizer.SanitizeBytes(buf.Bytes())) //nolint:gosec // sanitized above
}
// Diff renders a unified diff (git's patch text) to HTML: one styled row per
// line — file/hunk headers, additions, deletions, context — so the browse UI
// shows a colored diff instead of a plain <pre>. The input is git's own output
// (trusted origin), but every line is HTML-escaped, so the result is safe to
// embed. An empty diff yields "" (the caller shows nothing).
func Diff(unified string) template.HTML {
if strings.TrimSpace(unified) == "" {
return ""
}
var b strings.Builder
b.WriteString(`<div class="diff">`)
lines := strings.Split(unified, "\n")
// A trailing newline splits into a final empty element; drop it so it doesn't
// render as a stray blank row.
if n := len(lines); n > 0 && lines[n-1] == "" {
lines = lines[:n-1]
}
for _, line := range lines {
b.WriteString(`<div class="dl `)
b.WriteString(diffLineClass(line))
b.WriteString(`">`)
// An empty line still needs height; a zero-width space keeps the row.
if line == "" {
b.WriteString("​")
} else {
b.WriteString(template.HTMLEscapeString(line))
}
b.WriteString(`</div>`)
}
b.WriteString(`</div>`)
return template.HTML(b.String()) //nolint:gosec // every line is HTML-escaped above
}
// diffLineClass maps a unified-diff line to its CSS row class by the leading
// marker. File-level headers (`diff --git`, `+++`, `---`, mode/rename lines, …)
// are checked before the bare +/- so they aren't miscolored as add/delete.
func diffLineClass(line string) string {
switch {
case strings.HasPrefix(line, "@@"):
return "dl-hunk"
case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),
strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "),
strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"),
strings.HasPrefix(line, "old mode"), strings.HasPrefix(line, "new mode"),
strings.HasPrefix(line, "rename "), strings.HasPrefix(line, "similarity "),
strings.HasPrefix(line, "copy "), strings.HasPrefix(line, "Binary files"):
return "dl-file"
case strings.HasPrefix(line, "+"):
return "dl-add"
case strings.HasPrefix(line, "-"):
return "dl-del"
default: // context, "\ No newline at end of file", blank
return "dl-ctx"
}
}
// HasMermaid reports whether rendered markdown contains a mermaid diagram block
// (emitted as <pre class="mermaid">), so a page pulls in the mermaid script only
// when it actually has a diagram. Repo text that literally reads class="mermaid"
// is HTML-escaped by the renderer, so it can't trip this.
func HasMermaid(h template.HTML) bool {
return strings.Contains(string(h), `class="mermaid"`)
}
// IsMarkdown reports whether filename names a markdown file (.md/.markdown,
// case-insensitive).
func IsMarkdown(filename string) bool {
switch strings.ToLower(path.Ext(filename)) {
case ".md", ".markdown":
return true
default:
return false
}
}
// IsReadme reports whether name is a README markdown file (README.md /
// README.markdown, case-insensitive) — the file rendered below a tree listing.
func IsReadme(name string) bool {
lower := strings.ToLower(name)
return lower == "readme.md" || lower == "readme.markdown"
}