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.

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