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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 23m ago
4b8c9e1d
| 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" |
| 11 | "net/url" |
| 12 | "path" |
| 13 | "regexp" |
| 14 | "strings" |
| 15 | |
| 16 | "github.com/microcosm-cc/bluemonday" |
| 17 | "github.com/yuin/goldmark" |
| 18 | "github.com/yuin/goldmark/ast" |
| 19 | "github.com/yuin/goldmark/extension" |
| 20 | "github.com/yuin/goldmark/text" |
| 21 | |
| 22 | "rodent.software/vole/metadata" |
| 23 | ) |
| 24 | |
| 25 | var ( |
| 26 | markdown = goldmark.New(goldmark.WithExtensions(extension.GFM, extension.Typographer)) |
| 27 | sanitizer = bluemonday.UGCPolicy() |
| 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`) |
| 34 | ) |
| 35 | |
| 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 |
| 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 |
| 50 | } |
| 51 | |
| 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 |
| 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. |
| 61 | func (w *Web) renderMarkdown(repoName, src string) template.HTML { |
| 62 | return w.renderTreeMarkdown(treeDoc{Repo: repoName, Mentions: true}, src) |
| 63 | } |
| 64 | |
| 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 | |
| 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 |
| 77 | // `/<repo>` and 404; here it is resolved against the document's own directory |
| 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 { |
| 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 | } |
| 86 | source := []byte(src) |
| 87 | parsed := markdown.Parser().Parse(text.NewReader(source)) |
| 88 | rewriteTreeLinks(parsed, doc) |
| 89 | if doc.Mentions { |
| 90 | w.linkMentions(parsed, source) |
| 91 | } |
| 92 | var buf bytes.Buffer |
| 93 | if err := markdown.Renderer().Render(&buf, source, parsed); err != nil { |
| 94 | return template.HTML(template.HTMLEscapeString(src)) |
| 95 | } |
| 96 | return template.HTML(sanitizer.SanitizeBytes(buf.Bytes())) |
| 97 | } |
| 98 | |
| 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 | |
| 163 | base := "/" + url.PathEscape(doc.Repo) + "/" |
| 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 | |
| 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 { |
| 197 | groups := xrefRE.FindStringSubmatch(m) |
| 198 | if groups[1] != "" { |
| 199 | return "[#" + groups[1] + "](/" + repoName + "/issues/" + groups[1] + ")" |
| 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. |
| 203 | return "[#" + groups[2] + "](/" + repoName + "/n/" + groups[2] + ")" |
| 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 | } |
| 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 | } |
| 309 |