v0.33.0 cf26365

markdown.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"html/template"
8	"net/url"
9	"path"
10	"regexp"
11	"strconv"
12	"strings"
13	"time"
14
15	"bnema.dev/ermite/internal/domain"
16	"github.com/yuin/goldmark"
17	"github.com/yuin/goldmark/ast"
18	"github.com/yuin/goldmark/extension"
19	"github.com/yuin/goldmark/parser"
20	"github.com/yuin/goldmark/renderer"
21	"github.com/yuin/goldmark/text"
22	"github.com/yuin/goldmark/util"
23)
24
25// headingIDPrefix keeps the ids of README headings apart from the ids of the
26// page (main, keys, cmd-list…): a heading must not be able to take one.
27const headingIDPrefix = "md-"
28
29// markdown renders the Markdown of a repository. Its output is safe to put in
30// a page: raw HTML is dropped, and links and images follow the policy below.
31//
32// Link policy:
33//   - http and https links are kept, with rel="nofollow noopener noreferrer";
34//   - relative links and links to the root of the repository are rewritten to
35//     the blob route of the ref being viewed, never above the repository root;
36//   - #fragments point at the (prefixed) heading ids;
37//   - everything else (javascript:, data:, vbscript:, file:, mailto:,
38//     protocol-relative //host, empty targets…) loses its link: the text stays;
39//   - images are never loaded (the CSP forbids other origins, and the design
40//     shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
41type markdown struct {
42	md goldmark.Markdown
43}
44
45func newMarkdown() *markdown {
46	return &markdown{md: goldmark.New(
47		goldmark.WithExtensions(extension.GFM),
48		goldmark.WithParserOptions(parser.WithAutoHeadingID()),
49		goldmark.WithRendererOptions(
50			renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100), util.Prioritized(fencedRenderer{}, 100)),
51			// No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
52			// Table alignment as an align attribute: the CSP forbids style="".
53			extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
54		),
55	)}
56}
57
58// render turns source into HTML and the outline of its headings. repo and ref
59// are where relative links point; dir is the directory of the Markdown file
60// in the repository, "" for the root.
61func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
62	return m.renderWithIDs(source, repo, ref, dir, headingIDPrefix)
63}
64
65// renderWithIDs is render with the prefix of the heading ids and of the
66// #fragment links chosen, so that several documents on one page (the summaries
67// of a run) do not share ids.
68func (m *markdown) renderWithIDs(source []byte, repo, ref, dir, idPrefix string) (template.HTML, []Heading, error) {
69	return m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, dir: dir, idPrefix: idPrefix}, refResolver{})
70}
71
72// renderNote uses the repository Markdown policy, then adds local note links
73// and links to issues and merge requests.
74func (m *markdown) renderNote(source []byte, repo, ref, idPrefix string, refs refResolver) (template.HTML, error) {
75	h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refs)
76	return h, err
77}
78
79// renderIssue renders the trusted body of an issue: the policy of render, plus
80// links to issues and merge requests.
81func (m *markdown) renderIssue(source []byte, repo, ref, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
82	h, _, err := m.renderPolicy(source, linkPolicy{repo: repo, ref: ref, idPrefix: idPrefix}, refResolver{numbers: numbers})
83	return h, err
84}
85
86// refResolver tells which references of a text are links.
87type refResolver struct {
88	// notes tells whether ^N names a note of the page (nil: no ^N links).
89	notes func(int64) bool
90	// numbers gives the page of the issue or merge request #N, when it exists
91	// (nil: no #N links). The href is built by the server, never from the text.
92	numbers func(int64) (href string, kind domain.NumberKind, ok bool)
93}
94
95// renderUntrusted renders Markdown written by an anonymous visitor (an issue
96// approved from the public form). On top of the policy of render, which already
97// drops raw HTML and never loads an image:
98//   - only http, https and mailto links are kept, every one with
99//     rel="nofollow noopener noreferrer ugc"; relative links, #fragments and
100//     every other scheme lose their link and keep the text;
101//   - nothing points into the repository.
102func (m *markdown) renderUntrusted(source []byte, idPrefix string, numbers func(int64) (string, domain.NumberKind, bool)) (template.HTML, error) {
103	h, _, err := m.renderPolicy(source, linkPolicy{idPrefix: idPrefix, untrusted: true}, refResolver{numbers: numbers})
104	return h, err
105}
106
107func (m *markdown) renderPolicy(source []byte, pol linkPolicy, res refResolver) (template.HTML, []Heading, error) {
108	pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: pol.idPrefix, inner: parser.NewContext().IDs()}))
109	doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
110	pol.source = source
111	// Collect eligible text before the policy unwraps rejected links: their
112	// labels must not accidentally become references either.
113	var refs []*ast.Text
114	if res.notes != nil || res.numbers != nil {
115		_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
116			if !entering {
117				return ast.WalkContinue, nil
118			}
119			switch n := n.(type) {
120			case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
121				return ast.WalkSkipChildren, nil
122			case *ast.Text:
123				refs = append(refs, n)
124			}
125			return ast.WalkContinue, nil
126		})
127	}
128	pol.apply(doc)
129	for _, t := range refs {
130		references(t, source, res)
131	}
132
133	doc.SetAttributeString(markdownBudgetKey, &markdownLexBudget{deadline: time.Now().Add(markdownLexTime)})
134	var buf bytes.Buffer
135	if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
136		return "", nil, err
137	}
138	// #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
139	// HTML; linkPolicy has removed every URL it does not allow.
140	return template.HTML(buf.String()), outline(doc, source), nil
141}
142
143// prefixedIDs prefixes the heading ids that goldmark generates.
144type prefixedIDs struct {
145	prefix string
146	inner  parser.IDs
147}
148
149func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
150	return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
151}
152
153func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
154
155// outline lists the headings of a document in order.
156func outline(doc ast.Node, source []byte) []Heading {
157	var out []Heading
158	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
159		h, ok := n.(*ast.Heading)
160		if !ok || !entering {
161			return ast.WalkContinue, nil
162		}
163		id, _ := h.AttributeString("id")
164		idb, _ := id.([]byte)
165		out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
166		return ast.WalkSkipChildren, nil
167	})
168	return out
169}
170
171// plainText is the text of a node without markup.
172func plainText(n ast.Node, source []byte) string {
173	var b strings.Builder
174	_ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
175		if !entering {
176			return ast.WalkContinue, nil
177		}
178		switch t := c.(type) {
179		case *ast.Text:
180			b.Write(t.Value(source))
181			if t.SoftLineBreak() || t.HardLineBreak() {
182				b.WriteByte(' ')
183			}
184		case *ast.String:
185			b.Write(t.Value)
186		}
187		return ast.WalkContinue, nil
188	})
189	return strings.TrimSpace(b.String())
190}
191
192// linkPolicy applies the link and image policy to a parsed document.
193type linkPolicy struct {
194	repo, ref, dir string
195	source         []byte
196	// idPrefix is the prefix of the heading ids that #fragment links point at.
197	idPrefix string
198	// untrusted is the policy of text from an anonymous visitor.
199	untrusted bool
200}
201
202const (
203	relExternal  = "nofollow noopener noreferrer"
204	relUntrusted = "nofollow noopener noreferrer ugc"
205)
206
207func (p linkPolicy) rel() []byte {
208	if p.untrusted {
209		return []byte(relUntrusted)
210	}
211	return []byte(relExternal)
212}
213
214func (p linkPolicy) apply(doc ast.Node) {
215	// Collect first, change after: the walk must not see the tree move.
216	var links, images, autos []ast.Node
217	_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
218		if !entering {
219			return ast.WalkContinue, nil
220		}
221		switch n.(type) {
222		case *ast.Link:
223			links = append(links, n)
224		case *ast.Image:
225			images = append(images, n)
226		case *ast.AutoLink:
227			autos = append(autos, n)
228		}
229		return ast.WalkContinue, nil
230	})
231	for _, n := range images {
232		p.image(n.(*ast.Image))
233	}
234	for _, n := range links {
235		l := n.(*ast.Link)
236		dest, kind := p.target(string(l.Destination))
237		if kind == dropped {
238			unwrap(l)
239			continue
240		}
241		l.Destination = []byte(dest)
242		l.Title = nil
243		if kind == external {
244			l.SetAttributeString("rel", p.rel())
245		}
246	}
247	for _, n := range autos {
248		a := n.(*ast.AutoLink)
249		if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
250			a.SetAttributeString("rel", p.rel())
251			continue
252		}
253		if p.untrusted && a.AutoLinkType == ast.AutoLinkEmail && validMailbox(string(a.Label(p.source))) {
254			a.SetAttributeString("rel", p.rel())
255			continue
256		}
257		// An email address or another scheme: the label as plain text.
258		a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
259	}
260}
261
262// unwrap replaces a node by its children.
263func unwrap(n ast.Node) {
264	parent := n.Parent()
265	for c := n.FirstChild(); c != nil; c = n.FirstChild() {
266		parent.InsertBefore(parent, n, c)
267	}
268	parent.RemoveChild(parent, n)
269}
270
271// image replaces an image by its alt text.
272func (p linkPolicy) image(img *ast.Image) {
273	alt := plainText(img, p.source)
274	if alt == "" {
275		alt = "image"
276	}
277	img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
278}
279
280type linkKind int
281
282const (
283	dropped linkKind = iota
284	external
285	internal
286)
287
288// target applies the link policy to a destination.
289func (p linkPolicy) target(dest string) (string, linkKind) {
290	dest = strings.TrimSpace(dest)
291	if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
292		return "", dropped
293	}
294	if p.untrusted {
295		return untrustedTarget(dest)
296	}
297	if strings.HasPrefix(dest, "#") {
298		frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
299		if frag == "" || strings.ContainsAny(frag, " \"'<>") {
300			return "", dropped
301		}
302		return "#" + p.idPrefix + frag, internal
303	}
304	if strings.HasPrefix(dest, "//") {
305		return "", dropped
306	}
307	u, err := url.Parse(dest)
308	if err != nil {
309		return "", dropped
310	}
311	switch strings.ToLower(u.Scheme) {
312	case "http", "https":
313		if u.Host == "" {
314			return "", dropped
315		}
316		return u.String(), external
317	case "":
318	default:
319		return "", dropped
320	}
321	if u.Host != "" || u.Opaque != "" || u.Path == "" {
322		return "", dropped
323	}
324	// A path in the repository: from the directory of the file, or from the
325	// root when it starts with a slash. path.Clean on a rooted path never
326	// leaves the root, so ../../ cannot climb out of the repository.
327	var rel string
328	if strings.HasPrefix(u.Path, "/") {
329		rel = path.Clean(u.Path)
330	} else {
331		rel = path.Clean("/" + path.Join(p.dir, u.Path))
332	}
333	rel = strings.TrimPrefix(rel, "/")
334	href := repoHref(p.repo)
335	if rel != "" {
336		href = repoHref(p.repo, "blob", p.ref, rel)
337	}
338	if u.Fragment != "" {
339		href += "#" + url.PathEscape(u.Fragment)
340	}
341	return href, internal
342}
343
344// untrustedTarget keeps absolute http and https links with a host, and mailto
345// links to one plain address.
346func untrustedTarget(dest string) (string, linkKind) {
347	if strings.HasPrefix(dest, "//") {
348		return "", dropped
349	}
350	u, err := url.Parse(dest)
351	if err != nil {
352		return "", dropped
353	}
354	switch strings.ToLower(u.Scheme) {
355	case "http", "https":
356		if u.Host == "" || u.User != nil {
357			return "", dropped
358		}
359		return u.String(), external
360	case "mailto":
361		if u.Opaque == "" || u.RawQuery != "" || !validMailbox(u.Opaque) {
362			return "", dropped
363		}
364		return "mailto:" + u.Opaque, external
365	}
366	return "", dropped
367}
368
369// validMailbox tells whether s is a bare address: no space, quote, angle
370// bracket, percent sign, comma or second at sign.
371func validMailbox(s string) bool {
372	local, host, ok := strings.Cut(s, "@")
373	return ok && local != "" && host != "" && len(s) <= 254 &&
374		!strings.ContainsAny(local+host, " \t\r\n\"'<>%,;?#\\@")
375}
376
377func isWebURL(s string) bool {
378	u, err := url.Parse(s)
379	if err != nil || u.Host == "" {
380		return false
381	}
382	sc := strings.ToLower(u.Scheme)
383	return sc == "http" || sc == "https"
384}
385
386// kindAlt is the kind of altSpan.
387var kindAlt = ast.NewNodeKind("AltSpan")
388
389// altSpan stands for an image: its alt text, in a span the stylesheet styles.
390type altSpan struct {
391	ast.BaseInline
392	alt string
393}
394
395func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
396
397func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
398
399// altRenderer renders altSpan nodes.
400type altRenderer struct{}
401
402func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
403	r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
404		if entering {
405			_, _ = w.WriteString(`<span class="alt">[`)
406			_, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
407			_, _ = w.WriteString(`]</span>`)
408		}
409		return ast.WalkContinue, nil
410	})
411}
412
413// referencePattern matches ^N (groups 1-2) and #N (groups 3-4). The preceding
414// character is not part of a word; #N also rejects & and # so that numeric
415// entities such as &#39; stay text.
416var referencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b|(^|[^0-9A-Za-z_&#])#([1-9][0-9]{0,9})\b`)
417
418// references changes only text segments; code and link labels never enter
419// this function. New links deliberately bypass heading-prefix rewriting.
420func references(t *ast.Text, source []byte, res refResolver) {
421	parent := t.Parent()
422	if parent == nil {
423		return
424	}
425	value := t.Value(source)
426	offset := 0
427	for _, match := range referencePattern.FindAllSubmatchIndex(value, -1) {
428		isNote := match[4] >= 0
429		var from, to int
430		if isNote {
431			from, to = match[4], match[5]
432		} else {
433			from, to = match[8], match[9]
434		}
435		if (isNote && res.notes == nil) || (!isNote && res.numbers == nil) {
436			continue
437		}
438		id := string(value[from:to])
439		n, err := strconv.ParseInt(id, 10, 64)
440		if err != nil {
441			continue
442		}
443		start := from - 1
444		// Markdown punctuation may split adjacent text into separate AST nodes.
445		// Check the original preceding byte as well as the segment-local regex.
446		if at := t.Segment.Start + start; at > 0 {
447			prev := source[at-1]
448			if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' {
449				continue
450			}
451			if isNote && prev == '^' || !isNote && (prev == '&' || prev == '#') {
452				continue
453			}
454		}
455		link := ast.NewLink()
456		if isNote {
457			if !res.notes(n) {
458				continue
459			}
460			link.Destination = []byte("#c" + id)
461			link.Title = []byte("note " + id)
462		} else {
463			href, kind, ok := res.numbers(n)
464			if !ok {
465				continue
466			}
467			link.Destination = []byte(href)
468			title := "issue "
469			if kind == domain.NumberMergeRequest {
470				title = "merge request "
471			}
472			link.Title = []byte(title + id)
473		}
474		if start > offset {
475			parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
476		}
477		link.SetAttributeString("class", []byte("ref"))
478		link.AppendChild(link, ast.NewString(value[start:to]))
479		parent.InsertBefore(parent, t, link)
480		offset = to
481	}
482	if offset == 0 {
483		return
484	}
485	tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
486	tail.SetSoftLineBreak(t.SoftLineBreak())
487	tail.SetHardLineBreak(t.HardLineBreak())
488	parent.InsertBefore(parent, t, tail)
489	parent.RemoveChild(parent, t)
490}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close