tinyrelaycommit

Commit d2acf5573573

browse files at this commit | history

1diff --git a/internal/webui/page.html b/internal/webui/page.html
2index 4860704..89084c5 100644
3--- a/internal/webui/page.html
4+++ b/internal/webui/page.html
5@@ -52,6 +52,8 @@
6 {{else if eq .Tab "repos"}}{{template "repos" .}}
7 {{else if eq .Tab "repo"}}{{template "repo" .}}
8 {{else if eq .Tab "files"}}{{template "files" .}}
9+{{else if eq .Tab "wiki"}}{{template "wiki" .}}
10+{{else if eq .Tab "wikipage"}}{{template "wikipage" .}}
11 {{else if eq .Tab "file"}}{{template "file" .}}
12 {{else if eq .Tab "approvals"}}{{template "approvals" .}}
13 {{else if eq .Tab "event"}}{{template "event" .}}
14@@ -96,6 +98,7 @@
15 {{else if eq .Tab "repos"}}{{template "panel-repos" .}}
16 {{else if or (eq .Tab "files") (eq .Tab "file")}}{{template "panel-files" .}}
17 {{else if eq .Tab "approvals"}}{{template "panel-approvals" .}}
18+{{else if or (eq .Tab "wiki") (eq .Tab "wikipage")}}{{template "panel-wiki" .}}
19 {{else if eq .Tab "signin"}}{{template "panel-signin" .}}
20 {{else}}{{template "panel-relay" .}}
21 {{end}}{{end}}
22diff --git a/internal/webui/style.css b/internal/webui/style.css
23index a8c5513..52a2ee9 100644
24--- a/internal/webui/style.css
25+++ b/internal/webui/style.css
26@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
27 img{max-width:100%}
28
29 /* Selection: chrome is not selectable, content and values are. */
30-#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,diff-view caption small{user-select:none;-webkit-user-select:none}
31+#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,wiki-compose label,file-tools label,file-upload label,nostr-event>header,approval-item>header,wiki-article>header,diff-view caption small{user-select:none;-webkit-user-select:none}
32
33 /* Shell: a table with three columns; each column scrolls on its own. */
34 #topbar{display:none}
35@@ -82,7 +82,7 @@ share-link{display:inline-block}share-link:empty{display:none}
36 #rows th:last-child{text-align:right}
37 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}
38 #pages{font:12px var(--mono)}
39-#collaboration-filter,#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}
40+#collaboration-filter,#file-search,#wiki-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}
41 #collaboration-body{max-width:720px}
42 #file-actions{max-width:720px;margin:0 0 18px}
43 #events li>details{margin:0 0 10px;font:12px var(--mono)}#events li>details>summary{cursor:pointer;color:var(--muted)}
44@@ -116,8 +116,8 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
45 rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-upload,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}
46 nostr-compose,file-tools,file-upload,file-workspace-root{max-width:720px}
47 file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6px 0}
48-rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
49-rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
50+rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-upload label,#collaboration-filter label,#file-search label,#wiki-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
51+rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-upload label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input,#wiki-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
52 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}
53 file-upload label:has([type=checkbox]),#panel file-upload label:has([type=checkbox]){display:inline-block;margin:8px 14px 8px 0}file-upload label:has([type=checkbox]) input,#panel file-upload label:has([type=checkbox]) input{display:inline-block;width:auto;min-width:0;margin:0 4px 0 0;vertical-align:middle}
54 #panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none}
55@@ -215,6 +215,20 @@ source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);fo
56 diff-view ins,diff-view del{text-decoration:none}diff-view tr:has(ins){background:var(--add)}diff-view tr:has(del){background:var(--del)}diff-view b{color:var(--muted);font-weight:600}
57 diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top}
58
59+/* Wiki: the page list, one article, the merge request bar and the editor. */
60+#wiki-list #rows td:first-child{width:auto;font:14px var(--sans)}#wiki-list #rows td:nth-child(3),#wiki-list #rows td:nth-child(4){width:110px;font:12px var(--mono);color:var(--muted)}
61+wiki-article{display:block;max-width:720px}wiki-article>header{font:12px var(--mono);color:var(--muted);margin-bottom:8px}wiki-article>header nostr-key{color:var(--ink)}
62+wiki-article h1{font-size:20px;margin:18px 0 8px}wiki-article h2{font-size:17px}wiki-article h3{font-size:15px}wiki-article p{margin:0 0 10px}
63+wiki-article pre{background:var(--rail);border:1px solid var(--line);padding:10px 12px;overflow:auto;font-size:12.5px;line-height:1.5;white-space:pre}wiki-article ul,wiki-article ol{padding-left:20px;margin:0 0 10px}
64+merge-request{display:block;border:1px solid var(--line);background:var(--soft);padding:8px 10px;margin:0 0 14px;max-width:720px;font:13px var(--sans)}
65+merge-request nostr-key{color:var(--ink);font-weight:500}merge-request>a,merge-request button{margin-left:6px;font-size:12px}
66+merge-request nostr-react,merge-request nostr-react form{display:inline}merge-request nostr-react output{display:inline-block;margin-left:6px;font:12px var(--mono);color:var(--muted)}merge-request nostr-react output[data-error]{color:var(--alert)}
67+#merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}
68+wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}
69+wiki-compose label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}wiki-compose label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
70+wiki-compose label:has(textarea){display:block}wiki-compose textarea{min-height:14em}wiki-compose button{margin-right:6px}
71+wiki-compose output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}wiki-compose output[data-error]{color:var(--alert)}
72+
73 /* Activity grid. */
74 #grid{border-collapse:separate;border-spacing:2px;margin:0 0 8px;width:auto}
75 #grid td{width:10px;height:10px;padding:0;border:0;background:var(--g0)}
76diff --git a/internal/webui/templates.go b/internal/webui/templates.go
77index 2380653..661d629 100644
78--- a/internal/webui/templates.go
79+++ b/internal/webui/templates.go
80@@ -16,7 +16,7 @@ import (
81 // templateFS holds every page template. page.html owns the shell and the
82 // shared partials; the other files each define one group of tabs.
83 //
84-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html
85+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html
86 var templateFS embed.FS
87
88 // styleCSS is inlined into every page so the UI needs no extra request and
89@@ -102,7 +102,7 @@ type navItem struct {
90
91 // relayNav: entry points, then conversation, then collaborative artifacts,
92 // then what the relay publishes and stores, then what is yours.
93-var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
94+var relayNav = []navItem{{"/home", "/", "home", 0}, {"/search", "/search", "search", 0}, {"/inbox", "/inbox", "inbox", 1}, {"/approvals", "/approvals", "approvals", 1}, {"/repos", "/repos", "repos", 2}, {"/wiki", "/wiki", "wiki", 2}, {"/files", "/files", "files", 3}, {"/articles", "/articles", "articles", 3}, {"/sites", "/sites", "sites", 3}, {"/outbox", "/outbox", "outbox", 4}, {"/manage", "/manage/people", "manage", 4}}
95
96 // manageNav: who is here, what they may do, what the relay is, what it does
97 // over time, and how it is doing.
98@@ -253,6 +253,9 @@ func parseTemplates() (*template.Template, error) {
99 "approvalViews": approvalViews,
100 "approvalCounts": approvalCounts,
101 "approvalDevices": approvalDevices,
102+ "wikiHTML": wikiHTML,
103+ "wikiView": wikiPageView,
104+ "wikiURL": wikiURL,
105 }
106 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
107 if err != nil {
108diff --git a/internal/webui/webui.go b/internal/webui/webui.go
109index a10f734..14324b6 100644
110--- a/internal/webui/webui.go
111+++ b/internal/webui/webui.go
112@@ -85,6 +85,8 @@ type PageData struct {
113 Path string
114 Readme template.HTML
115 Tree []any
116+ // Merge is the browsewikimerge detail shown by a wiki page compare view.
117+ Merge any
118 // Connections lists the ways to open this relay in client apps that the
119 // viewer may see, with link placeholders already resolved.
120 Connections []any
121@@ -158,7 +160,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
122 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)
123 return
124 }
125- if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/manage/health") {
126+ if request.Method == http.MethodGet && (request.URL.Path == "/repos" || request.URL.Path == "/repo" || request.URL.Path == "/files" || request.URL.Path == "/file" || request.URL.Path == "/approvals" || request.URL.Path == "/wiki" || strings.HasPrefix(request.URL.Path, "/wiki/") || request.URL.Path == "/manage/health") {
127 a.browse(writer, request)
128 return
129 }
130@@ -524,10 +526,16 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
131 method = "browsefile"
132 case "/approvals":
133 method = "browseapprovals"
134+ case "/wiki":
135+ method = "browsewiki"
136 case "/manage/health":
137 method = "browsestatus"
138 }
139+ if strings.HasPrefix(path, "/wiki/") {
140+ method = "browsewikipage"
141+ }
142 params := []json.RawMessage{}
143+ pageQuery := request.URL.Query()
144 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}
145 if value := request.URL.Query().Get("limit"); value != "" {
146 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {
147@@ -577,6 +585,13 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
148 state = "all"
149 }
150 query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}
151+ case "browsewiki":
152+ query["author"] = request.URL.Query().Get("author")
153+ case "browsewikipage":
154+ // The name travels in the query as well so the page template can
155+ // offer to create a page that does not exist yet.
156+ pageQuery.Set("d", wikiPageName(path))
157+ query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}
158 case "browsestatus":
159 query = map[string]any{}
160 }
161@@ -588,10 +603,9 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
162 }
163 result, err := a.backend.Query(request.Context(), method, params, actor)
164 if err != nil {
165- a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})
166+ a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery})
167 return
168 }
169- pageQuery := request.URL.Query()
170 if method == "browserepo" && pageQuery.Get("view") == "" {
171 pageQuery.Set("view", "tree")
172 }
173@@ -602,6 +616,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
174 }
175 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}
176 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())
177+ if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {
178+ raw, _ := json.Marshal(map[string]any{"id": id})
179+ if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge", []json.RawMessage{raw}, actor); mergeErr == nil {
180+ data.Merge = merge
181+ } else {
182+ data.Notice = mergeErr.Error()
183+ }
184+ }
185 if method == "browserepo" && pageQuery.Get("view") == "home" {
186 data.Readme = a.readme(request.Context(), actor, pageQuery)
187 }
188@@ -610,7 +632,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
189 }
190
191 func browseTitle(path string, query url.Values, result any, slug string) string {
192- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals"}[path]
193+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki"}[path]
194+ if strings.HasPrefix(path, "/wiki/") {
195+ label = plainString(valueMap(result)["title"])
196+ if label == "" {
197+ label = query.Get("d")
198+ }
199+ label += " | wiki"
200+ }
201 if path == "/repo" {
202 label = query.Get("repo")
203 if label == "" {
204@@ -663,7 +692,12 @@ func (a *App) siblings(ctx context.Context, actor string, query url.Values) []an
205 }
206
207 func browseTab(path string) string {
208+ if strings.HasPrefix(path, "/wiki/") {
209+ return "wikipage"
210+ }
211 switch path {
212+ case "/wiki":
213+ return "wiki"
214 case "/repos":
215 return "repos"
216 case "/repo":
217@@ -1068,9 +1102,14 @@ func tabForPath(path string) string {
218 if strings.HasPrefix(path, "manage/") {
219 path = strings.TrimPrefix(path, "manage/")
220 }
221+ if strings.HasPrefix(path, "wiki/") {
222+ return "wikipage"
223+ }
224 switch path {
225 case "manage":
226 return "owner"
227+ case "wiki":
228+ return "wiki"
229 case "repos":
230 return "repos"
231 case "repo":
232diff --git a/internal/webui/wiki.go b/internal/webui/wiki.go
233new file mode 100644
234index 0000000..27f45ef
235--- /dev/null
236+++ b/internal/webui/wiki.go
237@@ -0,0 +1,113 @@
238+package webui
239+
240+// Wiki pages: the view model derived from the browsewikipage result so the
241+// templates read plain fields, and the URL helpers for page names.
242+
243+import (
244+ "html/template"
245+ "net/url"
246+ "strings"
247+
248+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"
249+)
250+
251+// wikiView is one page as the templates see it. Version is the version
252+// being shown, Number its place counted from the oldest, Others the rest.
253+type wikiView struct {
254+ D, Title string
255+ Version map[string]any
256+ Author string
257+ Number, Count int
258+ Forks, Links int
259+ Versions []map[string]any
260+ Others []map[string]any
261+ Merges []map[string]any
262+ OpenMerges []map[string]any
263+ OpenCount int
264+ RedirectsTo []map[string]any
265+ RedirectsFrom []map[string]any
266+}
267+
268+func wikiMaps(value any) []map[string]any {
269+ items, _ := value.([]any)
270+ maps := make([]map[string]any, 0, len(items))
271+ for _, item := range items {
272+ if m := valueMap(item); m != nil {
273+ maps = append(maps, m)
274+ }
275+ }
276+ return maps
277+}
278+
279+// wikiPageView reads the browsewikipage result. Open merge requests aimed at
280+// the shown version's author come first in OpenMerges; OpenCount counts every
281+// open request on the page.
282+func wikiPageView(result any) wikiView {
283+ page := valueMap(result)
284+ view := wikiView{D: plainString(page["d"]), Title: plainString(page["title"])}
285+ view.Versions = wikiMaps(page["versions"])
286+ view.Merges = wikiMaps(page["merges"])
287+ view.RedirectsTo = wikiMaps(page["redirects_to"])
288+ view.RedirectsFrom = wikiMaps(page["redirects_from"])
289+ view.Count = len(view.Versions)
290+ if current := valueMap(page["version"]); current != nil {
291+ view.Version = current
292+ view.Author = plainString(current["author"])
293+ if links, ok := current["links"].([]any); ok {
294+ view.Links = len(links)
295+ }
296+ }
297+ if view.Title == "" {
298+ view.Title = view.D
299+ }
300+ id, coordinate := plainString(view.Version["id"]), plainString(view.Version["coordinate"])
301+ for i, v := range view.Versions {
302+ if plainString(v["id"]) == id {
303+ view.Number = view.Count - i
304+ continue
305+ }
306+ view.Others = append(view.Others, v)
307+ fork := valueMap(v["fork"])
308+ if fork != nil && (plainString(fork["e"]) == id || (coordinate != "" && plainString(fork["a"]) == coordinate)) {
309+ view.Forks++
310+ }
311+ }
312+ for _, m := range view.Merges {
313+ if plainString(m["status"]) != "open" {
314+ continue
315+ }
316+ view.OpenCount++
317+ if plainString(m["destination"]) == view.Author {
318+ view.OpenMerges = append(view.OpenMerges, m)
319+ }
320+ }
321+ return view
322+}
323+
324+// wikiURL builds the page path for a name, with optional query pairs.
325+func wikiURL(d string, pairs ...string) string {
326+ path := "/wiki/" + url.PathEscape(d)
327+ values := url.Values{}
328+ for i := 0; i+1 < len(pairs); i += 2 {
329+ if pairs[i+1] != "" {
330+ values.Set(pairs[i], pairs[i+1])
331+ }
332+ }
333+ if len(values) == 0 {
334+ return path
335+ }
336+ return path + "?" + values.Encode()
337+}
338+
339+func wikiHTML(content any) template.HTML {
340+ return wiki.RenderHTML(plainString(content))
341+}
342+
343+// wikiPageName is the name in a /wiki/<d> path, normalized.
344+func wikiPageName(path string) string {
345+ name, err := url.PathUnescape(strings.TrimPrefix(path, "/wiki/"))
346+ if err != nil {
347+ name = strings.TrimPrefix(path, "/wiki/")
348+ }
349+ return wiki.Normalize(name)
350+}
351diff --git a/internal/webui/wiki.html b/internal/webui/wiki.html
352new file mode 100644
353index 0000000..fce3c02
354--- /dev/null
355+++ b/internal/webui/wiki.html
356@@ -0,0 +1,78 @@
357+{{define "wiki"}}<section id="wiki-list">
358+<h1>Wiki</h1>
359+<form method="get" action="/wiki" id="wiki-search"><label>Search <input name="q" value="{{.Query.Get "q"}}" placeholder="title or summary" autocomplete="off"></label> <button>Search</button>{{if .Query.Get "q"}} <a href="/wiki">clear</a>{{end}}</form>
360+{{if and .Actor (.Query.Get "new")}}<h2>New page</h2>{{template "wikiEditor" .}}
361+{{end}}{{if .Error}}{{template "alert" .Error}}
362+{{else if .Feed}}<table id="rows">
363+<thead><tr><th>page</th><th>author</th><th>versions</th><th>merge requests</th><th>updated</th></tr></thead>
364+<tbody>
365+{{range .Feed}}{{$p := asMap .}}{{$v := asMap (index $p "version")}}<tr><td><a href="{{wikiURL (str (index $p "d"))}}">{{str (index $p "title")}}</a></td><td>{{template "nostrKey" (str (index $v "author"))}}</td><td>{{str (index $p "versions")}}</td><td>{{str (index $p "open_merges")}} open</td><td>{{when (index $v "created_at")}}</td></tr>
366+{{end}}</tbody>
367+</table>
368+{{else}}<p>No pages yet.</p>
369+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/wiki" $.Query "cursor" .}}">next page</a></p>
370+{{end}}</section>
371+{{end}}
372+
373+{{define "wikipage"}}{{$d := .Query.Get "d"}}{{if .Error}}<section id="wiki-page">
374+<h1>{{$d}}</h1>
375+{{if hasPrefix .Error "not found:"}}<p>There is no page named <code>{{$d}}</code> yet.</p>
376+{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="/signin">Sign in</a> to write it.</p>{{end}}
377+{{else}}{{template "alert" .Error}}{{end}}</section>
378+{{else}}{{$w := wikiView .Event}}<section id="wiki-page">
379+<h1>{{$w.Title}}</h1>
380+{{template "alert" .Notice}}{{if not $w.Version}}<p>This name has no article of its own.</p>
381+{{template "wikiRedirects" $w}}{{if .Actor}}{{template "wikiEditor" .}}{{end}}
382+{{else}}{{range $m := $w.OpenMerges}}<merge-request>{{template "nostrKey" (str (index $m "author"))}} proposes a new version of this page | <a href="{{wikiURL $w.D "merge" (str (index $m "id"))}}">compare</a>{{if eq $.Actor (str (index $m "destination"))}}<nostr-react event="{{str (index $m "id")}}" pubkey="{{str (index $m "author")}}" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>{{end}}<a href="/e/{{str (index $m "id")}}">Reply</a></merge-request>
383+{{end}}{{if .Query.Get "merge"}}{{template "wikiCompare" .}}
384+{{else if .Query.Get "edit"}}{{template "wikiEditor" .}}
385+{{else if .Query.Get "history"}}{{template "wikiHistory" $w}}
386+{{else}}<wiki-article><header>wiki | {{$w.D}} | by {{template "nostrKey" $w.Author}} | version {{$w.Number}} of {{$w.Count}} | {{$w.Forks}} fork{{if ne $w.Forks 1}}s{{end}}</header>
387+{{with asMap (index $w.Version "fork")}}<p><small>forked from <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>
388+{{end}}{{with asMap (index $w.Version "defer")}}<p><small>the author defers to <a href="{{wikiURL $w.D "version" (str (index . "e"))}}">{{shortID (index . "e")}}</a></small></p>
389+{{end}}{{wikiHTML (index $w.Version "content")}}</wiki-article>
390+{{template "wikiRedirects" $w}}{{end}}{{end}}</section>
391+{{end}}{{end}}
392+
393+{{/* wikiRedirects lists the articles a name leads to. */}}
394+{{define "wikiRedirects"}}{{if .RedirectsFrom}}<p id="wiki-redirects">This name leads to {{range $i, $r := .RedirectsFrom}}{{if $i}}, {{end}}<a href="{{wikiURL (str (index $r "target_d"))}}">{{str (index $r "target_d")}}</a>{{end}}.</p>
395+{{end}}{{end}}
396+
397+{{/* wikiEditor publishes a version of the page in view, or a new page. */}}
398+{{define "wikiEditor"}}{{$w := wikiView .Event}}{{$d := or $w.D (.Query.Get "d")}}<wiki-compose name="{{$d}}" author="{{$w.Author}}" coordinate="{{str (index $w.Version "coordinate")}}" event="{{str (index $w.Version "id")}}">
399+<label>Title <input name="title" value="{{$w.Title}}" required autocomplete="off"></label>
400+<label>Name <input name="name" value="{{$d}}" required autocomplete="off" spellcheck="false"></label>
401+<label>Summary <input name="summary" value="{{str (index $w.Version "summary")}}" autocomplete="off"></label>
402+<label>Content <textarea name="content" rows="16" placeholder="Djot markup: # heading, *emphasis*, [[Page Name]]">{{str (index $w.Version "content")}}</textarea></label>
403+<button name="action" value="publish">Publish</button>{{if and $w.Author (ne $w.Author .Actor)}} <button name="action" value="propose">Propose to {{shortID $w.Author}}</button>{{end}}
404+</wiki-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{end}}
405+
406+{{/* wikiCompare shows the proposed version of a merge request above the current one. */}}
407+{{define "wikiCompare"}}{{$w := wikiView .Event}}{{$m := asMap .Merge}}{{if not $m}}<p>The merge request could not be loaded.</p>
408+{{else}}{{$r := asMap (index $m "merge")}}<div id="wiki-compare">
409+<p id="merge-status">merge request <a href="/e/{{str (index $r "id")}}">{{shortID (index $r "id")}}</a> | from {{template "nostrKey" (str (index $r "author"))}} to {{template "nostrKey" (str (index $r "destination"))}} | {{str (index $r "status")}}{{with index $r "content"}} | {{.}}{{end}}</p>
410+{{with asMap (index $m "proposed")}}<h2>Proposed version</h2>
411+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>
412+{{wikiHTML (index . "content")}}</wiki-article>
413+{{else}}<p>The proposed version is not available.</p>
414+{{end}}{{with asMap (index $m "target")}}<h2>Current version</h2>
415+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>
416+{{wikiHTML (index . "content")}}</wiki-article>
417+{{else}}<p>The destination author has no version of this page.</p>
418+{{end}}<p><a href="{{wikiURL $w.D}}">back to the page</a></p></div>{{end}}{{end}}
419+
420+{{/* wikiHistory lists every version of a page, newest first. */}}
421+{{define "wikiHistory"}}<table id="rows">
422+<thead><tr><th>author</th><th>version</th><th>notes</th><th>published</th></tr></thead>
423+<tbody>
424+{{range .Versions}}<tr><td>{{template "nostrKey" (str (index . "author"))}}</td><td><a href="{{wikiURL $.D "version" (str (index . "id"))}}">{{shortID (index . "id")}}</a></td><td>{{with asMap (index . "fork")}}fork of {{shortID (index . "e")}}{{end}}{{with asMap (index . "defer")}} defers to {{shortID (index . "e")}}{{end}}{{with index . "likes"}} | {{str .}} likes{{end}}</td><td>{{when (index . "created_at")}}</td></tr>
425+{{end}}</tbody>
426+</table>
427+<p><a href="{{wikiURL .D}}">back to the page</a></p>{{end}}
428+
429+{{define "panel-wiki"}}{{if eq .Tab "wiki"}}<section><h4>Wiki</h4><table><tr><th>protocol</th><td>NIP-54</td></tr><tr><th>markup</th><td>Djot</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr></table></section>
430+<section><h4>Actions</h4><ul>{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>
431+{{else}}{{$w := wikiView .Event}}<section><h4>Page</h4><table><tr><th>name</th><td><code>{{or $w.D (.Query.Get "d")}}</code></td></tr><tr><th>versions</th><td>{{$w.Count}}</td></tr><tr><th>merge requests</th><td>{{$w.OpenCount}} open</td></tr><tr><th>links</th><td>{{$w.Links}}</td></tr></table></section>
432+{{if $w.Others}}<section><h4>Other versions</h4><ul>{{range $w.Others}}<li><a href="{{wikiURL $w.D "version" (str (index . "id"))}}">{{shortID (index . "author")}}</a>{{with asMap (index . "fork")}} | fork{{end}} <small>{{short (index . "created_at")}}</small></li>{{end}}</ul></section>
433+{{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>
434+{{end}}<section><h4>Actions</h4><ul>{{if $w.Version}}{{if eq .Actor $w.Author}}<li><a href="{{wikiURL $w.D "edit" "1"}}">edit</a></li>{{else if .Actor}}<li><a href="{{wikiURL $w.D "edit" "1"}}">fork</a></li>{{end}}<li><a href="{{wikiURL $w.D "history" "1"}}">history</a></li>{{end}}{{if .Actor}}<li><a href="/wiki?new=1">new page</a></li>{{else}}<li><a href="/signin">sign in to write</a></li>{{end}}</ul></section>{{end}}{{end}}
435diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go
436new file mode 100644
437index 0000000..bc945ea
438--- /dev/null
439+++ b/internal/webui/wiki_test.go
440@@ -0,0 +1,198 @@
441+package webui
442+
443+import (
444+ "context"
445+ "encoding/json"
446+ "errors"
447+ "net/http"
448+ "net/http/httptest"
449+ "strings"
450+ "testing"
451+
452+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
453+)
454+
455+const (
456+ wikiOwner = "1111111111111111111111111111111111111111111111111111111111111111"
457+ wikiOther = "2222222222222222222222222222222222222222222222222222222222222222"
458+ wikiMerge = "3333333333333333333333333333333333333333333333333333333333333333"
459+ wikiOwnerV = "4444444444444444444444444444444444444444444444444444444444444444"
460+ wikiOtherV = "5555555555555555555555555555555555555555555555555555555555555555"
461+)
462+
463+// wikiBackend answers the three wiki queries with one page that has two
464+// versions and an open merge request from the second author to the owner.
465+type wikiBackend struct {
466+ fakeBackend
467+ calls []string
468+ params map[string]map[string]any
469+ private bool
470+}
471+
472+func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {
473+ b.calls = append(b.calls, method)
474+ var object map[string]any
475+ if len(params) == 1 {
476+ _ = json.Unmarshal(params[0], &object)
477+ }
478+ if b.params == nil {
479+ b.params = map[string]map[string]any{}
480+ }
481+ b.params[method] = object
482+ if b.private && actor == "" {
483+ return nil, errors.New("auth-required: sign in to read this relay")
484+ }
485+ owner := map[string]any{"id": wikiOwnerV, "author": wikiOwner, "created_at": float64(1788876000), "d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "summary": "What changed", "coordinate": "30818:" + wikiOwner + ":release-notes-1-4", "likes": float64(0),
486+ "content": "# Highlights\n\nPrivate tenants and _encrypted_ uploads. See [[Files and Private Repositories]].\n\n<script>alert(1)</script>", "links": []any{"files-and-private-repositories"}}
487+ other := map[string]any{"id": wikiOtherV, "author": wikiOther, "created_at": float64(1788880000), "d": "release-notes-1-4", "title": "Release notes 1.4", "coordinate": "30818:" + wikiOther + ":release-notes-1-4", "likes": float64(0), "fork": map[string]any{"a": owner["coordinate"], "e": wikiOwnerV}, "content": "# Highlights\n\nA proposed rewrite."}
488+ merge := map[string]any{"id": wikiMerge, "author": wikiOther, "created_at": float64(1788881000), "content": "Please take my rewrite", "target": owner["coordinate"], "target_d": "release-notes-1-4", "destination": wikiOwner, "source": wikiOtherV, "status": "open"}
489+ switch method {
490+ case "browsewiki":
491+ return map[string]any{"items": []any{map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "versions": float64(2), "open_merges": float64(1)}}, "next_cursor": "release-notes-1-4"}, nil
492+ case "browsewikipage":
493+ if object["d"] != "release-notes-1-4" {
494+ return nil, errors.New("not found: wiki page")
495+ }
496+ return map[string]any{"d": "release-notes-1-4", "title": "Release notes 1.4 <b>", "version": owner, "preferred_by": "owner", "versions": []any{other, owner}, "merges": []any{merge}, "redirects_to": []any{map[string]any{"d": "changelog-1-4", "target_d": "release-notes-1-4"}}, "redirects_from": []any{}}, nil
497+ case "browsewikimerge":
498+ return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil
499+ }
500+ return nil, errors.New("unsupported: " + method)
501+}
502+
503+func wikiApp(t *testing.T, b *wikiBackend, actor string) *App {
504+ t.Helper()
505+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})
506+ if err != nil {
507+ t.Fatal(err)
508+ }
509+ return app
510+}
511+
512+func wikiGet(t *testing.T, app *App, path string) string {
513+ t.Helper()
514+ recorder := httptest.NewRecorder()
515+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))
516+ if recorder.Code != http.StatusOK {
517+ t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())
518+ }
519+ body := recorder.Body.String()
520+ if strings.Contains(body, "class=") {
521+ t.Fatalf("%s carries a class attribute", path)
522+ }
523+ if strings.Contains(body, "ยท") {
524+ t.Fatalf("%s carries a middle dot", path)
525+ }
526+ return body
527+}
528+
529+func wantAll(t *testing.T, path, body string, wants ...string) {
530+ t.Helper()
531+ for _, want := range wants {
532+ if !strings.Contains(body, want) {
533+ t.Errorf("%s missing %q", path, want)
534+ }
535+ }
536+}
537+
538+func wantNone(t *testing.T, path, body string, wants ...string) {
539+ t.Helper()
540+ for _, want := range wants {
541+ if strings.Contains(body, want) {
542+ t.Errorf("%s unexpectedly contains %q", path, want)
543+ }
544+ }
545+}
546+
547+func TestWikiListRendersPagesAndSearch(t *testing.T) {
548+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
549+ app := wikiApp(t, b, wikiOwner)
550+ body := wikiGet(t, app, "/wiki?q=release")
551+ if b.calls[0] != "browsewiki" || b.params["browsewiki"]["q"] != "release" {
552+ t.Fatalf("calls=%v params=%v", b.calls, b.params)
553+ }
554+ wantAll(t, "/wiki", body,
555+ `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,
556+ `<a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a>`, `<nostr-key hex="`+wikiOwner+`"`,
557+ `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&amp;q=release"`,
558+ `<b>demo</b> &raquo; wiki`, `<a href="/wiki?new=1">new page</a>`)
559+ wantNone(t, "/wiki", body, "<wiki-compose")
560+ body = wikiGet(t, app, "/wiki?new=1")
561+ wantAll(t, "/wiki?new=1", body, `<h2>New page</h2>`, `<wiki-compose name="" author="" coordinate="" event="">`, `name="title"`, `name="name"`, `name="summary"`, `name="content"`, `value="publish"`, `<noscript>`)
562+ wantNone(t, "/wiki?new=1", body, "Propose to")
563+}
564+
565+func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {
566+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
567+ path := "/wiki/Release%20Notes%201-4"
568+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
569+ if b.params["browsewikipage"]["d"] != "release-notes-1-4" {
570+ t.Fatalf("page name was not normalized: %v", b.params["browsewikipage"])
571+ }
572+ wantAll(t, path, body,
573+ `<title>Release notes 1.4 &lt;b&gt; | wiki | demo</title>`, `<h1>Release notes 1.4 &lt;b&gt;</h1>`,
574+ `<merge-request><nostr-key hex="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4?merge=`+wikiMerge+`">compare</a>`,
575+ `<nostr-react event="`+wikiMerge+`" pubkey="`+wikiOther+`" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>`,
576+ `<a href="/e/`+wikiMerge+`">Reply</a>`,
577+ `<wiki-article><header>wiki | release-notes-1-4 | by <nostr-key hex="`+wikiOwner+`"`, `version 1 of 2 | 1 fork</header>`,
578+ `<h1>Highlights</h1>`, `<em>encrypted</em>`, `<a href="/wiki/files-and-private-repositories">Files and Private Repositories</a>`, `&lt;script&gt;alert(1)&lt;/script&gt;`,
579+ `<h4>Page</h4>`, `<th>versions</th><td>2</td>`, `<th>merge requests</th><td>1 open</td>`, `<th>links</th><td>1</td>`,
580+ `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,
581+ `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,
582+ `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,
583+ `<b>demo</b> &raquo; wiki/Release Notes 1-4`)
584+ wantNone(t, path, body, "<script>alert", ">fork</a>")
585+ // The other author sees the request but cannot answer it, and forks
586+ // instead of editing.
587+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)
588+ wantAll(t, path, body, `<merge-request>`, `href="/wiki/release-notes-1-4?edit=1">fork</a>`)
589+ wantNone(t, path, body, "<nostr-react", ">edit</a>")
590+}
591+
592+func TestWikiPageCompareEditorAndHistory(t *testing.T) {
593+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
594+ path := "/wiki/release-notes-1-4?merge=" + wikiMerge
595+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
596+ if b.params["browsewikimerge"]["id"] != wikiMerge {
597+ t.Fatalf("merge lookup params=%v", b.params["browsewikimerge"])
598+ }
599+ wantAll(t, path, body, `<div id="wiki-compare">`, `| open | Please take my rewrite</p>`, `<h2>Proposed version</h2>`, `A proposed rewrite.`, `<h2>Current version</h2>`, `<em>encrypted</em>`, `href="/wiki/release-notes-1-4">back to the page</a>`)
600+
601+ path = "/wiki/release-notes-1-4?edit=1"
602+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)
603+ wantAll(t, path, body, `<wiki-compose name="release-notes-1-4" author="`+wikiOwner+`" coordinate="30818:`+wikiOwner+`:release-notes-1-4" event="`+wikiOwnerV+`">`,
604+ `value="Release notes 1.4 &lt;b&gt;"`, `value="release-notes-1-4"`, `value="What changed"`, `&lt;script&gt;alert(1)&lt;/script&gt;</textarea>`,
605+ `<button name="action" value="propose">Propose to 111111111111</button>`, `JavaScript and a connected signer are required to publish.`)
606+ wantNone(t, path, body, "<wiki-article>")
607+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
608+ wantNone(t, path, body, "Propose to")
609+
610+ path = "/wiki/release-notes-1-4?history=1"
611+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)
612+ wantAll(t, path, body, `<th>author</th><th>version</th>`, `fork of 444444444444`, `href="/wiki/release-notes-1-4?version=`+wikiOwnerV+`">444444444444</a>`)
613+}
614+
615+func TestWikiMissingPageOffersTheEditorToMembers(t *testing.T) {
616+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}
617+ path := "/wiki/Whats%20Up%3F"
618+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)
619+ wantAll(t, path, body, `<h1>whats-up</h1>`, `There is no page named <code>whats-up</code> yet.`, `<wiki-compose name="whats-up"`, `<th>name</th><td><code>whats-up</code></td>`)
620+ wantNone(t, path, body, `role="alert"`)
621+ body = wikiGet(t, wikiApp(t, b, ""), path)
622+ wantAll(t, path, body, `<a href="/signin">Sign in</a> to write it.`)
623+ wantNone(t, path, body, "<wiki-compose")
624+}
625+
626+func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {
627+ members := policy.Defaults(wikiOwner)
628+ members.Reads = "members"
629+ b := &wikiBackend{fakeBackend: fakeBackend{policy: members}, private: true}
630+ for _, path := range []string{"/wiki", "/wiki/release-notes-1-4"} {
631+ body := wikiGet(t, wikiApp(t, b, ""), path)
632+ wantAll(t, path, body, `<p role="alert">auth-required: sign in to read this relay</p>`, `browsing as guest`)
633+ wantNone(t, path, body, "<wiki-article>", "<merge-request>", "Release notes 1.4", "<wiki-compose")
634+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)
635+ wantAll(t, path, body, "Release notes 1.4")
636+ wantNone(t, path, body, `role="alert"`)
637+ }
638+}

About

ownerc01503e8035f
visibilitypublic
headd2acf5573573

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs