tinyrelaycommit

Commit 56c842a74b9e

browse files at this commit | history

1diff --git a/internal/webui/markdown.go b/internal/webui/markdown.go
2new file mode 100644
3index 0000000..a4dd7bf
4--- /dev/null
5+++ b/internal/webui/markdown.go
6@@ -0,0 +1,98 @@
7+package webui
8+
9+import (
10+ "html"
11+ "html/template"
12+ "regexp"
13+ "strings"
14+)
15+
16+// renderMarkdown turns a small, common subset of Markdown into HTML: ATX
17+// headings, paragraphs, fenced code, unordered and ordered lists, inline
18+// code, emphasis, and links. Every piece of text is escaped first and only
19+// the tags this function emits reach the page, so untrusted README files and
20+// relay descriptions cannot inject markup. Anything else stays plain text.
21+func renderMarkdown(source string) template.HTML {
22+ lines := strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n")
23+ var out strings.Builder
24+ var paragraph []string
25+ flush := func() {
26+ if len(paragraph) == 0 {
27+ return
28+ }
29+ out.WriteString("<p>" + inlineMarkdown(strings.Join(paragraph, " ")) + "</p>\n")
30+ paragraph = paragraph[:0]
31+ }
32+ for i := 0; i < len(lines); i++ {
33+ line := lines[i]
34+ switch {
35+ case strings.HasPrefix(line, "```"):
36+ flush()
37+ var code []string
38+ for i++; i < len(lines) && !strings.HasPrefix(lines[i], "```"); i++ {
39+ code = append(code, lines[i])
40+ }
41+ out.WriteString("<pre><code>" + html.EscapeString(strings.Join(code, "\n")) + "</code></pre>\n")
42+ case strings.HasPrefix(line, "#"):
43+ flush()
44+ level := len(line) - len(strings.TrimLeft(line, "#"))
45+ if level > 6 {
46+ level = 6
47+ }
48+ text := strings.TrimSpace(line[level:])
49+ tag := "h" + string(rune('0'+level))
50+ out.WriteString("<" + tag + ">" + inlineMarkdown(text) + "</" + tag + ">\n")
51+ case isListItem(line):
52+ flush()
53+ ordered := orderedItem.MatchString(line)
54+ tag := "ul"
55+ if ordered {
56+ tag = "ol"
57+ }
58+ out.WriteString("<" + tag + ">")
59+ for ; i < len(lines) && isListItem(lines[i]); i++ {
60+ out.WriteString("<li>" + inlineMarkdown(listText(lines[i])) + "</li>")
61+ }
62+ i--
63+ out.WriteString("</" + tag + ">\n")
64+ case strings.TrimSpace(line) == "":
65+ flush()
66+ default:
67+ paragraph = append(paragraph, strings.TrimSpace(line))
68+ }
69+ }
70+ flush()
71+ return template.HTML(out.String())
72+}
73+
74+var (
75+ orderedItem = regexp.MustCompile(`^\s*\d+[.)]\s+`)
76+ bulletItem = regexp.MustCompile(`^\s*[-*+]\s+`)
77+ codeSpan = regexp.MustCompile("`([^`]+)`")
78+ linkSpan = regexp.MustCompile(`\[([^\]]+)\]\(((?:https?://|/|\./|#|mailto:)[^)\s]*)\)`)
79+ strongSpan = regexp.MustCompile(`\*\*([^*]+)\*\*`)
80+ emSpan = regexp.MustCompile(`(^|[^*\w])\*([^*]+)\*`)
81+)
82+
83+func isListItem(line string) bool {
84+ return bulletItem.MatchString(line) || orderedItem.MatchString(line)
85+}
86+
87+func listText(line string) string {
88+ if loc := bulletItem.FindStringIndex(line); loc != nil {
89+ return line[loc[1]:]
90+ }
91+ return line[orderedItem.FindStringIndex(line)[1]:]
92+}
93+
94+// inlineMarkdown escapes text, then restores the few inline forms it
95+// recognises. Link targets are restricted to http, https, relative paths,
96+// fragments and mailto so scripts and data URLs stay text.
97+func inlineMarkdown(text string) string {
98+ escaped := html.EscapeString(text)
99+ escaped = codeSpan.ReplaceAllString(escaped, "<code>$1</code>")
100+ escaped = linkSpan.ReplaceAllString(escaped, `<a href="$2">$1</a>`)
101+ escaped = strongSpan.ReplaceAllString(escaped, "<strong>$1</strong>")
102+ escaped = emSpan.ReplaceAllString(escaped, "$1<em>$2</em>")
103+ return escaped
104+}
105diff --git a/internal/webui/markdown_test.go b/internal/webui/markdown_test.go
106new file mode 100644
107index 0000000..860ccd7
108--- /dev/null
109+++ b/internal/webui/markdown_test.go
110@@ -0,0 +1,18 @@
111+package webui
112+
113+import (
114+ "strings"
115+ "testing"
116+)
117+
118+func TestMarkdownRendersCommonFormsAndEscapesTheRest(t *testing.T) {
119+ got := string(renderMarkdown("# Title\n\nA `code` span with **bold** and *em* and [a link](https://example.com).\n\n```sh\ngo build <x>\n```\n\n- one\n- two\n\n1. first\n2. second\n\n<script>alert(1)</script> and [bad](javascript:alert(1))"))
120+ for _, want := range []string{"<h1>Title</h1>", "<code>code</code>", "<strong>bold</strong>", "<em>em</em>", `<a href="https://example.com">a link</a>`, "<pre><code>go build &lt;x&gt;</code></pre>", "<ul><li>one</li><li>two</li></ul>", "<ol><li>first</li><li>second</li></ol>", "&lt;script&gt;alert(1)&lt;/script&gt;"} {
121+ if !strings.Contains(got, want) {
122+ t.Errorf("missing %q in %s", want, got)
123+ }
124+ }
125+ if strings.Contains(got, "<script>") || strings.Contains(got, `href="javascript:`) {
126+ t.Fatalf("unsafe markup leaked: %s", got)
127+ }
128+}

About

ownerc01503e8035f
visibilitypublic
head56c842a74b9e

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs