Resolve README-relative links
- SHA
adf15d6f685b894d7901ee5062d2b4eb107c2b92- Parents
-
c79744b - Tree
6d78971
adf15d6
adf15d6f685b894d7901ee5062d2b4eb107c2b92c79744b
6d78971| Status | File | + | - |
|---|---|---|---|
| M |
internal/web/handlers/repo/code.go
|
18 | 1 |
| A |
internal/web/handlers/repo/markdown_links.go
|
81 | 0 |
| A |
internal/web/handlers/repo/markdown_links_test.go
|
77 | 0 |
internal/web/handlers/repo/code.gomodified@@ -230,7 +230,13 @@ func (h *Handlers) findAndRenderREADME(r *http.Request, cc *codeContext, entries | ||
| 230 | 230 | if hasExt(lower, []string{".md", ".markdown"}) { |
| 231 | 231 | out, mderr := mdrender.RenderDocumentHTML(body) |
| 232 | 232 | if mderr == nil { |
| 233 | - return out | |
| 233 | + return rewriteMarkdownRelativeURLs( | |
| 234 | + out, | |
| 235 | + codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, cc.subpath), | |
| 236 | + codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""), | |
| 237 | + codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, cc.subpath), | |
| 238 | + codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""), | |
| 239 | + ) | |
| 234 | 240 | } |
| 235 | 241 | } |
| 236 | 242 | // Non-markdown plain text: escape + <pre>. |
@@ -304,6 +310,17 @@ func (h *Handlers) codeBlob(w http.ResponseWriter, r *http.Request) { | ||
| 304 | 310 | data["IsMarkdown"] = true |
| 305 | 311 | rendered, mderr := mdrender.RenderDocumentHTML(body) |
| 306 | 312 | if mderr == nil { |
| 313 | + dir := path.Dir(cc.subpath) | |
| 314 | + if dir == "." { | |
| 315 | + dir = "" | |
| 316 | + } | |
| 317 | + rendered = rewriteMarkdownRelativeURLs( | |
| 318 | + rendered, | |
| 319 | + codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, dir), | |
| 320 | + codeRouteBase(cc.owner, cc.row.Name, "blob", cc.ref, ""), | |
| 321 | + codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, dir), | |
| 322 | + codeRouteBase(cc.owner, cc.row.Name, "raw", cc.ref, ""), | |
| 323 | + ) | |
| 307 | 324 | data["MarkdownHTML"] = template.HTML(rendered) //nolint:gosec // sanitized |
| 308 | 325 | } |
| 309 | 326 | data["RawSource"] = string(body) |
internal/web/handlers/repo/markdown_links.goadded@@ -0,0 +1,81 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package repo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "bytes" | |
| 7 | + "io" | |
| 8 | + "net/url" | |
| 9 | + "path" | |
| 10 | + "strings" | |
| 11 | + | |
| 12 | + "golang.org/x/net/html" | |
| 13 | +) | |
| 14 | + | |
| 15 | +func codeRouteBase(owner, repoName, route, ref, dir string) string { | |
| 16 | + base := "/" + url.PathEscape(owner) + "/" + url.PathEscape(repoName) + "/" + route + "/" + escapePathSegments(ref) | |
| 17 | + if dir != "" { | |
| 18 | + base += "/" + escapePathSegments(dir) | |
| 19 | + } | |
| 20 | + return base | |
| 21 | +} | |
| 22 | + | |
| 23 | +func rewriteMarkdownRelativeURLs(fragment, linkBase, linkRoot, imageBase, imageRoot string) string { | |
| 24 | + if fragment == "" { | |
| 25 | + return "" | |
| 26 | + } | |
| 27 | + z := html.NewTokenizer(strings.NewReader(fragment)) | |
| 28 | + var out bytes.Buffer | |
| 29 | + for { | |
| 30 | + tt := z.Next() | |
| 31 | + if tt == html.ErrorToken { | |
| 32 | + if z.Err() == io.EOF { | |
| 33 | + return out.String() | |
| 34 | + } | |
| 35 | + return fragment | |
| 36 | + } | |
| 37 | + tok := z.Token() | |
| 38 | + switch tt { | |
| 39 | + case html.StartTagToken, html.SelfClosingTagToken: | |
| 40 | + rewriteMarkdownTokenURLs(&tok, linkBase, linkRoot, imageBase, imageRoot) | |
| 41 | + } | |
| 42 | + out.WriteString(tok.String()) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +func rewriteMarkdownTokenURLs(tok *html.Token, linkBase, linkRoot, imageBase, imageRoot string) { | |
| 47 | + switch tok.Data { | |
| 48 | + case "a": | |
| 49 | + rewriteAttr(tok, "href", linkBase, linkRoot) | |
| 50 | + case "img": | |
| 51 | + rewriteAttr(tok, "src", imageBase, imageRoot) | |
| 52 | + } | |
| 53 | +} | |
| 54 | + | |
| 55 | +func rewriteAttr(tok *html.Token, key, base, root string) { | |
| 56 | + for i := range tok.Attr { | |
| 57 | + if tok.Attr[i].Key == key { | |
| 58 | + tok.Attr[i].Val = rewriteRelativeMarkdownURL(tok.Attr[i].Val, base, root) | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +func rewriteRelativeMarkdownURL(raw, base, root string) string { | |
| 64 | + if raw == "" || base == "" || root == "" || strings.TrimSpace(raw) != raw { | |
| 65 | + return raw | |
| 66 | + } | |
| 67 | + if strings.HasPrefix(raw, "#") || strings.HasPrefix(raw, "//") { | |
| 68 | + return raw | |
| 69 | + } | |
| 70 | + u, err := url.Parse(raw) | |
| 71 | + if err != nil || u.IsAbs() || u.Host != "" || strings.HasPrefix(u.Path, "/") || u.Path == "" { | |
| 72 | + return raw | |
| 73 | + } | |
| 74 | + next := path.Clean(path.Clean(base) + "/" + u.Path) | |
| 75 | + if next != root && !strings.HasPrefix(next, root+"/") { | |
| 76 | + return raw | |
| 77 | + } | |
| 78 | + u.Path = next | |
| 79 | + u.RawPath = "" | |
| 80 | + return u.String() | |
| 81 | +} | |
internal/web/handlers/repo/markdown_links_test.goadded@@ -0,0 +1,77 @@ | ||
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | |
| 3 | +package repo | |
| 4 | + | |
| 5 | +import ( | |
| 6 | + "strings" | |
| 7 | + "testing" | |
| 8 | +) | |
| 9 | + | |
| 10 | +func TestRewriteMarkdownRelativeURLs(t *testing.T) { | |
| 11 | + t.Parallel() | |
| 12 | + in := `<p align="center"><img src="internal/web/static/logo/shithub-mark.svg" width="120"></p>` + | |
| 13 | + `<p><a href="CONTRIBUTING.md">docs</a> <a href="#why">why</a> <a href="https://example.com/x">external</a></p>` | |
| 14 | + got := rewriteMarkdownRelativeURLs( | |
| 15 | + in, | |
| 16 | + codeRouteBase("tenseleyFlow", "shithub", "blob", "trunk", ""), | |
| 17 | + codeRouteBase("tenseleyFlow", "shithub", "blob", "trunk", ""), | |
| 18 | + codeRouteBase("tenseleyFlow", "shithub", "raw", "trunk", ""), | |
| 19 | + codeRouteBase("tenseleyFlow", "shithub", "raw", "trunk", ""), | |
| 20 | + ) | |
| 21 | + for _, want := range []string{ | |
| 22 | + `align="center"`, | |
| 23 | + `src="/tenseleyFlow/shithub/raw/trunk/internal/web/static/logo/shithub-mark.svg"`, | |
| 24 | + `width="120"`, | |
| 25 | + `href="/tenseleyFlow/shithub/blob/trunk/CONTRIBUTING.md"`, | |
| 26 | + `href="#why"`, | |
| 27 | + `href="https://example.com/x"`, | |
| 28 | + } { | |
| 29 | + if !strings.Contains(got, want) { | |
| 30 | + t.Fatalf("missing %q in %q", want, got) | |
| 31 | + } | |
| 32 | + } | |
| 33 | +} | |
| 34 | + | |
| 35 | +func TestRewriteMarkdownRelativeURLsFromSubdirectory(t *testing.T) { | |
| 36 | + t.Parallel() | |
| 37 | + got := rewriteMarkdownRelativeURLs( | |
| 38 | + `<p><img src="../assets/logo mark.svg"><a href="./guide.md#install">guide</a></p>`, | |
| 39 | + codeRouteBase("octo", "demo", "blob", "feature/x", "docs/reference"), | |
| 40 | + codeRouteBase("octo", "demo", "blob", "feature/x", ""), | |
| 41 | + codeRouteBase("octo", "demo", "raw", "feature/x", "docs/reference"), | |
| 42 | + codeRouteBase("octo", "demo", "raw", "feature/x", ""), | |
| 43 | + ) | |
| 44 | + for _, want := range []string{ | |
| 45 | + `src="/octo/demo/raw/feature/x/docs/assets/logo%20mark.svg"`, | |
| 46 | + `href="/octo/demo/blob/feature/x/docs/reference/guide.md#install"`, | |
| 47 | + } { | |
| 48 | + if !strings.Contains(got, want) { | |
| 49 | + t.Fatalf("missing %q in %q", want, got) | |
| 50 | + } | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +func TestRewriteRelativeMarkdownURLDoesNotEscapeRepoRoot(t *testing.T) { | |
| 55 | + t.Parallel() | |
| 56 | + got := rewriteRelativeMarkdownURL( | |
| 57 | + "../../../../settings", | |
| 58 | + codeRouteBase("octo", "demo", "blob", "trunk", "docs"), | |
| 59 | + codeRouteBase("octo", "demo", "blob", "trunk", ""), | |
| 60 | + ) | |
| 61 | + if got != "../../../../settings" { | |
| 62 | + t.Fatalf("escaped repo-root link should be left alone, got %q", got) | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 66 | +func TestRewriteRelativeMarkdownURL(t *testing.T) { | |
| 67 | + t.Parallel() | |
| 68 | + got := rewriteRelativeMarkdownURL( | |
| 69 | + "internal/web/static/logo/shithub-mark.svg", | |
| 70 | + codeRouteBase("tenseleyFlow", "shithub", "raw", "trunk", ""), | |
| 71 | + codeRouteBase("tenseleyFlow", "shithub", "raw", "trunk", ""), | |
| 72 | + ) | |
| 73 | + want := "/tenseleyFlow/shithub/raw/trunk/internal/web/static/logo/shithub-mark.svg" | |
| 74 | + if got != want { | |
| 75 | + t.Fatalf("rewrite = %q, want %q", got, want) | |
| 76 | + } | |
| 77 | +} | |