gitmote

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("&#8203;")
		} 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"
}