tinyrelaycommit

Commit 66e4007b2405

browse files at this commit | history

1diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go
2new file mode 100644
3index 0000000..9e13959
4--- /dev/null
5+++ b/internal/wiki/wiki.go
6@@ -0,0 +1,398 @@
7+// Package wiki implements the NIP-54 rules that do not depend on storage:
8+// article name normalization, link discovery and a conservative Djot
9+// renderer for article content.
10+package wiki
11+
12+import (
13+ "html/template"
14+ "net/url"
15+ "strings"
16+ "unicode"
17+)
18+
19+// Normalize turns a title into the article's d tag: lowercase, whitespace as
20+// hyphens, punctuation and symbols removed, hyphens collapsed and trimmed.
21+// Letters of every script, combining marks and digits are preserved.
22+func Normalize(title string) string {
23+ var out []rune
24+ for _, r := range strings.ToLower(title) {
25+ switch {
26+ case unicode.IsSpace(r) || r == '-' || r == '_':
27+ if len(out) > 0 && out[len(out)-1] != '-' {
28+ out = append(out, '-')
29+ }
30+ case unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r):
31+ out = append(out, r)
32+ }
33+ }
34+ return strings.Trim(string(out), "-")
35+}
36+
37+// Links lists the targets referenced from Djot content in order of first
38+// appearance: normalized article names for wikilinks and reference-style
39+// links with no definition, and full nostr: URIs for NIP-21 links.
40+func Links(content string) []string {
41+ refs := definitions(content)
42+ seen := map[string]bool{}
43+ var links []string
44+ add := func(target string) {
45+ if target != "" && !seen[target] {
46+ seen[target] = true
47+ links = append(links, target)
48+ }
49+ }
50+ for _, def := range refs {
51+ if strings.HasPrefix(strings.ToLower(def), "nostr:") {
52+ add(def)
53+ }
54+ }
55+ var visit func(text string)
56+ visit = func(text string) {
57+ for _, span := range inlineSpans(text) {
58+ switch span.kind {
59+ case "em", "strong":
60+ visit(span.text)
61+ case "wikilink":
62+ add(Normalize(span.target))
63+ case "link":
64+ if strings.HasPrefix(strings.ToLower(span.target), "nostr:") {
65+ add(span.target)
66+ }
67+ case "reference":
68+ if def, ok := refs[strings.ToLower(span.target)]; ok {
69+ if strings.HasPrefix(strings.ToLower(def), "nostr:") {
70+ add(def)
71+ }
72+ } else {
73+ add(Normalize(span.target))
74+ }
75+ }
76+ }
77+ }
78+ for _, block := range blocks(content) {
79+ switch block.kind {
80+ case "pre", "def":
81+ case "ul", "ol":
82+ for _, item := range block.items {
83+ visit(item)
84+ }
85+ default:
86+ visit(block.text)
87+ }
88+ }
89+ return links
90+}
91+
92+// RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced
93+// code, bullet and numbered lists, emphasis, strong, code spans, links,
94+// [[wikilinks]] and reference-style links. Everything else is shown as
95+// escaped text. Link targets are limited to http, https, mailto, nostr and
96+// site-relative paths.
97+func RenderHTML(content string) template.HTML {
98+ refs := definitions(content)
99+ var b strings.Builder
100+ for _, block := range blocks(content) {
101+ switch block.kind {
102+ case "def":
103+ continue
104+ case "pre":
105+ b.WriteString("<pre><code")
106+ if block.lang != "" {
107+ b.WriteString(` class="language-` + template.HTMLEscapeString(block.lang) + `"`)
108+ }
109+ b.WriteString(">" + template.HTMLEscapeString(block.text) + "</code></pre>\n")
110+ case "heading":
111+ level := string(rune('0' + block.level))
112+ b.WriteString("<h" + level + ">" + renderInline(block.text, refs) + "</h" + level + ">\n")
113+ case "ul", "ol":
114+ b.WriteString("<" + block.kind + ">\n")
115+ for _, item := range block.items {
116+ b.WriteString("<li>" + renderInline(item, refs) + "</li>\n")
117+ }
118+ b.WriteString("</" + block.kind + ">\n")
119+ default:
120+ b.WriteString("<p>" + renderInline(block.text, refs) + "</p>\n")
121+ }
122+ }
123+ return template.HTML(b.String())
124+}
125+
126+type block struct {
127+ kind string
128+ level int
129+ lang string
130+ text string
131+ items []string
132+}
133+
134+// definitions collects reference link definitions, "[label]: target", keyed
135+// by their lowercase label.
136+func definitions(content string) map[string]string {
137+ refs := map[string]string{}
138+ for _, block := range blocks(content) {
139+ if block.kind == "def" {
140+ refs[strings.ToLower(block.lang)] = block.text
141+ }
142+ }
143+ return refs
144+}
145+
146+func blocks(content string) []block {
147+ lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
148+ var out []block
149+ var para []string
150+ flush := func() {
151+ if len(para) > 0 {
152+ out = append(out, block{kind: "p", text: strings.Join(para, "\n")})
153+ para = nil
154+ }
155+ }
156+ for i := 0; i < len(lines); i++ {
157+ trimmed := strings.TrimSpace(lines[i])
158+ if trimmed == "" {
159+ flush()
160+ continue
161+ }
162+ if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" {
163+ flush()
164+ lang := append(strings.Fields(trimmed[3:]), "")[0]
165+ var body []string
166+ for i++; i < len(lines); i++ {
167+ if t := strings.TrimSpace(lines[i]); len(t) >= 3 && strings.Trim(t, fence[:1]) == "" {
168+ break
169+ }
170+ body = append(body, lines[i])
171+ }
172+ out = append(out, block{kind: "pre", lang: lang, text: strings.Join(body, "\n") + "\n"})
173+ continue
174+ }
175+ if level := headingLevel(trimmed); level > 0 {
176+ flush()
177+ out = append(out, block{kind: "heading", level: level, text: strings.TrimSpace(trimmed[level:])})
178+ continue
179+ }
180+ if label, target, ok := definitionLine(trimmed); ok && len(para) == 0 {
181+ out = append(out, block{kind: "def", lang: label, text: target})
182+ continue
183+ }
184+ if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 {
185+ var items []string
186+ for ; i < len(lines); i++ {
187+ current := strings.TrimSpace(lines[i])
188+ itemKind, rest := listMarker(current)
189+ if itemKind != kind {
190+ break
191+ }
192+ items = append(items, rest)
193+ }
194+ i--
195+ out = append(out, block{kind: kind, items: items})
196+ continue
197+ }
198+ para = append(para, trimmed)
199+ }
200+ flush()
201+ return out
202+}
203+
204+func headingLevel(line string) int {
205+ level := len(line) - len(strings.TrimLeft(line, "#"))
206+ if level == 0 || level > 6 || level >= len(line) || line[level] != ' ' {
207+ return 0
208+ }
209+ return level
210+}
211+
212+func definitionLine(line string) (label, target string, ok bool) {
213+ end := strings.Index(line, "]:")
214+ if !strings.HasPrefix(line, "[") || end < 1 {
215+ return "", "", false
216+ }
217+ target = strings.TrimSpace(line[end+2:])
218+ return strings.TrimSpace(line[1:end]), target, target != "" && !strings.ContainsAny(target, " \t")
219+}
220+
221+func listMarker(line string) (kind, rest string) {
222+ if len(line) > 1 && (line[0] == '-' || line[0] == '*' || line[0] == '+') && line[1] == ' ' {
223+ return "ul", strings.TrimSpace(line[2:])
224+ }
225+ digits := len(line) - len(strings.TrimLeft(line, "0123456789"))
226+ if digits > 0 && digits+1 < len(line) && (line[digits] == '.' || line[digits] == ')') && line[digits+1] == ' ' {
227+ return "ol", strings.TrimSpace(line[digits+2:])
228+ }
229+ return "", ""
230+}
231+
232+type span struct {
233+ kind string // text, code, em, strong, link, wikilink, reference
234+ text, target string
235+}
236+
237+// inlineSpans splits paragraph text into literal runs and recognized inline
238+// elements. It never nests: the text inside a link or emphasis is rendered
239+// as further spans by the caller.
240+func inlineSpans(text string) []span {
241+ var spans []span
242+ var literal strings.Builder
243+ flush := func() {
244+ if literal.Len() > 0 {
245+ spans = append(spans, span{kind: "text", text: literal.String()})
246+ literal.Reset()
247+ }
248+ }
249+ for i := 0; i < len(text); {
250+ c := text[i]
251+ switch {
252+ case c == '\\' && i+1 < len(text):
253+ literal.WriteByte(text[i+1])
254+ i += 2
255+ continue
256+ case c == '`':
257+ run := strings.TrimLeft(text[i:], "`")
258+ marker := text[i : len(text)-len(run)]
259+ if end := strings.Index(run, marker); end >= 0 {
260+ flush()
261+ spans = append(spans, span{kind: "code", text: strings.TrimSpace(run[:end])})
262+ i += 2*len(marker) + end
263+ continue
264+ }
265+ case c == '*' || c == '_':
266+ if end := emphasisEnd(text, i); end > 0 {
267+ flush()
268+ kind := "em"
269+ if c == '*' {
270+ kind = "strong"
271+ }
272+ spans = append(spans, span{kind: kind, text: text[i+1 : end]})
273+ i = end + 1
274+ continue
275+ }
276+ case c == '[' && strings.HasPrefix(text[i:], "[["):
277+ if end := strings.Index(text[i:], "]]"); end > 2 {
278+ flush()
279+ target, label, _ := strings.Cut(text[i+2:i+end], "|")
280+ spans = append(spans, span{kind: "wikilink", target: strings.TrimSpace(target), text: strings.TrimSpace(label)})
281+ i += end + 2
282+ continue
283+ }
284+ case c == '[':
285+ if s, n := linkAt(text[i:]); n > 0 {
286+ flush()
287+ spans = append(spans, s)
288+ i += n
289+ continue
290+ }
291+ }
292+ literal.WriteByte(c)
293+ i++
294+ }
295+ flush()
296+ return spans
297+}
298+
299+// emphasisEnd finds the closing marker for Djot emphasis: the opener is not
300+// followed by whitespace and the closer is not preceded by it.
301+func emphasisEnd(text string, start int) int {
302+ marker := text[start]
303+ if start+1 >= len(text) || text[start+1] == ' ' || text[start+1] == '\n' {
304+ return 0
305+ }
306+ for i := start + 2; i < len(text); i++ {
307+ if text[i] == marker && text[i-1] != ' ' && text[i-1] != '\n' {
308+ return i
309+ }
310+ }
311+ return 0
312+}
313+
314+// linkAt recognizes [text](target), [text][label] and [text][] at the start
315+// of the string and reports how many bytes it consumed.
316+func linkAt(text string) (span, int) {
317+ depth, end := 0, -1
318+ for i := 0; i < len(text); i++ {
319+ if text[i] == '[' {
320+ depth++
321+ } else if text[i] == ']' {
322+ if depth--; depth == 0 {
323+ end = i
324+ break
325+ }
326+ }
327+ }
328+ if end <= 0 {
329+ return span{}, 0
330+ }
331+ label := text[1:end]
332+ rest := text[end+1:]
333+ if strings.HasPrefix(rest, "(") {
334+ if close := strings.IndexByte(rest, ')'); close > 0 {
335+ return span{kind: "link", text: label, target: strings.TrimSpace(rest[1:close])}, end + 2 + close
336+ }
337+ }
338+ if strings.HasPrefix(rest, "[") {
339+ if close := strings.IndexByte(rest, ']'); close > 0 {
340+ target := strings.TrimSpace(rest[1:close])
341+ if target == "" {
342+ target = label
343+ }
344+ return span{kind: "reference", text: label, target: target}, end + 2 + close
345+ }
346+ }
347+ return span{}, 0
348+}
349+
350+func renderInline(text string, refs map[string]string) string {
351+ var b strings.Builder
352+ for _, s := range inlineSpans(text) {
353+ switch s.kind {
354+ case "text":
355+ b.WriteString(template.HTMLEscapeString(s.text))
356+ case "code":
357+ b.WriteString("<code>" + template.HTMLEscapeString(s.text) + "</code>")
358+ case "em", "strong":
359+ b.WriteString("<" + s.kind + ">" + renderInline(s.text, refs) + "</" + s.kind + ">")
360+ case "wikilink":
361+ label := s.text
362+ if label == "" {
363+ label = s.target
364+ }
365+ b.WriteString(anchor("/wiki/"+url.PathEscape(Normalize(s.target)), renderInline(label, refs)))
366+ case "link":
367+ b.WriteString(anchor(safeHref(s.target), renderInline(s.text, refs)))
368+ case "reference":
369+ href := "/wiki/" + url.PathEscape(Normalize(s.target))
370+ if def, ok := refs[strings.ToLower(s.target)]; ok {
371+ href = safeHref(def)
372+ }
373+ b.WriteString(anchor(href, renderInline(s.text, refs)))
374+ }
375+ }
376+ return b.String()
377+}
378+
379+func anchor(href, inner string) string {
380+ if href == "" {
381+ return inner
382+ }
383+ return `<a href="` + template.HTMLEscapeString(href) + `">` + inner + `</a>`
384+}
385+
386+// safeHref keeps http, https, mailto and site-relative targets, routes
387+// nostr: links through the relay's open page and drops everything else.
388+func safeHref(target string) string {
389+ target = strings.TrimSpace(target)
390+ lower := strings.ToLower(target)
391+ switch {
392+ case strings.HasPrefix(lower, "nostr:"):
393+ return "/open?target=" + url.QueryEscape(target)
394+ case strings.HasPrefix(lower, "mailto:"):
395+ return target
396+ case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
397+ if u, err := url.Parse(target); err == nil && u.Host != "" {
398+ return target
399+ }
400+ case strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && !strings.HasPrefix(target, "/\\"):
401+ return target
402+ }
403+ return ""
404+}
405diff --git a/internal/wiki/wiki_test.go b/internal/wiki/wiki_test.go
406new file mode 100644
407index 0000000..24732b8
408--- /dev/null
409+++ b/internal/wiki/wiki_test.go
410@@ -0,0 +1,143 @@
411+package wiki
412+
413+import (
414+ "fmt"
415+ "strings"
416+ "testing"
417+)
418+
419+func TestNormalizeFollowsNIP54Examples(t *testing.T) {
420+ cases := map[string]string{
421+ "Wiki Article": "wiki-article",
422+ "What's Up?": "whats-up",
423+ " Hello World ": "hello-world",
424+ "Article 1": "article-1",
425+ "ウィキペディア": "ウィキペディア",
426+ "Ñoño": "ñoño",
427+ "Москва": "москва",
428+ "日本語 Article": "日本語-article",
429+ "proof-of-work": "proof-of-work",
430+ "--Lightning--": "lightning",
431+ "a -- b\tc": "a-b-c",
432+ "C++ & C#": "c-c",
433+ "e\u0301lan": "e\u0301lan",
434+ "": "",
435+ "!!!": "",
436+ "snake_case_name": "snake-case-name",
437+ "Tabs\tand\nbreaks": "tabs-and-breaks",
438+ }
439+ for input, want := range cases {
440+ if got := Normalize(input); got != want {
441+ t.Errorf("Normalize(%q) = %q, want %q", input, got, want)
442+ }
443+ }
444+}
445+
446+func TestLinksFindWikilinksReferencesAndNostrTargets(t *testing.T) {
447+ content := "Bitcoin is a [cryptocurrency][] invented by [Satoshi Nakamoto][].\n\n" +
448+ "See also: [proof of work][] and [lightning network][Lightning Network], [[Mining|the miners]] and _[Биткойн][]_.\n\n" +
449+ "- [Bob](nostr:npub1bob) wrote `[[not a link]]`\n" +
450+ "- [web](https://example.com/[[x]])\n\n" +
451+ "```\n[[ignored in code]]\n```\n\n" +
452+ "[Satoshi Nakamoto]: nostr:npub1satoshi\n"
453+ got := Links(content)
454+ want := []string{"nostr:npub1satoshi", "cryptocurrency", "proof-of-work", "lightning-network", "mining", "биткойн", "nostr:npub1bob"}
455+ if fmt.Sprint(got) != fmt.Sprint(want) {
456+ t.Fatalf("Links = %v, want %v", got, want)
457+ }
458+ if links := Links("plain text"); len(links) != 0 {
459+ t.Fatalf("plain text links = %v", links)
460+ }
461+}
462+
463+func TestRenderHTMLSupportedSyntax(t *testing.T) {
464+ content := "# Title with _emphasis_\n\n" +
465+ "A paragraph with *strong*, `code <b>` and a [link](https://example.com/a?b=1&c=2).\n" +
466+ "Second line of the paragraph.\n\n" +
467+ "## Lists\n\n" +
468+ "- one [[Wiki Page]]\n- two [[Другая|shown]]\n\n" +
469+ "1. first\n2) second\n\n" +
470+ "``` go\nfunc main() {}\n<script>alert(1)</script>\n```\n\n" +
471+ "[Bob](nostr:npub1bob) and [reference][] and [defined][site].\n\n" +
472+ "[site]: https://example.org/\n"
473+ got := string(RenderHTML(content))
474+ for _, want := range []string{
475+ "<h1>Title with <em>emphasis</em></h1>",
476+ "<p>A paragraph with <strong>strong</strong>, <code>code &lt;b&gt;</code> and a <a href=\"https://example.com/a?b=1&amp;c=2\">link</a>.\nSecond line of the paragraph.</p>",
477+ "<h2>Lists</h2>",
478+ "<ul>\n<li>one <a href=\"/wiki/wiki-page\">Wiki Page</a></li>\n<li>two <a href=\"/wiki/%D0%B4%D1%80%D1%83%D0%B3%D0%B0%D1%8F\">shown</a></li>\n</ul>",
479+ "<ol>\n<li>first</li>\n<li>second</li>\n</ol>",
480+ "<pre><code class=\"language-go\">func main() {}\n&lt;script&gt;alert(1)&lt;/script&gt;\n</code></pre>",
481+ "<a href=\"/open?target=nostr%3Anpub1bob\">Bob</a>",
482+ "<a href=\"/wiki/reference\">reference</a>",
483+ "<a href=\"https://example.org/\">defined</a>",
484+ } {
485+ if !strings.Contains(got, want) {
486+ t.Errorf("missing %q in\n%s", want, got)
487+ }
488+ }
489+ if strings.Contains(got, "[site]") {
490+ t.Errorf("definition line rendered:\n%s", got)
491+ }
492+}
493+
494+func TestRenderHTMLEscapesEverythingElse(t *testing.T) {
495+ cases := []struct{ content, forbidden, required string }{
496+ {"<script>alert(1)</script>", "<script", "&lt;script&gt;"},
497+ {"[click](javascript:alert(1))", "javascript:", "click"},
498+ {"[click](JavaScript:alert(1))", "avaScript:", "click"},
499+ {"[click](data:text/html;base64,AAAA)", "data:", "click"},
500+ {"[click](vbscript:msgbox)", "vbscript:", "click"},
501+ {"[click](//evil.example/x)", "evil.example", "click"},
502+ {"[click](/\\evil.example)", "href", "click"},
503+ {"[[Page]] [x](\"><img src=x onerror=alert(1)>)", "<img", "x&gt;"},
504+ {"[ref][]\n\n[ref]: javascript:alert(1)", "javascript:", "<p>ref</p>"},
505+ {"<a href=\"https://x\">raw html</a>", "<a href", "&lt;a href"},
506+ {"<img src=x onerror=alert(1)>", "<img", "&lt;img"},
507+ {"_<b>bold</b>_", "<b>", "<em>&lt;b&gt;bold&lt;/b&gt;</em>"},
508+ {"[[<script>]]", "<script", "/wiki/script"},
509+ {"[[x|<script>]]", "<script>", "&lt;script&gt;"},
510+ {"`<script>`", "<script>", "<code>&lt;script&gt;</code>"},
511+ {"[text](https://example.com/\"onmouseover=\"alert(1))", "onmouseover=\"", "&#34;onmouseover=&#34;"},
512+ }
513+ for _, tc := range cases {
514+ got := string(RenderHTML(tc.content))
515+ if strings.Contains(got, tc.forbidden) {
516+ t.Errorf("%q rendered %q:\n%s", tc.content, tc.forbidden, got)
517+ }
518+ if !strings.Contains(got, tc.required) {
519+ t.Errorf("%q lacks %q:\n%s", tc.content, tc.required, got)
520+ }
521+ }
522+}
523+
524+func TestRenderHTMLHandlesEdgeCases(t *testing.T) {
525+ cases := map[string]string{
526+ "": "",
527+ "\n\n\n": "",
528+ "```": "<pre><code>\n</code></pre>\n",
529+ "```\nunterminated": "<pre><code>unterminated\n</code></pre>\n",
530+ "#NoSpace": "<p>#NoSpace</p>\n",
531+ "####### seven": "<p>####### seven</p>\n",
532+ "* not strong": "<ul>\n<li>not strong</li>\n</ul>\n",
533+ "a * b * c": "<p>a * b * c</p>\n",
534+ "a_b_c": "<p>a<em>b</em>c</p>\n",
535+ "\\*escaped\\*": "<p>*escaped*</p>\n",
536+ "``code with ` inside``": "<p><code>code with ` inside</code></p>\n",
537+ "[[]]": "<p>[[]]</p>\n",
538+ "[unclosed": "<p>[unclosed</p>\n",
539+ "[text](": "<p>[text](</p>\n",
540+ "[mailto](mailto:a@b.example)": "<p><a href=\"mailto:a@b.example\">mailto</a></p>\n",
541+ "[rel](/files)": "<p><a href=\"/files\">rel</a></p>\n",
542+ "[bad](http://)": "<p>bad</p>\n",
543+ "- item\nplain": "<ul>\n<li>item</li>\n</ul>\n<p>plain</p>\n",
544+ "para\n- not a list": "<p>para\n- not a list</p>\n",
545+ "Ünïcödé & <ampersand>": "<p>Ünïcödé &amp; &lt;ampersand&gt;</p>\n",
546+ "line\r\nbreak": "<p>line\nbreak</p>\n",
547+ }
548+ for content, want := range cases {
549+ if got := string(RenderHTML(content)); got != want {
550+ t.Errorf("RenderHTML(%q) =\n%s\nwant\n%s", content, got, want)
551+ }
552+ }
553+}

About

ownerc01503e8035f
visibilitypublic
head66e4007b2405

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs