clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

resolve README links to file viewers and render articles as Markdown

Dami committed 789c96d
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>
5 files changed, +102 -4
Minternal/webui/markdown.go55+++++++++++++++++++++++++++++-
Minternal/webui/markdown_test.go21+++++++++++
Minternal/webui/page.html2+-
Minternal/webui/public_pages_test.go26++++++++++++++
Minternal/webui/webui.go2+-
M internal/webui/markdown.go +53 -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+}
M internal/webui/markdown_test.go +21 -0
M internal/webui/page.html +1 -1
M internal/webui/public_pages_test.go +26 -0
@@ -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)}}
M internal/webui/webui.go +1 -1