detached 27e453c

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

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