clone

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

add the wiki list and page routes with the merge request bar, editor, compare and history views

Dami committed d2acf55
commit
d2acf557357353282d0666d529579637a1b73ab0
parent
5a45ce4de06270a60720d83aaf3f006a88ed7b36
author
Dami <dami.etoile@gmail.com>
7 files changed, +458 -10
Minternal/webui/page.html3+
Minternal/webui/style.css22+++-
Minternal/webui/templates.go7+-
Minternal/webui/webui.go47+++++++-
Ainternal/webui/wiki.go113+++++++++++++++++
Ainternal/webui/wiki.html78++++++++++++
Ainternal/webui/wiki_test.go198++++++++++++++++++++++++++++++
M internal/webui/page.html +3 -0
M internal/webui/style.css +18 -4
@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
2020 img{max-width:100%}2121 2222 /* Selection: chrome is not selectable, content and values are. */23-#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}23+#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}2424 2525 /* Shell: a table with three columns; each column scrolls on its own. */2626 #topbar{display:none}
@@ -82,7 +82,7 @@ share-link{display:inline-block}share-link:empty{display:none}
8282 #rows th:last-child{text-align:right}8383 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}8484 #pages{font:12px var(--mono)}85-#collaboration-filter,#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}85+#collaboration-filter,#file-search,#wiki-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}8686 #collaboration-body{max-width:720px}8787 #file-actions{max-width:720px;margin:0 0 18px}8888 #events li>details{margin:0 0 10px;font:12px var(--mono)}#events li>details>summary{cursor:pointer;color:var(--muted)}
@@ -116,8 +116,8 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
116116 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}117117 nostr-compose,file-tools,file-upload,file-workspace-root{max-width:720px}118118 file-tools h3,file-upload h3{margin:0 0 4px}file-tools p,file-upload p{margin:6px 0}119-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)}120-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}119+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)}120+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}121121 file-upload label[data-share]{display:block}file-upload input[data-share-url]{width:100%}122122 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}123123 #panel file-upload [hidden],file-upload [hidden],file-tools [hidden],#panel push-toggle [hidden]{display:none}
@@ -215,6 +215,20 @@ source-view tfoot td,diff-view tfoot td{white-space:normal;color:var(--muted);fo
215215 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}216216 diff-view caption{text-align:left;font:13px var(--mono);padding:8px 12px;border-bottom:1px solid var(--line);caption-side:top}217217 218+/* Wiki: the page list, one article, the merge request bar and the editor. */219+#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)}220+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)}221+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}222+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}223+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)}224+merge-request nostr-key{color:var(--ink);font-weight:500}merge-request>a,merge-request button{margin-left:6px;font-size:12px}225+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)}226+#merge-status{font:12px var(--mono);color:var(--muted)}#wiki-compare h2{margin-top:18px}#wiki-redirects{font:12px var(--mono);color:var(--muted)}227+wiki-compose{display:block;max-width:720px;border-bottom:1px solid var(--line);padding:10px 0}228+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}229+wiki-compose label:has(textarea){display:block}wiki-compose textarea{min-height:14em}wiki-compose button{margin-right:6px}230+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)}231+218232 /* Activity grid. */219233 #grid{border-collapse:separate;border-spacing:2px;margin:0 0 8px;width:auto}220234 #grid td{width:10px;height:10px;padding:0;border:0;background:var(--g0)}
M internal/webui/templates.go +5 -2
@@ -16,7 +16,7 @@ import (
1616 // templateFS holds every page template. page.html owns the shell and the1717 // shared partials; the other files each define one group of tabs.1818 //19-//go:embed page.html public.html manage.html browse.html repo.html collaboration.html19+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html wiki.html2020 var templateFS embed.FS2121 2222 // styleCSS is inlined into every page so the UI needs no extra request and
@@ -102,7 +102,7 @@ type navItem struct {
102102 103103 // relayNav: entry points, then conversation, then collaborative artifacts,104104 // then what the relay publishes and stores, then what is yours.105-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}}105+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}}106106 107107 // manageNav: who is here, what they may do, what the relay is, what it does108108 // over time, and how it is doing.
@@ -253,6 +253,9 @@ func parseTemplates() (*template.Template, error) {
253253 "approvalViews": approvalViews,254254 "approvalCounts": approvalCounts,255255 "approvalDevices": approvalDevices,256+ "wikiHTML": wikiHTML,257+ "wikiView": wikiPageView,258+ "wikiURL": wikiURL,256259 }257260 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")258261 if err != nil {
M internal/webui/webui.go +43 -4
@@ -85,6 +85,8 @@ type PageData struct {
8585 Path string8686 Readme template.HTML8787 Tree []any88+ // Merge is the browsewikimerge detail shown by a wiki page compare view.89+ Merge any8890 // Connections lists the ways to open this relay in client apps that the8991 // viewer may see, with link placeholders already resolved.9092 Connections []any
@@ -158,7 +160,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
158160 http.Redirect(writer, request, requestPrefix(request)+"/manage/health", http.StatusMovedPermanently)159161 return160162 }161- 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") {163+ 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") {162164 a.browse(writer, request)163165 return164166 }
@@ -524,10 +526,16 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
524526 method = "browsefile"525527 case "/approvals":526528 method = "browseapprovals"529+ case "/wiki":530+ method = "browsewiki"527531 case "/manage/health":528532 method = "browsestatus"529533 }534+ if strings.HasPrefix(path, "/wiki/") {535+ method = "browsewikipage"536+ }530537 params := []json.RawMessage{}538+ pageQuery := request.URL.Query()531539 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}532540 if value := request.URL.Query().Get("limit"); value != "" {533541 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {
@@ -577,6 +585,13 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
577585 state = "all"578586 }579587 query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}588+ case "browsewiki":589+ query["author"] = request.URL.Query().Get("author")590+ case "browsewikipage":591+ // The name travels in the query as well so the page template can592+ // offer to create a page that does not exist yet.593+ pageQuery.Set("d", wikiPageName(path))594+ query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}580595 case "browsestatus":581596 query = map[string]any{}582597 }
@@ -588,10 +603,9 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
588603 }589604 result, err := a.backend.Query(request.Context(), method, params, actor)590605 if err != nil {591- a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: request.URL.Query()})606+ a.render(writer, request, PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery})592607 return593608 }594- pageQuery := request.URL.Query()595609 if method == "browserepo" && pageQuery.Get("view") == "" {596610 pageQuery.Set("view", "tree")597611 }
@@ -602,6 +616,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
602616 }603617 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery}604618 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())619+ if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {620+ raw, _ := json.Marshal(map[string]any{"id": id})621+ if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge", []json.RawMessage{raw}, actor); mergeErr == nil {622+ data.Merge = merge623+ } else {624+ data.Notice = mergeErr.Error()625+ }626+ }605627 if method == "browserepo" && pageQuery.Get("view") == "home" {606628 data.Readme = a.readme(request.Context(), actor, pageQuery)607629 }
@@ -610,7 +632,14 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
610632 }611633 612634 func browseTitle(path string, query url.Values, result any, slug string) string {613- label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals"}[path]635+ label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/wiki": "Wiki"}[path]636+ if strings.HasPrefix(path, "/wiki/") {637+ label = plainString(valueMap(result)["title"])638+ if label == "" {639+ label = query.Get("d")640+ }641+ label += " | wiki"642+ }614643 if path == "/repo" {615644 label = query.Get("repo")616645 if label == "" {
@@ -663,7 +692,12 @@ func (a *App) siblings(ctx context.Context, actor string, query url.Values) []an
663692 }664693 665694 func browseTab(path string) string {695+ if strings.HasPrefix(path, "/wiki/") {696+ return "wikipage"697+ }666698 switch path {699+ case "/wiki":700+ return "wiki"667701 case "/repos":668702 return "repos"669703 case "/repo":
@@ -1068,9 +1102,14 @@ func tabForPath(path string) string {
10681102 if strings.HasPrefix(path, "manage/") {10691103 path = strings.TrimPrefix(path, "manage/")10701104 }1105+ if strings.HasPrefix(path, "wiki/") {1106+ return "wikipage"1107+ }10711108 switch path {10721109 case "manage":10731110 return "owner"1111+ case "wiki":1112+ return "wiki"10741113 case "repos":10751114 return "repos"10761115 case "repo":
A internal/webui/wiki.go +113 -0
@@ -0,0 +1,113 @@
1+package webui2+3+// Wiki pages: the view model derived from the browsewikipage result so the4+// templates read plain fields, and the URL helpers for page names.5+6+import (7+ "html/template"8+ "net/url"9+ "strings"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/wiki"12+)13+14+// wikiView is one page as the templates see it. Version is the version15+// being shown, Number its place counted from the oldest, Others the rest.16+type wikiView struct {17+ D, Title string18+ Version map[string]any19+ Author string20+ Number, Count int21+ Forks, Links int22+ Versions []map[string]any23+ Others []map[string]any24+ Merges []map[string]any25+ OpenMerges []map[string]any26+ OpenCount int27+ RedirectsTo []map[string]any28+ RedirectsFrom []map[string]any29+}30+31+func wikiMaps(value any) []map[string]any {32+ items, _ := value.([]any)33+ maps := make([]map[string]any, 0, len(items))34+ for _, item := range items {35+ if m := valueMap(item); m != nil {36+ maps = append(maps, m)37+ }38+ }39+ return maps40+}41+42+// wikiPageView reads the browsewikipage result. Open merge requests aimed at43+// the shown version's author come first in OpenMerges; OpenCount counts every44+// open request on the page.45+func wikiPageView(result any) wikiView {46+ page := valueMap(result)47+ view := wikiView{D: plainString(page["d"]), Title: plainString(page["title"])}48+ view.Versions = wikiMaps(page["versions"])49+ view.Merges = wikiMaps(page["merges"])50+ view.RedirectsTo = wikiMaps(page["redirects_to"])51+ view.RedirectsFrom = wikiMaps(page["redirects_from"])52+ view.Count = len(view.Versions)53+ if current := valueMap(page["version"]); current != nil {54+ view.Version = current55+ view.Author = plainString(current["author"])56+ if links, ok := current["links"].([]any); ok {57+ view.Links = len(links)58+ }59+ }60+ if view.Title == "" {61+ view.Title = view.D62+ }63+ id, coordinate := plainString(view.Version["id"]), plainString(view.Version["coordinate"])64+ for i, v := range view.Versions {65+ if plainString(v["id"]) == id {66+ view.Number = view.Count - i67+ continue68+ }69+ view.Others = append(view.Others, v)70+ fork := valueMap(v["fork"])71+ if fork != nil && (plainString(fork["e"]) == id || (coordinate != "" && plainString(fork["a"]) == coordinate)) {72+ view.Forks++73+ }74+ }75+ for _, m := range view.Merges {76+ if plainString(m["status"]) != "open" {77+ continue78+ }79+ view.OpenCount++80+ if plainString(m["destination"]) == view.Author {81+ view.OpenMerges = append(view.OpenMerges, m)82+ }83+ }84+ return view85+}86+87+// wikiURL builds the page path for a name, with optional query pairs.88+func wikiURL(d string, pairs ...string) string {89+ path := "/wiki/" + url.PathEscape(d)90+ values := url.Values{}91+ for i := 0; i+1 < len(pairs); i += 2 {92+ if pairs[i+1] != "" {93+ values.Set(pairs[i], pairs[i+1])94+ }95+ }96+ if len(values) == 0 {97+ return path98+ }99+ return path + "?" + values.Encode()100+}101+102+func wikiHTML(content any) template.HTML {103+ return wiki.RenderHTML(plainString(content))104+}105+106+// wikiPageName is the name in a /wiki/<d> path, normalized.107+func wikiPageName(path string) string {108+ name, err := url.PathUnescape(strings.TrimPrefix(path, "/wiki/"))109+ if err != nil {110+ name = strings.TrimPrefix(path, "/wiki/")111+ }112+ return wiki.Normalize(name)113+}
A internal/webui/wiki.html +78 -0
@@ -0,0 +1,78 @@
1+{{define "wiki"}}<section id="wiki-list">2+<h1>Wiki</h1>3+<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>4+{{if and .Actor (.Query.Get "new")}}<h2>New page</h2>{{template "wikiEditor" .}}5+{{end}}{{if .Error}}{{template "alert" .Error}}6+{{else if .Feed}}<table id="rows">7+<thead><tr><th>page</th><th>author</th><th>versions</th><th>merge requests</th><th>updated</th></tr></thead>8+<tbody>9+{{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>10+{{end}}</tbody>11+</table>12+{{else}}<p>No pages yet.</p>13+{{end}}{{with (asMap .Event).next_cursor}}<p id="pages"><a href="{{browsePageURL "/wiki" $.Query "cursor" .}}">next page</a></p>14+{{end}}</section>15+{{end}}16+17+{{define "wikipage"}}{{$d := .Query.Get "d"}}{{if .Error}}<section id="wiki-page">18+<h1>{{$d}}</h1>19+{{if hasPrefix .Error "not found:"}}<p>There is no page named <code>{{$d}}</code> yet.</p>20+{{if .Actor}}{{template "wikiEditor" .}}{{else}}<p><a href="/signin">Sign in</a> to write it.</p>{{end}}21+{{else}}{{template "alert" .Error}}{{end}}</section>22+{{else}}{{$w := wikiView .Event}}<section id="wiki-page">23+<h1>{{$w.Title}}</h1>24+{{template "alert" .Notice}}{{if not $w.Version}}<p>This name has no article of its own.</p>25+{{template "wikiRedirects" $w}}{{if .Actor}}{{template "wikiEditor" .}}{{end}}26+{{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>27+{{end}}{{if .Query.Get "merge"}}{{template "wikiCompare" .}}28+{{else if .Query.Get "edit"}}{{template "wikiEditor" .}}29+{{else if .Query.Get "history"}}{{template "wikiHistory" $w}}30+{{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>31+{{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>32+{{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>33+{{end}}{{wikiHTML (index $w.Version "content")}}</wiki-article>34+{{template "wikiRedirects" $w}}{{end}}{{end}}</section>35+{{end}}{{end}}36+37+{{/* wikiRedirects lists the articles a name leads to. */}}38+{{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>39+{{end}}{{end}}40+41+{{/* wikiEditor publishes a version of the page in view, or a new page. */}}42+{{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")}}">43+<label>Title <input name="title" value="{{$w.Title}}" required autocomplete="off"></label>44+<label>Name <input name="name" value="{{$d}}" required autocomplete="off" spellcheck="false"></label>45+<label>Summary <input name="summary" value="{{str (index $w.Version "summary")}}" autocomplete="off"></label>46+<label>Content <textarea name="content" rows="16" placeholder="Djot markup: # heading, *emphasis*, [[Page Name]]">{{str (index $w.Version "content")}}</textarea></label>47+<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}}48+</wiki-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{end}}49+50+{{/* wikiCompare shows the proposed version of a merge request above the current one. */}}51+{{define "wikiCompare"}}{{$w := wikiView .Event}}{{$m := asMap .Merge}}{{if not $m}}<p>The merge request could not be loaded.</p>52+{{else}}{{$r := asMap (index $m "merge")}}<div id="wiki-compare">53+<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>54+{{with asMap (index $m "proposed")}}<h2>Proposed version</h2>55+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>56+{{wikiHTML (index . "content")}}</wiki-article>57+{{else}}<p>The proposed version is not available.</p>58+{{end}}{{with asMap (index $m "target")}}<h2>Current version</h2>59+<wiki-article><header>wiki | {{str (index . "d")}} | by {{template "nostrKey" (str (index . "author"))}} | {{when (index . "created_at")}}</header>60+{{wikiHTML (index . "content")}}</wiki-article>61+{{else}}<p>The destination author has no version of this page.</p>62+{{end}}<p><a href="{{wikiURL $w.D}}">back to the page</a></p></div>{{end}}{{end}}63+64+{{/* wikiHistory lists every version of a page, newest first. */}}65+{{define "wikiHistory"}}<table id="rows">66+<thead><tr><th>author</th><th>version</th><th>notes</th><th>published</th></tr></thead>67+<tbody>68+{{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>69+{{end}}</tbody>70+</table>71+<p><a href="{{wikiURL .D}}">back to the page</a></p>{{end}}72+73+{{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>74+<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>75+{{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>76+{{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>77+{{end}}{{if $w.RedirectsTo}}<section><h4>Also known as</h4><ul>{{range $w.RedirectsTo}}<li><code>{{str (index . "d")}}</code></li>{{end}}</ul></section>78+{{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}}
A internal/webui/wiki_test.go +198 -0
@@ -0,0 +1,198 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "net/http"8+ "net/http/httptest"9+ "strings"10+ "testing"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/policy"13+)14+15+const (16+ wikiOwner = "1111111111111111111111111111111111111111111111111111111111111111"17+ wikiOther = "2222222222222222222222222222222222222222222222222222222222222222"18+ wikiMerge = "3333333333333333333333333333333333333333333333333333333333333333"19+ wikiOwnerV = "4444444444444444444444444444444444444444444444444444444444444444"20+ wikiOtherV = "5555555555555555555555555555555555555555555555555555555555555555"21+)22+23+// wikiBackend answers the three wiki queries with one page that has two24+// versions and an open merge request from the second author to the owner.25+type wikiBackend struct {26+ fakeBackend27+ calls []string28+ params map[string]map[string]any29+ private bool30+}31+32+func (b *wikiBackend) Query(_ context.Context, method string, params []json.RawMessage, actor string) (any, error) {33+ b.calls = append(b.calls, method)34+ var object map[string]any35+ if len(params) == 1 {36+ _ = json.Unmarshal(params[0], &object)37+ }38+ if b.params == nil {39+ b.params = map[string]map[string]any{}40+ }41+ b.params[method] = object42+ if b.private && actor == "" {43+ return nil, errors.New("auth-required: sign in to read this relay")44+ }45+ 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),46+ "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"}}47+ 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."}48+ 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"}49+ switch method {50+ case "browsewiki":51+ 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"}, nil52+ case "browsewikipage":53+ if object["d"] != "release-notes-1-4" {54+ return nil, errors.New("not found: wiki page")55+ }56+ 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{}}, nil57+ case "browsewikimerge":58+ return map[string]any{"merge": merge, "proposed": other, "target": owner}, nil59+ }60+ return nil, errors.New("unsupported: " + method)61+}62+63+func wikiApp(t *testing.T, b *wikiBackend, actor string) *App {64+ t.Helper()65+ app, err := New(b, Options{Actor: func(*http.Request) (string, error) { return actor, nil }})66+ if err != nil {67+ t.Fatal(err)68+ }69+ return app70+}71+72+func wikiGet(t *testing.T, app *App, path string) string {73+ t.Helper()74+ recorder := httptest.NewRecorder()75+ app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil))76+ if recorder.Code != http.StatusOK {77+ t.Fatalf("%s: status=%d body=%s", path, recorder.Code, recorder.Body.String())78+ }79+ body := recorder.Body.String()80+ if strings.Contains(body, "class=") {81+ t.Fatalf("%s carries a class attribute", path)82+ }83+ if strings.Contains(body, "ยท") {84+ t.Fatalf("%s carries a middle dot", path)85+ }86+ return body87+}88+89+func wantAll(t *testing.T, path, body string, wants ...string) {90+ t.Helper()91+ for _, want := range wants {92+ if !strings.Contains(body, want) {93+ t.Errorf("%s missing %q", path, want)94+ }95+ }96+}97+98+func wantNone(t *testing.T, path, body string, wants ...string) {99+ t.Helper()100+ for _, want := range wants {101+ if strings.Contains(body, want) {102+ t.Errorf("%s unexpectedly contains %q", path, want)103+ }104+ }105+}106+107+func TestWikiListRendersPagesAndSearch(t *testing.T) {108+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}109+ app := wikiApp(t, b, wikiOwner)110+ body := wikiGet(t, app, "/wiki?q=release")111+ if b.calls[0] != "browsewiki" || b.params["browsewiki"]["q"] != "release" {112+ t.Fatalf("calls=%v params=%v", b.calls, b.params)113+ }114+ wantAll(t, "/wiki", body,115+ `href="/wiki" aria-current="page"`, `id="wiki-search"`, `value="release"`,116+ `<a href="/wiki/release-notes-1-4">Release notes 1.4 &lt;b&gt;</a>`, `<nostr-key hex="`+wikiOwner+`"`,117+ `<td>2</td><td>1 open</td>`, `2026-09-08 14:00 UTC`, `href="/wiki?cursor=release-notes-1-4&amp;q=release"`,118+ `<b>demo</b> &raquo; wiki`, `<a href="/wiki?new=1">new page</a>`)119+ wantNone(t, "/wiki", body, "<wiki-compose")120+ body = wikiGet(t, app, "/wiki?new=1")121+ 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>`)122+ wantNone(t, "/wiki?new=1", body, "Propose to")123+}124+125+func TestWikiPageRendersArticleMergeBarAndPanel(t *testing.T) {126+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}127+ path := "/wiki/Release%20Notes%201-4"128+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)129+ if b.params["browsewikipage"]["d"] != "release-notes-1-4" {130+ t.Fatalf("page name was not normalized: %v", b.params["browsewikipage"])131+ }132+ wantAll(t, path, body,133+ `<title>Release notes 1.4 &lt;b&gt; | wiki | demo</title>`, `<h1>Release notes 1.4 &lt;b&gt;</h1>`,134+ `<merge-request><nostr-key hex="`+wikiOther+`"`, `proposes a new version of this page`, `href="/wiki/release-notes-1-4?merge=`+wikiMerge+`">compare</a>`,135+ `<nostr-react event="`+wikiMerge+`" pubkey="`+wikiOther+`" kind="818"><button name="reaction" value="+">Accept</button><button name="reaction" value="-">Reject</button></nostr-react>`,136+ `<a href="/e/`+wikiMerge+`">Reply</a>`,137+ `<wiki-article><header>wiki | release-notes-1-4 | by <nostr-key hex="`+wikiOwner+`"`, `version 1 of 2 | 1 fork</header>`,138+ `<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;`,139+ `<h4>Page</h4>`, `<th>versions</th><td>2</td>`, `<th>merge requests</th><td>1 open</td>`, `<th>links</th><td>1</td>`,140+ `<h4>Other versions</h4>`, `href="/wiki/release-notes-1-4?version=`+wikiOtherV+`">222222222222</a> | fork`,141+ `<h4>Also known as</h4>`, `<code>changelog-1-4</code>`,142+ `href="/wiki/release-notes-1-4?edit=1">edit</a>`, `href="/wiki/release-notes-1-4?history=1">history</a>`,143+ `<b>demo</b> &raquo; wiki/Release Notes 1-4`)144+ wantNone(t, path, body, "<script>alert", ">fork</a>")145+ // The other author sees the request but cannot answer it, and forks146+ // instead of editing.147+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)148+ wantAll(t, path, body, `<merge-request>`, `href="/wiki/release-notes-1-4?edit=1">fork</a>`)149+ wantNone(t, path, body, "<nostr-react", ">edit</a>")150+}151+152+func TestWikiPageCompareEditorAndHistory(t *testing.T) {153+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}154+ path := "/wiki/release-notes-1-4?merge=" + wikiMerge155+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)156+ if b.params["browsewikimerge"]["id"] != wikiMerge {157+ t.Fatalf("merge lookup params=%v", b.params["browsewikimerge"])158+ }159+ 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>`)160+161+ path = "/wiki/release-notes-1-4?edit=1"162+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)163+ wantAll(t, path, body, `<wiki-compose name="release-notes-1-4" author="`+wikiOwner+`" coordinate="30818:`+wikiOwner+`:release-notes-1-4" event="`+wikiOwnerV+`">`,164+ `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>`,165+ `<button name="action" value="propose">Propose to 111111111111</button>`, `JavaScript and a connected signer are required to publish.`)166+ wantNone(t, path, body, "<wiki-article>")167+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)168+ wantNone(t, path, body, "Propose to")169+170+ path = "/wiki/release-notes-1-4?history=1"171+ body = wikiGet(t, wikiApp(t, b, wikiOwner), path)172+ wantAll(t, path, body, `<th>author</th><th>version</th>`, `fork of 444444444444`, `href="/wiki/release-notes-1-4?version=`+wikiOwnerV+`">444444444444</a>`)173+}174+175+func TestWikiMissingPageOffersTheEditorToMembers(t *testing.T) {176+ b := &wikiBackend{fakeBackend: fakeBackend{policy: policy.Defaults(wikiOwner)}}177+ path := "/wiki/Whats%20Up%3F"178+ body := wikiGet(t, wikiApp(t, b, wikiOwner), path)179+ 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>`)180+ wantNone(t, path, body, `role="alert"`)181+ body = wikiGet(t, wikiApp(t, b, ""), path)182+ wantAll(t, path, body, `<a href="/signin">Sign in</a> to write it.`)183+ wantNone(t, path, body, "<wiki-compose")184+}185+186+func TestWikiGuestAccessFollowsTheReadsPolicy(t *testing.T) {187+ members := policy.Defaults(wikiOwner)188+ members.Reads = "members"189+ b := &wikiBackend{fakeBackend: fakeBackend{policy: members}, private: true}190+ for _, path := range []string{"/wiki", "/wiki/release-notes-1-4"} {191+ body := wikiGet(t, wikiApp(t, b, ""), path)192+ wantAll(t, path, body, `<p role="alert">auth-required: sign in to read this relay</p>`, `browsing as guest`)193+ wantNone(t, path, body, "<wiki-article>", "<merge-request>", "Release notes 1.4", "<wiki-compose")194+ body = wikiGet(t, wikiApp(t, b, wikiOther), path)195+ wantAll(t, path, body, "Release notes 1.4")196+ wantNone(t, path, body, `role="alert"`)197+ }198+}