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.renderDocument(source, repo, ref, dir, 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.renderDocument(source, repo, ref, "", idPrefix, notes)
73 return h, err
74}
75
76func (m *markdown) renderDocument(source []byte, repo, ref, dir, idPrefix string, notes func(int64) bool) (template.HTML, []Heading, error) {
77 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{prefix: idPrefix, inner: parser.NewContext().IDs()}))
78 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
79 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source, idPrefix: idPrefix}
80 // Collect eligible text before the policy unwraps rejected links: their
81 // labels must not accidentally become references either.
82 var refs []*ast.Text
83 if notes != nil {
84 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85 if !entering {
86 return ast.WalkContinue, nil
87 }
88 switch n := n.(type) {
89 case *ast.CodeSpan, *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeBlock, *ast.FencedCodeBlock:
90 return ast.WalkSkipChildren, nil
91 case *ast.Text:
92 refs = append(refs, n)
93 }
94 return ast.WalkContinue, nil
95 })
96 }
97 pol.apply(doc)
98 for _, t := range refs {
99 noteReferences(t, source, notes)
100 }
101
102 var buf bytes.Buffer
103 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
104 return "", nil, err
105 }
106 // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
107 // HTML; linkPolicy has removed every URL it does not allow.
108 return template.HTML(buf.String()), outline(doc, source), nil
109}
110
111// prefixedIDs prefixes the heading ids that goldmark generates.
112type prefixedIDs struct {
113 prefix string
114 inner parser.IDs
115}
116
117func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
118 return append([]byte(p.prefix), p.inner.Generate(value, kind)...)
119}
120
121func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
122
123// outline lists the headings of a document in order.
124func outline(doc ast.Node, source []byte) []Heading {
125 var out []Heading
126 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
127 h, ok := n.(*ast.Heading)
128 if !ok || !entering {
129 return ast.WalkContinue, nil
130 }
131 id, _ := h.AttributeString("id")
132 idb, _ := id.([]byte)
133 out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
134 return ast.WalkSkipChildren, nil
135 })
136 return out
137}
138
139// plainText is the text of a node without markup.
140func plainText(n ast.Node, source []byte) string {
141 var b strings.Builder
142 _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
143 if !entering {
144 return ast.WalkContinue, nil
145 }
146 switch t := c.(type) {
147 case *ast.Text:
148 b.Write(t.Value(source))
149 if t.SoftLineBreak() || t.HardLineBreak() {
150 b.WriteByte(' ')
151 }
152 case *ast.String:
153 b.Write(t.Value)
154 }
155 return ast.WalkContinue, nil
156 })
157 return strings.TrimSpace(b.String())
158}
159
160// linkPolicy applies the link and image policy to a parsed document.
161type linkPolicy struct {
162 repo, ref, dir string
163 source []byte
164 // idPrefix is the prefix of the heading ids that #fragment links point at.
165 idPrefix string
166}
167
168func (p linkPolicy) apply(doc ast.Node) {
169 // Collect first, change after: the walk must not see the tree move.
170 var links, images, autos []ast.Node
171 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
172 if !entering {
173 return ast.WalkContinue, nil
174 }
175 switch n.(type) {
176 case *ast.Link:
177 links = append(links, n)
178 case *ast.Image:
179 images = append(images, n)
180 case *ast.AutoLink:
181 autos = append(autos, n)
182 }
183 return ast.WalkContinue, nil
184 })
185 for _, n := range images {
186 p.image(n.(*ast.Image))
187 }
188 for _, n := range links {
189 l := n.(*ast.Link)
190 dest, kind := p.target(string(l.Destination))
191 if kind == dropped {
192 unwrap(l)
193 continue
194 }
195 l.Destination = []byte(dest)
196 l.Title = nil
197 if kind == external {
198 l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
199 }
200 }
201 for _, n := range autos {
202 a := n.(*ast.AutoLink)
203 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
204 a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
205 continue
206 }
207 // An email address or another scheme: the label as plain text.
208 a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
209 }
210}
211
212// unwrap replaces a node by its children.
213func unwrap(n ast.Node) {
214 parent := n.Parent()
215 for c := n.FirstChild(); c != nil; c = n.FirstChild() {
216 parent.InsertBefore(parent, n, c)
217 }
218 parent.RemoveChild(parent, n)
219}
220
221// image replaces an image by its alt text.
222func (p linkPolicy) image(img *ast.Image) {
223 alt := plainText(img, p.source)
224 if alt == "" {
225 alt = "image"
226 }
227 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
228}
229
230type linkKind int
231
232const (
233 dropped linkKind = iota
234 external
235 internal
236)
237
238// target applies the link policy to a destination.
239func (p linkPolicy) target(dest string) (string, linkKind) {
240 dest = strings.TrimSpace(dest)
241 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
242 return "", dropped
243 }
244 if strings.HasPrefix(dest, "#") {
245 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
246 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
247 return "", dropped
248 }
249 return "#" + p.idPrefix + frag, internal
250 }
251 if strings.HasPrefix(dest, "//") {
252 return "", dropped
253 }
254 u, err := url.Parse(dest)
255 if err != nil {
256 return "", dropped
257 }
258 switch strings.ToLower(u.Scheme) {
259 case "http", "https":
260 if u.Host == "" {
261 return "", dropped
262 }
263 return u.String(), external
264 case "":
265 default:
266 return "", dropped
267 }
268 if u.Host != "" || u.Opaque != "" || u.Path == "" {
269 return "", dropped
270 }
271 // A path in the repository: from the directory of the file, or from the
272 // root when it starts with a slash. path.Clean on a rooted path never
273 // leaves the root, so ../../ cannot climb out of the repository.
274 var rel string
275 if strings.HasPrefix(u.Path, "/") {
276 rel = path.Clean(u.Path)
277 } else {
278 rel = path.Clean("/" + path.Join(p.dir, u.Path))
279 }
280 rel = strings.TrimPrefix(rel, "/")
281 href := repoHref(p.repo)
282 if rel != "" {
283 href = repoHref(p.repo, "blob", p.ref, rel)
284 }
285 if u.Fragment != "" {
286 href += "#" + url.PathEscape(u.Fragment)
287 }
288 return href, internal
289}
290
291func isWebURL(s string) bool {
292 u, err := url.Parse(s)
293 if err != nil || u.Host == "" {
294 return false
295 }
296 sc := strings.ToLower(u.Scheme)
297 return sc == "http" || sc == "https"
298}
299
300// kindAlt is the kind of altSpan.
301var kindAlt = ast.NewNodeKind("AltSpan")
302
303// altSpan stands for an image: its alt text, in a span the stylesheet styles.
304type altSpan struct {
305 ast.BaseInline
306 alt string
307}
308
309func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
310
311func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
312
313// altRenderer renders altSpan nodes.
314type altRenderer struct{}
315
316func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
317 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
318 if entering {
319 _, _ = w.WriteString(`<span class="alt">[`)
320 _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
321 _, _ = w.WriteString(`]</span>`)
322 }
323 return ast.WalkContinue, nil
324 })
325}
326
327var noteReferencePattern = regexp.MustCompile(`(^|[^0-9A-Za-z_^])\^([1-9][0-9]{0,5})\b`)
328
329// noteReferences changes only text segments; code and link labels never enter
330// this function. New fragment links deliberately bypass heading-prefix rewriting.
331func noteReferences(t *ast.Text, source []byte, exists func(int64) bool) {
332 parent := t.Parent()
333 if parent == nil {
334 return
335 }
336 value := t.Value(source)
337 offset := 0
338 for _, match := range noteReferencePattern.FindAllSubmatchIndex(value, -1) {
339 id := string(value[match[4]:match[5]])
340 n, err := strconv.ParseInt(id, 10, 64)
341 if err != nil || !exists(n) {
342 continue
343 }
344 start := match[4] - 1
345 // Markdown punctuation may split adjacent text into separate AST nodes.
346 // Check the original preceding byte as well as the segment-local regex.
347 if at := t.Segment.Start + start; at > 0 {
348 prev := source[at-1]
349 if prev >= '0' && prev <= '9' || prev >= 'A' && prev <= 'Z' || prev >= 'a' && prev <= 'z' || prev == '_' || prev == '^' {
350 continue
351 }
352 }
353 if start > offset {
354 parent.InsertBefore(parent, t, ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Start+start)))
355 }
356 link := ast.NewLink()
357 link.Destination = []byte("#c" + id)
358 link.Title = []byte("note " + id)
359 link.SetAttributeString("class", []byte("ref"))
360 link.AppendChild(link, ast.NewString(value[start:match[5]]))
361 parent.InsertBefore(parent, t, link)
362 offset = match[5]
363 }
364 if offset == 0 {
365 return
366 }
367 tail := ast.NewTextSegment(text.NewSegment(t.Segment.Start+offset, t.Segment.Stop))
368 tail.SetSoftLineBreak(t.SoftLineBreak())
369 tail.SetHardLineBreak(t.HardLineBreak())
370 parent.InsertBefore(parent, t, tail)
371 parent.RemoveChild(parent, t)
372}