Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Blame · server/web/markdown.go

Line attribution on main · 308 line(s)

c717d4eb
Keenan Nemetz
13d ago
1 package web
2
3 // The markdown boundary. Issue bodies, comments, release notes and a repository
4 // README are authored by pushes, so this is where untrusted text becomes HTML
5 // that is safe to place in a page: goldmark renders it, bluemonday sanitises it,
6 // and only the result is marked template.HTML.
7
8 import (
9 "bytes"
10 "html/template"
796ccd90
Keenan Nemetz
13d ago
11 "net/url"
12 "path"
c717d4eb
Keenan Nemetz
13d ago
13 "regexp"
14 "strings"
15
16 "github.com/microcosm-cc/bluemonday"
17 "github.com/yuin/goldmark"
796ccd90
Keenan Nemetz
13d ago
18 "github.com/yuin/goldmark/ast"
c717d4eb
Keenan Nemetz
13d ago
19 "github.com/yuin/goldmark/extension"
796ccd90
Keenan Nemetz
13d ago
20 "github.com/yuin/goldmark/text"
a6b42433
Keenan Nemetz
7d ago
21
d37d301f
Keenan Nemetz
15h ago
22 "rodent.software/vole/metadata"
c717d4eb
Keenan Nemetz
13d ago
23 )
24
25 var (
26 markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Typographer))
27 sanitizer = bluemonday.UGCPolicy()
e05f1c48
Keenan Nemetz
13d ago
28 // xrefRE matches the two ways a body names another entity: the durable ULID and
29 // the short alias. The alias is deliberately only digits, so it can never be
30 // confused with a ULID prefix, and a number longer than nine digits is not a
31 // plausible issue number while a 26-character ULID that happens to be all digits
32 // is still too long to match here.
33 xrefRE = regexp.MustCompile(`#([0-9A-HJKMNP-TV-Z]{26})\b|#([0-9]{1,9})\b`)
c717d4eb
Keenan Nemetz
13d ago
34 )
35
796ccd90
Keenan Nemetz
13d ago
36 // treeDoc is a document that lives at a path in a repository, which is what a
37 // relative link inside it resolves against. An empty Ref means the content is not
38 // a file in a tree — an issue body, a comment, release notes — and relative links
39 // in it are left exactly as they were written.
40 type treeDoc struct {
41 Repo string
42 Ref string
43 Path string
a6b42433
Keenan Nemetz
7d ago
44 // Mentions links @name to the account it addresses. It is set for the
45 // documents that address people — an issue, a comment, a change request, a
46 // release note — and left off for a README or a documentation file, where a
47 // name is an example rather than somebody being spoken to. The same bodies
48 // are the ones CI evaluates mentions in.
49 Mentions bool
796ccd90
Keenan Nemetz
13d ago
50 }
51
c717d4eb
Keenan Nemetz
13d ago
52 // renderMarkdown converts untrusted commit content into HTML that is safe to put
53 // in a page. Issues are authored by pushes, so this is the boundary where a
54 // <script> tag stops being text.
55 //
56 // `#<ulid>` becomes a link to the issue. That is done on the markdown source
57 // rather than on the HTML, and fenced code blocks are skipped so a reference
a6b42433
Keenan Nemetz
7d ago
58 // inside a code sample stays literal. `@name` becomes a link to the account's
59 // profile on the same terms: the mention scanner CI uses decides what counts, so
60 // the page and a workflow agree about who was addressed.
c717d4eb
Keenan Nemetz
13d ago
61 func (w *Web) renderMarkdown(repoName, src string) template.HTML {
a6b42433
Keenan Nemetz
7d ago
62 return w.renderTreeMarkdown(treeDoc{Repo: repoName, Mentions: true}, src)
796ccd90
Keenan Nemetz
13d ago
63 }
64
6ce635a8
Keenan Nemetz
1d ago
65 // renderInstanceMarkdown renders the landing page's introduction. An instance
66 // admin writes it on the home page rather than pushing it, but it is still text
67 // from a form that ends up in a page, so it goes through the same parser and
68 // sanitizer as an issue body. It has no repository and addresses nobody: `#123` stays literal
69 // and `@name` stays a name.
70 func (w *Web) renderInstanceMarkdown(src string) template.HTML {
71 return w.renderTreeMarkdown(treeDoc{}, src)
72 }
73
796ccd90
Keenan Nemetz
13d ago
74 // renderTreeMarkdown is renderMarkdown for a document that has a place in the
75 // tree. A README is rendered from the repository overview and from the code
76 // browser, so a link written as `docs/getting-started.md` would resolve under
ed03b3d1
Keenan Nemetz
2h ago
77 // `/<repo>` and 404; here it is resolved against the document's own directory
796ccd90
Keenan Nemetz
13d ago
78 // and pointed at the code browser, which is the surface that serves files.
79 func (w *Web) renderTreeMarkdown(doc treeDoc, src string) template.HTML {
6ce635a8
Keenan Nemetz
1d ago
80 // #123 names an issue in a repository. An instance-level document has no
81 // repository, so the reference is left as text rather than turned into a
82 // link with an empty repository in it.
83 if doc.Repo != "" {
84 src = linkCrossReferences(doc.Repo, src)
85 }
796ccd90
Keenan Nemetz
13d ago
86 source := []byte(src)
87 parsed := markdown.Parser().Parse(text.NewReader(source))
88 rewriteTreeLinks(parsed, doc)
a6b42433
Keenan Nemetz
7d ago
89 if doc.Mentions {
90 w.linkMentions(parsed, source)
91 }
c717d4eb
Keenan Nemetz
13d ago
92 var buf bytes.Buffer
796ccd90
Keenan Nemetz
13d ago
93 if err := markdown.Renderer().Render(&buf, source, parsed); err != nil {
c717d4eb
Keenan Nemetz
13d ago
94 return template.HTML(template.HTMLEscapeString(src))
95 }
96 return template.HTML(sanitizer.SanitizeBytes(buf.Bytes()))
97 }
98
796ccd90
Keenan Nemetz
13d ago
99 // rewriteTreeLinks points the relative links and images in a tree document at the
100 // file they name. It works on the parsed tree rather than on the markdown text so
101 // that every destination goldmark understands is covered the same way, including
102 // reference-style links and images.
103 func rewriteTreeLinks(doc ast.Node, tree treeDoc) {
104 ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
105 if !entering {
106 return ast.WalkContinue, nil
107 }
108 switch v := n.(type) {
109 case *ast.Link:
110 if href, ok := docHref(tree, string(v.Destination), false); ok {
111 v.Destination = []byte(href)
112 }
113 case *ast.Image:
114 if href, ok := docHref(tree, string(v.Destination), true); ok {
115 v.Destination = []byte(href)
116 }
117 }
118 return ast.WalkContinue, nil
119 })
120 }
121
122 // docHref resolves one destination written in a tree document into the URL that
123 // serves it, reporting false for a destination that is already somewhere else:
124 // a scheme, a protocol-relative `//host`, a site-absolute `/path`, a bare
125 // `#anchor`/`?query` that refers to the document itself, or any document that
126 // does not live in a tree at all.
127 //
128 // A fragment and query on a relative link are kept, so `docs/x.md#heading`
129 // previews the file and then jumps. `..` is clamped at the repository root rather
130 // than allowed to walk out of it.
131 func docHref(doc treeDoc, dest string, image bool) (string, bool) {
132 if doc.Ref == "" || dest == "" || strings.HasPrefix(dest, "/") {
133 return "", false
134 }
135 u, err := url.Parse(dest)
136 if err != nil || u.Scheme != "" || u.Host != "" {
137 return "", false
138 }
139 name, suffix := dest, ""
140 if i := strings.IndexAny(dest, "?#"); i >= 0 {
141 name, suffix = dest[:i], dest[i:]
142 }
143 if name == "" {
144 return "", false
145 }
146 dir := path.Dir(doc.Path)
147 if dir == "." {
148 dir = ""
149 }
150 // A trailing slash, or a reference to a directory itself, opens the tree.
151 directory := strings.HasSuffix(name, "/") || name == "." || name == ".."
152 target := path.Join(dir, name)
153 if target == "." {
154 target = ""
155 }
156 for strings.HasPrefix(target, "../") {
157 target = strings.TrimPrefix(target, "../")
158 }
159 if target == ".." {
160 target = ""
161 }
162
ed03b3d1
Keenan Nemetz
2h ago
163 base := "/" + url.PathEscape(doc.Repo) + "/"
796ccd90
Keenan Nemetz
13d ago
164 ref := url.PathEscape(doc.Ref)
165 switch {
166 case image:
167 return base + "raw/" + ref + "/" + target + suffix, true
168 case directory && target == "":
169 // The repository root, which the code page is: there is no tree route
170 // for an empty path.
171 return base + "code?ref=" + url.QueryEscape(doc.Ref), true
172 case directory:
173 return base + "tree/" + ref + "/" + target + suffix, true
174 case isMarkdown(target):
175 // A documentation link wants the rendered page, and it opens on the same
176 // surface with a toggle back to the source.
177 return base + "blob/" + ref + "/" + target + "?preview=1" + suffix, true
178 default:
179 return base + "blob/" + ref + "/" + target + suffix, true
180 }
181 }
182
c717d4eb
Keenan Nemetz
13d ago
183 func linkCrossReferences(repoName, src string) string {
184 var out strings.Builder
185 inFence := false
186 for _, line := range strings.SplitAfter(src, "\n") {
187 if strings.HasPrefix(strings.TrimSpace(line), "```") {
188 inFence = !inFence
189 out.WriteString(line)
190 continue
191 }
192 if inFence {
193 out.WriteString(line)
194 continue
195 }
196 out.WriteString(xrefRE.ReplaceAllStringFunc(line, func(m string) string {
e05f1c48
Keenan Nemetz
13d ago
197 groups := xrefRE.FindStringSubmatch(m)
198 if groups[1] != "" {
ed03b3d1
Keenan Nemetz
2h ago
199 return "[#" + groups[1] + "](/" + repoName + "/issues/" + groups[1] + ")"
e05f1c48
Keenan Nemetz
13d ago
200 }
201 // A number is kind-agnostic: it may name an issue or a change request, and
202 // only the server can say which. One route answers both.
ed03b3d1
Keenan Nemetz
2h ago
203 return "[#" + groups[2] + "](/" + repoName + "/n/" + groups[2] + ")"
c717d4eb
Keenan Nemetz
13d ago
204 }))
205 }
206 return out.String()
207 }
208
209 // isMarkdown names the extensions the web renders instead of showing as source.
210 func isMarkdown(path string) bool {
211 switch strings.ToLower(path[strings.LastIndexByte(path, '.')+1:]) {
212 case "md", "markdown", "mdown", "mkd":
213 return true
214 }
215 return false
216 }
a6b42433
Keenan Nemetz
7d ago
217
218 // linkMentions turns `@name` into a link to the account's profile. It works on
219 // the parsed tree rather than on the source text so that everything goldmark
220 // already understands stays correct: a mention inside inline code, a fenced
221 // block, an image, or another link's label is left exactly as written, and a
222 // backslash escape is still visible in the raw segment and therefore still
223 // literal.
224 //
225 // Only names that belong to an account become links. A typo, or a name that
226 // addresses nobody, stays text: a profile link that 404s is worse than a mention
227 // that does not open anything.
228 func (w *Web) linkMentions(doc ast.Node, source []byte) {
229 var nodes []*ast.Text
230 ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
231 if !entering {
232 return ast.WalkContinue, nil
233 }
234 t, ok := n.(*ast.Text)
235 if !ok || t.IsRaw() || !mentionProse(n) {
236 return ast.WalkContinue, nil
237 }
238 nodes = append(nodes, t)
239 return ast.WalkContinue, nil
240 })
241 for _, node := range nodes {
242 w.linkMentionNode(node, source)
243 }
244 }
245
246 // mentionProse reports whether a text node is prose a mention may be linked in:
247 // not the inside of code, a link, an autolink, or an image.
248 func mentionProse(n ast.Node) bool {
249 for p := n.Parent(); p != nil; p = p.Parent() {
250 switch p.(type) {
251 case *ast.Link, *ast.AutoLink, *ast.Image, *ast.CodeSpan, *ast.CodeBlock, *ast.FencedCodeBlock:
252 return false
253 }
254 }
255 return true
256 }
257
258 // linkMentionNode rewrites one text node into prose and links. The spans come from
259 // metadata.EachMention, which is the scanner the trigger server evaluates, so
260 // what the page links and what wakes a workflow cannot drift apart.
261 func (w *Web) linkMentionNode(node *ast.Text, source []byte) {
262 raw := node.Segment.Value(source)
263 type mention struct {
264 start, end int // byte offsets into raw, the '@' included
265 href string
266 }
267 var found []mention
268 metadata.EachMention(string(raw), func(start, end int, name string) bool {
269 ref := w.userRef(name)
270 if !ref.Known {
271 return true
272 }
273 found = append(found, mention{start: start, end: end, href: ref.Href})
274 return true
275 })
276 if len(found) == 0 {
277 return
278 }
279
280 base := node.Segment.Start
281 var replacement []ast.Node
282 last := node.Segment.Start
283 for _, m := range found {
284 start, end := base+m.start, base+m.end
285 if start > last {
286 replacement = append(replacement, ast.NewTextSegment(text.NewSegment(last, start)))
287 }
288 link := ast.NewLink()
289 link.Destination = []byte(m.href)
290 link.AppendChild(link, ast.NewTextSegment(text.NewSegment(start, end)))
291 replacement = append(replacement, link)
292 last = end
293 }
294 if last < node.Segment.Stop {
295 replacement = append(replacement, ast.NewTextSegment(text.NewSegment(last, node.Segment.Stop)))
296 }
297 if len(replacement) == 0 {
298 return
299 }
300
301 parent := node.Parent()
302 parent.ReplaceChild(parent, node, replacement[0])
303 cursor := replacement[0]
304 for _, n := range replacement[1:] {
305 parent.InsertAfter(parent, cursor, n)
306 cursor = n
307 }
308 }