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 ' 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}