| 1 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 2 | index 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}} |
| 22 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 23 | index 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)} |
| 76 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 77 | index 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 { |
| 108 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 109 | index 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": |
| 232 | diff --git a/internal/webui/wiki.go b/internal/webui/wiki.go |
| 233 | new file mode 100644 |
| 234 | index 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 | +} |
| 351 | diff --git a/internal/webui/wiki.html b/internal/webui/wiki.html |
| 352 | new file mode 100644 |
| 353 | index 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}} |
| 435 | diff --git a/internal/webui/wiki_test.go b/internal/webui/wiki_test.go |
| 436 | new file mode 100644 |
| 437 | index 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 <b></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&q=release"`, |
| 558 | + `<b>demo</b> » 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 <b> | wiki | demo</title>`, `<h1>Release notes 1.4 <b></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>`, `<script>alert(1)</script>`, |
| 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> » 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 <b>"`, `value="release-notes-1-4"`, `value="What changed"`, `<script>alert(1)</script></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 | +} |