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 "strings"
11
12 "github.com/yuin/goldmark"
13 "github.com/yuin/goldmark/ast"
14 "github.com/yuin/goldmark/extension"
15 "github.com/yuin/goldmark/parser"
16 "github.com/yuin/goldmark/renderer"
17 "github.com/yuin/goldmark/text"
18 "github.com/yuin/goldmark/util"
19)
20
21// headingIDPrefix keeps the ids of README headings apart from the ids of the
22// page (main, keys, cmd-list…): a heading must not be able to take one.
23const headingIDPrefix = "md-"
24
25// markdown renders the Markdown of a repository. Its output is safe to put in
26// a page: raw HTML is dropped, and links and images follow the policy below.
27//
28// Link policy:
29// - http and https links are kept, with rel="nofollow noopener noreferrer";
30// - relative links and links to the root of the repository are rewritten to
31// the blob route of the ref being viewed, never above the repository root;
32// - #fragments point at the (prefixed) heading ids;
33// - everything else (javascript:, data:, vbscript:, file:, mailto:,
34// protocol-relative //host, empty targets…) loses its link: the text stays;
35// - images are never loaded (the CSP forbids other origins, and the design
36// shows an image as its alt text): each becomes <span class="alt">[alt]</span>.
37type markdown struct {
38 md goldmark.Markdown
39}
40
41func newMarkdown() *markdown {
42 return &markdown{md: goldmark.New(
43 goldmark.WithExtensions(extension.GFM),
44 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
45 goldmark.WithRendererOptions(
46 renderer.WithNodeRenderers(util.Prioritized(altRenderer{}, 100)),
47 // No html.WithUnsafe(): raw HTML blocks and inline tags are dropped.
48 // Table alignment as an align attribute: the CSP forbids style="".
49 extension.WithTableCellAlignMethod(extension.TableCellAlignAttribute),
50 ),
51 )}
52}
53
54// render turns source into HTML and the outline of its headings. repo and ref
55// are where relative links point; dir is the directory of the Markdown file
56// in the repository, "" for the root.
57func (m *markdown) render(source []byte, repo, ref, dir string) (template.HTML, []Heading, error) {
58 pc := parser.NewContext(parser.WithIDs(&prefixedIDs{inner: parser.NewContext().IDs()}))
59 doc := m.md.Parser().Parse(text.NewReader(source), parser.WithContext(pc))
60 pol := linkPolicy{repo: repo, ref: ref, dir: dir, source: source}
61 pol.apply(doc)
62
63 var buf bytes.Buffer
64 if err := m.md.Renderer().Render(&buf, source, doc); err != nil {
65 return "", nil, err
66 }
67 // #nosec G203 -- goldmark without WithUnsafe escapes text and drops raw
68 // HTML; linkPolicy has removed every URL it does not allow.
69 return template.HTML(buf.String()), outline(doc, source), nil
70}
71
72// prefixedIDs prefixes the heading ids that goldmark generates.
73type prefixedIDs struct{ inner parser.IDs }
74
75func (p *prefixedIDs) Generate(value []byte, kind ast.NodeKind) []byte {
76 return append([]byte(headingIDPrefix), p.inner.Generate(value, kind)...)
77}
78
79func (p *prefixedIDs) Put(value []byte) { p.inner.Put(value) }
80
81// outline lists the headings of a document in order.
82func outline(doc ast.Node, source []byte) []Heading {
83 var out []Heading
84 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
85 h, ok := n.(*ast.Heading)
86 if !ok || !entering {
87 return ast.WalkContinue, nil
88 }
89 id, _ := h.AttributeString("id")
90 idb, _ := id.([]byte)
91 out = append(out, Heading{Level: h.Level, ID: string(idb), Text: plainText(h, source)})
92 return ast.WalkSkipChildren, nil
93 })
94 return out
95}
96
97// plainText is the text of a node without markup.
98func plainText(n ast.Node, source []byte) string {
99 var b strings.Builder
100 _ = ast.Walk(n, func(c ast.Node, entering bool) (ast.WalkStatus, error) {
101 if !entering {
102 return ast.WalkContinue, nil
103 }
104 switch t := c.(type) {
105 case *ast.Text:
106 b.Write(t.Value(source))
107 if t.SoftLineBreak() || t.HardLineBreak() {
108 b.WriteByte(' ')
109 }
110 case *ast.String:
111 b.Write(t.Value)
112 }
113 return ast.WalkContinue, nil
114 })
115 return strings.TrimSpace(b.String())
116}
117
118// linkPolicy applies the link and image policy to a parsed document.
119type linkPolicy struct {
120 repo, ref, dir string
121 source []byte
122}
123
124func (p linkPolicy) apply(doc ast.Node) {
125 // Collect first, change after: the walk must not see the tree move.
126 var links, images, autos []ast.Node
127 _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
128 if !entering {
129 return ast.WalkContinue, nil
130 }
131 switch n.(type) {
132 case *ast.Link:
133 links = append(links, n)
134 case *ast.Image:
135 images = append(images, n)
136 case *ast.AutoLink:
137 autos = append(autos, n)
138 }
139 return ast.WalkContinue, nil
140 })
141 for _, n := range images {
142 p.image(n.(*ast.Image))
143 }
144 for _, n := range links {
145 l := n.(*ast.Link)
146 dest, kind := p.target(string(l.Destination))
147 if kind == dropped {
148 unwrap(l)
149 continue
150 }
151 l.Destination = []byte(dest)
152 l.Title = nil
153 if kind == external {
154 l.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
155 }
156 }
157 for _, n := range autos {
158 a := n.(*ast.AutoLink)
159 if a.AutoLinkType == ast.AutoLinkURL && isWebURL(string(a.URL(p.source))) {
160 a.SetAttributeString("rel", []byte("nofollow noopener noreferrer"))
161 continue
162 }
163 // An email address or another scheme: the label as plain text.
164 a.Parent().ReplaceChild(a.Parent(), a, ast.NewString(append([]byte(nil), a.Label(p.source)...)))
165 }
166}
167
168// unwrap replaces a node by its children.
169func unwrap(n ast.Node) {
170 parent := n.Parent()
171 for c := n.FirstChild(); c != nil; c = n.FirstChild() {
172 parent.InsertBefore(parent, n, c)
173 }
174 parent.RemoveChild(parent, n)
175}
176
177// image replaces an image by its alt text.
178func (p linkPolicy) image(img *ast.Image) {
179 alt := plainText(img, p.source)
180 if alt == "" {
181 alt = "image"
182 }
183 img.Parent().ReplaceChild(img.Parent(), img, &altSpan{alt: alt})
184}
185
186type linkKind int
187
188const (
189 dropped linkKind = iota
190 external
191 internal
192)
193
194// target applies the link policy to a destination.
195func (p linkPolicy) target(dest string) (string, linkKind) {
196 dest = strings.TrimSpace(dest)
197 if dest == "" || strings.ContainsAny(dest, "\x00\r\n\t\\") {
198 return "", dropped
199 }
200 if strings.HasPrefix(dest, "#") {
201 frag := strings.ToLower(strings.TrimPrefix(dest, "#"))
202 if frag == "" || strings.ContainsAny(frag, " \"'<>") {
203 return "", dropped
204 }
205 return "#" + headingIDPrefix + frag, internal
206 }
207 if strings.HasPrefix(dest, "//") {
208 return "", dropped
209 }
210 u, err := url.Parse(dest)
211 if err != nil {
212 return "", dropped
213 }
214 switch strings.ToLower(u.Scheme) {
215 case "http", "https":
216 if u.Host == "" {
217 return "", dropped
218 }
219 return u.String(), external
220 case "":
221 default:
222 return "", dropped
223 }
224 if u.Host != "" || u.Opaque != "" || u.Path == "" {
225 return "", dropped
226 }
227 // A path in the repository: from the directory of the file, or from the
228 // root when it starts with a slash. path.Clean on a rooted path never
229 // leaves the root, so ../../ cannot climb out of the repository.
230 var rel string
231 if strings.HasPrefix(u.Path, "/") {
232 rel = path.Clean(u.Path)
233 } else {
234 rel = path.Clean("/" + path.Join(p.dir, u.Path))
235 }
236 rel = strings.TrimPrefix(rel, "/")
237 href := repoHref(p.repo)
238 if rel != "" {
239 href = repoHref(p.repo, "blob", p.ref, rel)
240 }
241 if u.Fragment != "" {
242 href += "#" + url.PathEscape(u.Fragment)
243 }
244 return href, internal
245}
246
247func isWebURL(s string) bool {
248 u, err := url.Parse(s)
249 if err != nil || u.Host == "" {
250 return false
251 }
252 sc := strings.ToLower(u.Scheme)
253 return sc == "http" || sc == "https"
254}
255
256// kindAlt is the kind of altSpan.
257var kindAlt = ast.NewNodeKind("AltSpan")
258
259// altSpan stands for an image: its alt text, in a span the stylesheet styles.
260type altSpan struct {
261 ast.BaseInline
262 alt string
263}
264
265func (n *altSpan) Kind() ast.NodeKind { return kindAlt }
266
267func (n *altSpan) Dump(source []byte, level int) { ast.DumpHelper(n, source, level, nil, nil) }
268
269// altRenderer renders altSpan nodes.
270type altRenderer struct{}
271
272func (altRenderer) RegisterFuncs(r renderer.NodeRendererFuncRegisterer) {
273 r.Register(kindAlt, func(w util.BufWriter, _ []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
274 if entering {
275 _, _ = w.WriteString(`<span class="alt">[`)
276 _, _ = w.Write(util.EscapeHTML([]byte(n.(*altSpan).alt)))
277 _, _ = w.WriteString(`]</span>`)
278 }
279 return ast.WalkContinue, nil
280 })
281}