Relative README links open the repository file viewer at the same ref; external, absolute and fragment links stay as written. Long-form events render their Markdown in the shared event viewer.
- commit
- 789c96dc188d999ff4e67486387d97e9df2e6299
- parent
- 3408716c6332a8604afde0285761b34e0dd16e7c
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 55 | +++++++++++++++++++++++++++++- |
| M | internal/ | 21 | +++++++++++ |
| M | internal/ | 2 | +- |
| M | internal/ | 26 | ++++++++++++++ |
| M | internal/ | 2 | +- |
@@ -3,6 +3,8 @@ package webui
33 import (44 "html"55 "html/template"6+ "net/url"7+ "path"68 "regexp"79 "strings"810 )@@ -69,7 +71,7 @@ var (
6971 orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`)7072 bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`)7173 codeSpan = regexp.MustCompile("`([^`]+)`")72- linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(((?:https?://|/|\./|#|mailto:)[^)\s]*)\)`)74+ linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)7375 strongSpan = regexp.MustCompile(`\*\*([^*]+)\*\*`)7476 emSpan = regexp.MustCompile(`(^|[^*\w])\*([^*]+)\*`)7577 )@@ -91,8 +93,57 @@ func listText(line string) string {
9193 func inlineMarkdown(text string) string {9294 escaped := html.EscapeString(text)9395 escaped = codeSpan.ReplaceAllString(escaped, "<code>$1</code>")94- escaped = linkSpan.ReplaceAllString(escaped, `<a href="$2">$1</a>`)96+ escaped = linkSpan.ReplaceAllStringFunc(escaped, func(value string) string {97+ match := linkSpan.FindStringSubmatch(value)98+ if len(match) != 3 || !safeMarkdownLink(html.UnescapeString(match[2])) {99+ return value100+ }101+ return `<a href="` + match[2] + `">` + match[1] + `</a>`102+ })95103 escaped = strongSpan.ReplaceAllString(escaped, "<strong>$1</strong>")96104 escaped = emSpan.ReplaceAllString(escaped, "$1<em>$2</em>")97105 return escaped98106 }107+108+func safeMarkdownLink(target string) bool {109+ return strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") ||110+ (strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//")) ||111+ strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") ||112+ strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") ||113+ (!strings.Contains(target, ":") && !strings.HasPrefix(target, "//"))114+}115+116+var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`)117+118+// renderRepositoryMarkdown resolves relative README links to relay file119+// viewers while retaining external links and fragment-only links.120+func renderRepositoryMarkdown(source string, query url.Values) template.HTML {121+ rendered := string(renderMarkdown(source))122+ return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string {123+ match := markdownAnchor.FindStringSubmatch(anchor)124+ target, err := url.Parse(html.UnescapeString(match[1]))125+ if err != nil || target.IsAbs() || target.Host != "" || target.Path == "" || strings.HasPrefix(target.Path, "/") {126+ return anchor127+ }128+ if target.Path == ".." || strings.HasPrefix(target.Path, "../") {129+ return anchor130+ }131+ name := strings.TrimPrefix(path.Clean("/"+target.Path), "/")132+ if name == "" {133+ return anchor134+ }135+ destination, err := url.Parse(repoURL(query, "file", name))136+ if err != nil {137+ return anchor138+ }139+ params := destination.Query()140+ for key, values := range target.Query() {141+ if !params.Has(key) {142+ params[key] = values143+ }144+ }145+ destination.RawQuery = params.Encode()146+ destination.Fragment = target.Fragment147+ return `<a href="` + html.EscapeString(destination.String()) + `">`148+ }))149+}@@ -1,6 +1,7 @@
11 package webui22 33 import (4+ "net/url"45 "strings"56 "testing"67 )@@ -16,3 +17,23 @@ func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) {
1617 t.Fatalf("unsafe markup leaked: %s", got)1718 }1819 }20+21+func TestRepositoryMarkdownResolvesRelativeLinks(t *testing.T) {22+ query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}}23+ got := string(renderRepositoryMarkdown("[guide](docs/guide.md) [up](../secret.md) [site](https://example.com)", query))24+ if !strings.Contains(got, `href="/repo?owner=alice&path=docs%2Fguide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file"`) {25+ t.Fatalf("relative link was not mapped to repository viewer: %s", got)26+ }27+ if !strings.Contains(got, `href="https://example.com"`) {28+ t.Fatalf("repository link handling changed unsafe or external links: %s", got)29+ }30+}31+32+func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) {33+ query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}}34+ got := string(renderRepositoryMarkdown("[guide](docs/caf%C3%A9%20guide.md?download=1#L2)", query))35+ want := `href="/repo?download=1&owner=alice&path=docs%2Fcaf%C3%A9+guide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file#L2"`36+ if !strings.Contains(got, want) {37+ t.Fatalf("encoded README link lost its resource or fragment: %s", got)38+ }39+}@@ -121,7 +121,7 @@
121121 122122 {{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}">123123 <header><a href="/e/{{str (index $e "id")}}">{{shortID (index $e "id")}}</a> | kind {{str (index $e "kind")}} | {{template "nostrKey" (str (index $e "pubkey"))}}<small><time datetime="{{datetime (index $e "created_at")}}">{{when (index $e "created_at")}}</time></small></header>124-<pre>{{str (index $e "content")}}</pre>124+{{if eq (str (index $e "kind")) "30023"}}<article>{{markdown (str (index $e "content"))}}</article>{{else}}<pre>{{str (index $e "content")}}</pre>{{end}}125125 <details><summary>event JSON</summary>{{template "jsonView" .}}</details>126126 </nostr-event>{{end}}127127 @@ -29,6 +29,32 @@ func (b *articleBackend) Query(_ context.Context, method string, _ []json.RawMes
2929 return nil, nil3030 }3131 32+type articleDetailBackend struct{ fakeBackend }33+34+func (b *articleDetailBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) {35+ if method == "eventdetail" {36+ return map[string]any{"id": "article-1", "kind": 30023, "created_at": float64(1700000000), "pubkey": strings.Repeat("a", 64), "content": "# Heading\n\nBody"}, nil37+ }38+ return nil, nil39+}40+41+func TestArticleDetailRendersMarkdownContent(t *testing.T) {42+ backend := &articleDetailBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}43+ app, err := New(backend, Options{})44+ if err != nil {45+ t.Fatal(err)46+ }47+ recorder := httptest.NewRecorder()48+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/e/article-1", nil))49+ body := recorder.Body.String()50+ if recorder.Code != http.StatusOK || !strings.Contains(body, `<nostr-event id="event-article-1">`) || !strings.Contains(body, "<article><h1>Heading</h1>") {51+ t.Fatalf("article detail did not render markdown within the shared event viewer: status=%d", recorder.Code)52+ }53+ if strings.Contains(body, "<pre># Heading") {54+ t.Fatalf("article detail retained plaintext rendering: %s", body)55+ }56+}57+3258 func TestPublicSearchAndInboxRenderBackendResults(t *testing.T) {3359 owner := strings.Repeat("a", 64)3460 backend := &publicBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}@@ -556,7 +556,7 @@ func (a *App) readme(ctx context.Context, actor string, query url.Values) templa
556556 if binary, _ := page["binary"].(bool); binary || content == "" {557557 continue558558 }559- return renderMarkdown(content)559+ return renderRepositoryMarkdown(content, query)560560 }561561 return ""562562 }