| 1 | diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go |
| 2 | index a4dd7bf..c2f3046 100644 |
| 3 | --- a/internal/webui/markdown.go |
| 4 | +++ b/internal/webui/markdown.go |
| 5 | @@ -3,6 +3,8 @@ package webui |
| 6 | import ( |
| 7 | "html" |
| 8 | "html/template" |
| 9 | + "net/url" |
| 10 | + "path" |
| 11 | "regexp" |
| 12 | "strings" |
| 13 | ) |
| 14 | @@ -69,7 +71,7 @@ var ( |
| 15 | orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`) |
| 16 | bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`) |
| 17 | codeSpan = regexp.MustCompile("`([^`]+)`") |
| 18 | - linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(((?:https?://|/|\./|#|mailto:)[^)\s]*)\)`) |
| 19 | + linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`) |
| 20 | strongSpan = regexp.MustCompile(`\*\*([^*]+)\*\*`) |
| 21 | emSpan = regexp.MustCompile(`(^|[^*\w])\*([^*]+)\*`) |
| 22 | ) |
| 23 | @@ -91,8 +93,57 @@ func listText(line string) string { |
| 24 | func inlineMarkdown(text string) string { |
| 25 | escaped := html.EscapeString(text) |
| 26 | escaped = codeSpan.ReplaceAllString(escaped, "<code>$1</code>") |
| 27 | - escaped = linkSpan.ReplaceAllString(escaped, `<a href="$2">$1</a>`) |
| 28 | + escaped = linkSpan.ReplaceAllStringFunc(escaped, func(value string) string { |
| 29 | + match := linkSpan.FindStringSubmatch(value) |
| 30 | + if len(match) != 3 || !safeMarkdownLink(html.UnescapeString(match[2])) { |
| 31 | + return value |
| 32 | + } |
| 33 | + return `<a href="` + match[2] + `">` + match[1] + `</a>` |
| 34 | + }) |
| 35 | escaped = strongSpan.ReplaceAllString(escaped, "<strong>$1</strong>") |
| 36 | escaped = emSpan.ReplaceAllString(escaped, "$1<em>$2</em>") |
| 37 | return escaped |
| 38 | } |
| 39 | + |
| 40 | +func safeMarkdownLink(target string) bool { |
| 41 | + return strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") || |
| 42 | + (strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//")) || |
| 43 | + strings.HasPrefix(target, "./") || strings.HasPrefix(target, "../") || |
| 44 | + strings.HasPrefix(target, "#") || strings.HasPrefix(target, "mailto:") || |
| 45 | + (!strings.Contains(target, ":") && !strings.HasPrefix(target, "//")) |
| 46 | +} |
| 47 | + |
| 48 | +var markdownAnchor = regexp.MustCompile(`<a href="([^"]+)">`) |
| 49 | + |
| 50 | +// renderRepositoryMarkdown resolves relative README links to relay file |
| 51 | +// viewers while retaining external links and fragment-only links. |
| 52 | +func renderRepositoryMarkdown(source string, query url.Values) template.HTML { |
| 53 | + rendered := string(renderMarkdown(source)) |
| 54 | + return template.HTML(markdownAnchor.ReplaceAllStringFunc(rendered, func(anchor string) string { |
| 55 | + match := markdownAnchor.FindStringSubmatch(anchor) |
| 56 | + target, err := url.Parse(html.UnescapeString(match[1])) |
| 57 | + if err != nil || target.IsAbs() || target.Host != "" || target.Path == "" || strings.HasPrefix(target.Path, "/") { |
| 58 | + return anchor |
| 59 | + } |
| 60 | + if target.Path == ".." || strings.HasPrefix(target.Path, "../") { |
| 61 | + return anchor |
| 62 | + } |
| 63 | + name := strings.TrimPrefix(path.Clean("/"+target.Path), "/") |
| 64 | + if name == "" { |
| 65 | + return anchor |
| 66 | + } |
| 67 | + destination, err := url.Parse(repoURL(query, "file", name)) |
| 68 | + if err != nil { |
| 69 | + return anchor |
| 70 | + } |
| 71 | + params := destination.Query() |
| 72 | + for key, values := range target.Query() { |
| 73 | + if !params.Has(key) { |
| 74 | + params[key] = values |
| 75 | + } |
| 76 | + } |
| 77 | + destination.RawQuery = params.Encode() |
| 78 | + destination.Fragment = target.Fragment |
| 79 | + return `<a href="` + html.EscapeString(destination.String()) + `">` |
| 80 | + })) |
| 81 | +} |
| 82 | diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go |
| 83 | index 860ccd7..7268e81 100644 |
| 84 | --- a/internal/webui/markdown_test.go |
| 85 | +++ b/internal/webui/markdown_test.go |
| 86 | @@ -1,6 +1,7 @@ |
| 87 | package webui |
| 88 | |
| 89 | import ( |
| 90 | + "net/url" |
| 91 | "strings" |
| 92 | "testing" |
| 93 | ) |
| 94 | @@ -16,3 +17,23 @@ func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) { |
| 95 | t.Fatalf("unsafe markup leaked: %s", got) |
| 96 | } |
| 97 | } |
| 98 | + |
| 99 | +func TestRepositoryMarkdownResolvesRelativeLinks(t *testing.T) { |
| 100 | + query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}} |
| 101 | + got := string(renderRepositoryMarkdown("[guide](docs/guide.md) [up](../secret.md) [site](https://example.com)", query)) |
| 102 | + if !strings.Contains(got, `href="/repo?owner=alice&path=docs%2Fguide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file"`) { |
| 103 | + t.Fatalf("relative link was not mapped to repository viewer: %s", got) |
| 104 | + } |
| 105 | + if !strings.Contains(got, `href="https://example.com"`) { |
| 106 | + t.Fatalf("repository link handling changed unsafe or external links: %s", got) |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +func TestRepositoryMarkdownDecodesPathsAndPreservesFragments(t *testing.T) { |
| 111 | + query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"refs/heads/main"}} |
| 112 | + got := string(renderRepositoryMarkdown("[guide](docs/caf%C3%A9%20guide.md?download=1#L2)", query)) |
| 113 | + want := `href="/repo?download=1&owner=alice&path=docs%2Fcaf%C3%A9+guide.md&ref=refs%2Fheads%2Fmain&repo=notes&view=file#L2"` |
| 114 | + if !strings.Contains(got, want) { |
| 115 | + t.Fatalf("encoded README link lost its resource or fragment: %s", got) |
| 116 | + } |
| 117 | +} |
| 118 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 119 | index ccc71d4..60f1a2d 100644 |
| 120 | --- a/internal/webui/page.html |
| 121 | +++ b/internal/webui/page.html |
| 122 | @@ -121,7 +121,7 @@ |
| 123 | |
| 124 | {{define "nostrEvent"}}{{$e := asMap .}}<nostr-event id="event-{{str (index $e "id")}}"> |
| 125 | <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> |
| 126 | -<pre>{{str (index $e "content")}}</pre> |
| 127 | +{{if eq (str (index $e "kind")) "30023"}}<article>{{markdown (str (index $e "content"))}}</article>{{else}}<pre>{{str (index $e "content")}}</pre>{{end}} |
| 128 | <details><summary>event JSON</summary>{{template "jsonView" .}}</details> |
| 129 | </nostr-event>{{end}} |
| 130 | |
| 131 | diff --git a/internal/webui/public_pages_test.go b/internal/webui/public_pages_test.go |
| 132 | index 37d4b46..1a28f76 100644 |
| 133 | --- a/internal/webui/public_pages_test.go |
| 134 | +++ b/internal/webui/public_pages_test.go |
| 135 | @@ -29,6 +29,32 @@ func (b *articleBackend) Query(_ context.Context, method string, _ []json.RawMes |
| 136 | return nil, nil |
| 137 | } |
| 138 | |
| 139 | +type articleDetailBackend struct{ fakeBackend } |
| 140 | + |
| 141 | +func (b *articleDetailBackend) Query(_ context.Context, method string, _ []json.RawMessage, _ string) (any, error) { |
| 142 | + if method == "eventdetail" { |
| 143 | + return map[string]any{"id": "article-1", "kind": 30023, "created_at": float64(1700000000), "pubkey": strings.Repeat("a", 64), "content": "# Heading\n\nBody"}, nil |
| 144 | + } |
| 145 | + return nil, nil |
| 146 | +} |
| 147 | + |
| 148 | +func TestArticleDetailRendersMarkdownContent(t *testing.T) { |
| 149 | + backend := &articleDetailBackend{fakeBackend: fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}} |
| 150 | + app, err := New(backend, Options{}) |
| 151 | + if err != nil { |
| 152 | + t.Fatal(err) |
| 153 | + } |
| 154 | + recorder := httptest.NewRecorder() |
| 155 | + app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/e/article-1", nil)) |
| 156 | + body := recorder.Body.String() |
| 157 | + if recorder.Code != http.StatusOK || !strings.Contains(body, `<nostr-event id="event-article-1">`) || !strings.Contains(body, "<article><h1>Heading</h1>") { |
| 158 | + t.Fatalf("article detail did not render markdown within the shared event viewer: status=%d", recorder.Code) |
| 159 | + } |
| 160 | + if strings.Contains(body, "<pre># Heading") { |
| 161 | + t.Fatalf("article detail retained plaintext rendering: %s", body) |
| 162 | + } |
| 163 | +} |
| 164 | + |
| 165 | func TestPublicSearchAndInboxRenderBackendResults(t *testing.T) { |
| 166 | owner := strings.Repeat("a", 64) |
| 167 | backend := &publicBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}} |
| 168 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 169 | index 1b8711a..21c74f4 100644 |
| 170 | --- a/internal/webui/webui.go |
| 171 | +++ b/internal/webui/webui.go |
| 172 | @@ -556,7 +556,7 @@ func (a *App) readme(ctx context.Context, actor string, query url.Values) templa |
| 173 | if binary, _ := page["binary"].(bool); binary || content == "" { |
| 174 | continue |
| 175 | } |
| 176 | - return renderMarkdown(content) |
| 177 | + return renderRepositoryMarkdown(content, query) |
| 178 | } |
| 179 | return "" |
| 180 | } |