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