tinyrelaycommit

Commit 789c96dc188d

browse files at this commit | history

1diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go
2index 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+}
82diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go
83index 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&amp;path=docs%2Fguide.md&amp;ref=refs%2Fheads%2Fmain&amp;repo=notes&amp;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&amp;owner=alice&amp;path=docs%2Fcaf%C3%A9+guide.md&amp;ref=refs%2Fheads%2Fmain&amp;repo=notes&amp;view=file#L2"`
114+ if !strings.Contains(got, want) {
115+ t.Fatalf("encoded README link lost its resource or fragment: %s", got)
116+ }
117+}
118diff --git a/internal/webui/page.html b/internal/webui/page.html
119index 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
131diff --git a/internal/webui/public_pages_test.go b/internal/webui/public_pages_test.go
132index 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)}}
168diff --git a/internal/webui/webui.go b/internal/webui/webui.go
169index 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 }

About

ownerc01503e8035f
visibilitypublic
head789c96dc188d

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs