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.
Resolve a README's relative links against the file, not the page
796ccd9051ef49f10655088ec39b8dfb844f4124 · Keenan Nemetz · 13d ago · parent 53ff967b
Resolve a README's relative links against the file, not the page A relative link was resolved by the browser against the URL the README happened to be rendered at, so `docs/getting-started.md` reached `/r/<repo>/docs/getting-started.md` from the overview page and 404'd, and the same link broke differently from the code browser. A document that lives in the tree now carries its own path, and its links and images are rewritten after parsing and before rendering: markdown opens the preview, other files open the source, images point at the raw route, a directory opens the tree, and `.`/`..` resolve the way a reader expects with climbing clamped at the repository root. A destination that already names somewhere — a scheme, `//host`, a site-absolute path, a bare `#anchor` or `?query` — is left alone, and so is any document with no place in a tree, such as an issue body or a comment. The rewrite works on the parsed tree rather than on the markdown text, so reference-style links are covered too, and the source view still shows the file's bytes unchanged. Refs #01M5XF52R132A88C6WEQ6H6BK6
server/web/markdown.go +112−2
| @@ -8,12 +8,16 @@ package web | |||
| 8 | 8 | import ( | |
| 9 | 9 | "bytes" | |
| 10 | 10 | "html/template" | |
| 11 | "net/url" | ||
| 12 | "path" | ||
| 11 | 13 | "regexp" | |
| 12 | 14 | "strings" | |
| 13 | 15 | ||
| 14 | 16 | "github.com/microcosm-cc/bluemonday" | |
| 15 | 17 | "github.com/yuin/goldmark" | |
| 18 | "github.com/yuin/goldmark/ast" | ||
| 16 | 19 | "github.com/yuin/goldmark/extension" | |
| 20 | "github.com/yuin/goldmark/text" | ||
| 17 | 21 | ) | |
| 18 | 22 | ||
| 19 | 23 | var ( | |
| @@ -22,6 +26,16 @@ var ( | |||
| 22 | 26 | xrefRE = regexp.MustCompile(`#([0-9A-HJKMNP-TV-Z]{26})\b`) | |
| 23 | 27 | ) | |
| 24 | 28 | ||
| 29 | // treeDoc is a document that lives at a path in a repository, which is what a | ||
| 30 | // relative link inside it resolves against. An empty Ref means the content is not | ||
| 31 | // a file in a tree — an issue body, a comment, release notes — and relative links | ||
| 32 | // in it are left exactly as they were written. | ||
| 33 | type treeDoc struct { | ||
| 34 | Repo string | ||
| 35 | Ref string | ||
| 36 | Path string | ||
| 37 | } | ||
| 38 | |||
| 25 | 39 | // renderMarkdown converts untrusted commit content into HTML that is safe to put | |
| 26 | 40 | // in a page. Issues are authored by pushes, so this is the boundary where a | |
| 27 | 41 | // <script> tag stops being text. | |
| @@ -30,14 +44,110 @@ var ( | |||
| 30 | 44 | // rather than on the HTML, and fenced code blocks are skipped so a reference | |
| 31 | 45 | // inside a code sample stays literal. | |
| 32 | 46 | func (w *Web) renderMarkdown(repoName, src string) template.HTML { | |
| 33 | src = linkCrossReferences(repoName, src) | ||
| 47 | return w.renderTreeMarkdown(treeDoc{Repo: repoName}, src) | ||
| 48 | } | ||
| 49 | |||
| 50 | // renderTreeMarkdown is renderMarkdown for a document that has a place in the | ||
| 51 | // tree. A README is rendered from the repository overview and from the code | ||
| 52 | // browser, so a link written as `docs/getting-started.md` would resolve under | ||
| 53 | // `/r/<repo>` and 404; here it is resolved against the document's own directory | ||
| 54 | // and pointed at the code browser, which is the surface that serves files. | ||
| 55 | func (w *Web) renderTreeMarkdown(doc treeDoc, src string) template.HTML { | ||
| 56 | src = linkCrossReferences(doc.Repo, src) | ||
| 57 | source := []byte(src) | ||
| 58 | parsed := markdown.Parser().Parse(text.NewReader(source)) | ||
| 59 | rewriteTreeLinks(parsed, doc) | ||
| 34 | 60 | var buf bytes.Buffer | |
| 35 | if err := markdown.Convert([]byte(src), &buf); err != nil { | ||
| 61 | if err := markdown.Renderer().Render(&buf, source, parsed); err != nil { | ||
| 36 | 62 | return template.HTML(template.HTMLEscapeString(src)) | |
| 37 | 63 | } | |
| 38 | 64 | return template.HTML(sanitizer.SanitizeBytes(buf.Bytes())) | |
| 39 | 65 | } | |
| 40 | 66 | ||
| 67 | // rewriteTreeLinks points the relative links and images in a tree document at the | ||
| 68 | // file they name. It works on the parsed tree rather than on the markdown text so | ||
| 69 | // that every destination goldmark understands is covered the same way, including | ||
| 70 | // reference-style links and images. | ||
| 71 | func rewriteTreeLinks(doc ast.Node, tree treeDoc) { | ||
| 72 | ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { | ||
| 73 | if !entering { | ||
| 74 | return ast.WalkContinue, nil | ||
| 75 | } | ||
| 76 | switch v := n.(type) { | ||
| 77 | case *ast.Link: | ||
| 78 | if href, ok := docHref(tree, string(v.Destination), false); ok { | ||
| 79 | v.Destination = []byte(href) | ||
| 80 | } | ||
| 81 | case *ast.Image: | ||
| 82 | if href, ok := docHref(tree, string(v.Destination), true); ok { | ||
| 83 | v.Destination = []byte(href) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | return ast.WalkContinue, nil | ||
| 87 | }) | ||
| 88 | } | ||
| 89 | |||
| 90 | // docHref resolves one destination written in a tree document into the URL that | ||
| 91 | // serves it, reporting false for a destination that is already somewhere else: | ||
| 92 | // a scheme, a protocol-relative `//host`, a site-absolute `/path`, a bare | ||
| 93 | // `#anchor`/`?query` that refers to the document itself, or any document that | ||
| 94 | // does not live in a tree at all. | ||
| 95 | // | ||
| 96 | // A fragment and query on a relative link are kept, so `docs/x.md#heading` | ||
| 97 | // previews the file and then jumps. `..` is clamped at the repository root rather | ||
| 98 | // than allowed to walk out of it. | ||
| 99 | func docHref(doc treeDoc, dest string, image bool) (string, bool) { | ||
| 100 | if doc.Ref == "" || dest == "" || strings.HasPrefix(dest, "/") { | ||
| 101 | return "", false | ||
| 102 | } | ||
| 103 | u, err := url.Parse(dest) | ||
| 104 | if err != nil || u.Scheme != "" || u.Host != "" { | ||
| 105 | return "", false | ||
| 106 | } | ||
| 107 | name, suffix := dest, "" | ||
| 108 | if i := strings.IndexAny(dest, "?#"); i >= 0 { | ||
| 109 | name, suffix = dest[:i], dest[i:] | ||
| 110 | } | ||
| 111 | if name == "" { | ||
| 112 | return "", false | ||
| 113 | } | ||
| 114 | dir := path.Dir(doc.Path) | ||
| 115 | if dir == "." { | ||
| 116 | dir = "" | ||
| 117 | } | ||
| 118 | // A trailing slash, or a reference to a directory itself, opens the tree. | ||
| 119 | directory := strings.HasSuffix(name, "/") || name == "." || name == ".." | ||
| 120 | target := path.Join(dir, name) | ||
| 121 | if target == "." { | ||
| 122 | target = "" | ||
| 123 | } | ||
| 124 | for strings.HasPrefix(target, "../") { | ||
| 125 | target = strings.TrimPrefix(target, "../") | ||
| 126 | } | ||
| 127 | if target == ".." { | ||
| 128 | target = "" | ||
| 129 | } | ||
| 130 | |||
| 131 | base := "/r/" + url.PathEscape(doc.Repo) + "/" | ||
| 132 | ref := url.PathEscape(doc.Ref) | ||
| 133 | switch { | ||
| 134 | case image: | ||
| 135 | return base + "raw/" + ref + "/" + target + suffix, true | ||
| 136 | case directory && target == "": | ||
| 137 | // The repository root, which the code page is: there is no tree route | ||
| 138 | // for an empty path. | ||
| 139 | return base + "code?ref=" + url.QueryEscape(doc.Ref), true | ||
| 140 | case directory: | ||
| 141 | return base + "tree/" + ref + "/" + target + suffix, true | ||
| 142 | case isMarkdown(target): | ||
| 143 | // A documentation link wants the rendered page, and it opens on the same | ||
| 144 | // surface with a toggle back to the source. | ||
| 145 | return base + "blob/" + ref + "/" + target + "?preview=1" + suffix, true | ||
| 146 | default: | ||
| 147 | return base + "blob/" + ref + "/" + target + suffix, true | ||
| 148 | } | ||
| 149 | } | ||
| 150 | |||
| 41 | 151 | func linkCrossReferences(repoName, src string) string { | |
| 42 | 152 | var out strings.Builder | |
| 43 | 153 | inFence := false | |