tinyrelayfile
tinyrelay/tinyclient/webui.go/

tinyclient/webui.go raw
1package tinyclient
2
3import (
4 "bytes"
5 "context"
6 _ "embed"
7 "encoding/json"
8 "fmt"
9 "html/template"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/FelineStateMachine/tinyrelay/internal/policy"
17 "github.com/FelineStateMachine/tinyrelay/internal/views"
18 "github.com/skip2/go-qrcode"
19)
20
21// signerJS is the vendored MIT licensed Nostr signer bundle used for NIP-46
22// and Nostr Connect browser flows. It contains no relay branding or secrets.
23//
24//go:embed signer.js
25var signerJS []byte
26
27// fixiJS is the vendored BSD-0 all-in-one bundle. It provides optional
28// hypermedia fragments, focus-preserving swaps, small form state helpers, SSE
29// handling, and fetch wrappers for pages that opt in with fx-* attributes.
30//
31//go:embed fixi.js
32var fixiJS []byte
33
34// Backend supplies relay identity and the method calls used by page handlers.
35// Query receives the request context, method name, JSON arguments and actor
36// public key, and returns the method's JSON-shaped result. Policy, URL, slug
37// and identity describe the relay rendered into each page.
38type Backend interface {
39 Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error)
40 Policy() Policy
41 URL() string
42 Slug() string
43 Identity() string
44}
45
46// AccountPreferencesReader provides private, account-scoped preferences to
47// server-rendered account pages. It is optional for small embedders.
48type AccountPreferencesReader interface {
49 SharePresence(context.Context, string) bool
50}
51
52// CustomViewSource is the optional backend summary of the custom views the
53// renderers show in place of fenced code blocks: names and languages only.
54type CustomViewSource interface {
55 CustomViews() []CustomView
56}
57
58// ActorResolver returns the authenticated public key associated with request.
59// An empty key represents an anonymous browser request.
60type ActorResolver func(request *http.Request) (string, error)
61
62// QRProvider encodes text as an image for pages that show a connection QR.
63type QRProvider func(text string) ([]byte, error)
64
65type App struct {
66 backend Backend
67 actor ActorResolver
68 qr QRProvider
69 tmpl *template.Template
70 version string
71 revision string
72}
73
74type Options struct {
75 Actor ActorResolver
76 QR QRProvider
77 // Version and Revision identify the relay build on the health page.
78 Version, Revision string
79}
80
81type PageData struct {
82 Activity chatActivityPage
83 SharePresence bool
84 Version string
85 Revision string
86 Title string
87 URL string
88 Slug string
89 Identity string
90 Policy policy.Policy
91 Actor string
92 Owner bool
93 Tab string
94 Notice string
95 Error string
96 Methods []string
97 Event any
98 Invite string
99 View string
100 Feed []any
101 Base string
102 Query url.Values
103 Private bool
104 Path string
105 Readme template.HTML
106 Tree []any
107 // Merge is the browsewikimerge detail shown by a wiki page compare view.
108 Merge any
109 // Connections lists the ways to open this relay in client apps that the
110 // viewer may see, with link placeholders already resolved.
111 Connections []any
112 // Rooms lists the rooms the viewer may see; the rooms rail renders it.
113 Rooms []any
114 // Callbacks lists the event callbacks shown beside each agent.
115 Callbacks []any
116 // CustomViews lists the owner's custom views on the Views page.
117 CustomViews []any
118 // Requests lists the access requests on the People page, pending first;
119 // Review is true when the viewer may decide them.
120 Requests []any
121 Review bool
122}
123
124func New(backend Backend, options Options) (*App, error) {
125 if backend == nil {
126 return nil, fmt.Errorf("tinyclient backend is required")
127 }
128 qr := options.QR
129 if qr == nil {
130 qr = defaultQR
131 }
132 a := &App{backend: backend, actor: options.Actor, qr: qr, version: options.Version, revision: options.Revision}
133 tmpl, err := parseTemplates(a)
134 if err != nil {
135 return nil, err
136 }
137 a.tmpl = tmpl
138 return a, nil
139}
140
141// blocks is the block renderer for the custom views the backend reports,
142// or nil when there are none so fenced blocks stay code.
143func (a *App) blocks() blockRenderer {
144 source, ok := a.backend.(CustomViewSource)
145 if !ok {
146 return nil
147 }
148 renderer := views.NewRenderer(source.CustomViews())
149 if len(renderer.Languages) == 0 {
150 return nil
151 }
152 return renderer.Block
153}
154
155func (a *App) markdown(source string) template.HTML {
156 return renderMarkdownWith(source, a.blocks())
157}
158
159func (a *App) chatMarkdown(source string) template.HTML {
160 return renderChatMarkdownWith(source, a.blocks())
161}
162
163func defaultQR(text string) ([]byte, error) {
164 code, err := qrcode.New(text, qrcode.Medium)
165 if err != nil {
166 return nil, fmt.Errorf("encode QR: %w", err)
167 }
168 bitmap := code.Bitmap()
169 var out strings.Builder
170 size := len(bitmap)
171 fmt.Fprintf(&out, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" shape-rendering="crispEdges"><rect width="100%%" height="100%%" fill="white"/>`, size, size)
172 for y, row := range bitmap {
173 for x, dark := range row {
174 if dark {
175 fmt.Fprintf(&out, `<rect x="%d" y="%d" width="1" height="1"/>`, x, y)
176 }
177 }
178 }
179 out.WriteString(`</svg>`)
180 return []byte(out.String()), nil
181}
182
183func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
184 if request.URL.Path == BackendPath {
185 a.BackendHandler().ServeHTTP(writer, request)
186 return
187 }
188 if strings.HasPrefix(request.URL.Path, "/avatars/") {
189 serveAvatar(writer, request)
190 return
191 }
192 if a.denyPrivateEndpoint(writer, request) {
193 return
194 }
195 if a.handleChatRoute(writer, request) || a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {
196 return
197 }
198 if a.handleSocialRoute(writer, request) {
199 return
200 }
201 if request.Method != http.MethodGet && request.Method != http.MethodHead {
202 http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
203 return
204 }
205 if request.URL.Path == "/card.json" {
206 a.card(writer)
207 return
208 }
209 if request.URL.Path == "/people" {
210 a.peopleJSON(writer, request)
211 return
212 }
213 if request.URL.Path == "/connect.json" {
214 a.connectJSON(writer, request)
215 return
216 }
217 if strings.HasPrefix(request.URL.Path, "/e/") || strings.HasPrefix(request.URL.Path, "/a/") {
218 if !a.pageFeatureEnabled(request.URL.Path) {
219 http.NotFound(writer, request)
220 return
221 }
222 a.eventPage(writer, request)
223 return
224 }
225 if strings.HasPrefix(request.URL.Path, "/view/") {
226 a.viewPage(writer, request)
227 return
228 }
229 if strings.HasPrefix(request.URL.Path, "/invite/") {
230 a.invitePage(writer, request)
231 return
232 }
233 if serveEmbeddedAsset(writer, request, request.URL.Path) {
234 return
235 }
236 if request.URL.Path == "/open" {
237 a.openLink(writer, request)
238 return
239 }
240 if request.URL.Path == "/manifest.webmanifest" {
241 a.manifest(writer, request)
242 return
243 }
244 if request.URL.Path == "/qr.svg" {
245 a.qrImage(writer, request.URL.Query().Get("text"))
246 return
247 }
248 if request.URL.Path == "/card.svg" {
249 writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")
250 _, _ = fmt.Fprintf(writer, `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 180"><rect width="420" height="180" fill="white"/><text x="20" y="70" font-family="sans-serif" font-size="28">%s</text><text x="20" y="112" font-family="monospace" font-size="14">%s</text></svg>`, template.HTMLEscapeString(a.backend.Policy().Name), template.HTMLEscapeString(a.backend.URL()))
251 return
252 }
253 if request.URL.Path == "/card.nostr" {
254 writer.Header().Set("content-type", "text/plain; charset=utf-8")
255 _, _ = fmt.Fprintf(writer, "nostr:%s", identityNpub(a.backend.Identity()))
256 return
257 }
258 if request.URL.Path == "/articles.json" {
259 if !a.pageFeatureEnabled(request.URL.Path) {
260 http.NotFound(writer, request)
261 return
262 }
263 a.articlesJSON(writer, request)
264 return
265 }
266 if request.URL.Path == "/feed" || request.URL.Path == "/feed.xml" {
267 if !a.pageFeatureEnabled(request.URL.Path) {
268 http.NotFound(writer, request)
269 return
270 }
271 a.feed(writer, request)
272 return
273 }
274 a.page(writer, request)
275}
276
277func (a *App) jobStatus(writer http.ResponseWriter, request *http.Request) {
278 actor, err := a.resolveActor(request)
279 if err != nil || actor == "" {
280 http.Error(writer, "authentication required", http.StatusUnauthorized)
281 return
282 }
283 writer.Header().Set("content-type", "text/event-stream; charset=utf-8")
284 writer.Header().Set("cache-control", "no-cache")
285 writer.Header().Set("x-content-type-options", "nosniff")
286 flusher, ok := writer.(http.Flusher)
287 if !ok {
288 http.Error(writer, "streaming is unavailable", http.StatusNotImplemented)
289 return
290 }
291 var previous string
292 writeUpdate := func() bool {
293 currentActor, actorErr := a.resolveActor(request)
294 if actorErr != nil || currentActor == "" || currentActor != actor {
295 return false
296 }
297 result, queryErr := a.backend.Query(request.Context(), "listjobs", nil, currentActor)
298 fragment := jobsFragment(result, queryErr)
299 if fragment == previous {
300 return true
301 }
302 previous = fragment
303 _, writeErr := fmt.Fprintf(writer, "data: %s\n\n", fragment)
304 flusher.Flush()
305 return writeErr == nil
306 }
307 if !writeUpdate() {
308 return
309 }
310 ticker := time.NewTicker(2 * time.Second)
311 defer ticker.Stop()
312 for {
313 select {
314 case <-request.Context().Done():
315 return
316 case <-ticker.C:
317 if !writeUpdate() {
318 return
319 }
320 }
321 }
322}
323
324func (a *App) joinPolicy(writer http.ResponseWriter) {
325 writeJSON(writer, http.StatusOK, map[string]string{"terms": a.backend.Policy().JoinTerms})
326}
327
328func (a *App) claimInvite(writer http.ResponseWriter, request *http.Request) {
329 actor, err := a.resolveActor(request)
330 if err != nil || actor == "" {
331 writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
332 return
333 }
334 var code, termsHash string
335 if strings.HasPrefix(request.Header.Get("content-type"), "application/json") {
336 var body struct {
337 Params []json.RawMessage `json:"params"`
338 }
339 if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
340 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "invalid JSON request"})
341 return
342 }
343 if len(body.Params) > 0 {
344 _ = json.Unmarshal(body.Params[0], &code)
345 }
346 if len(body.Params) > 1 {
347 _ = json.Unmarshal(body.Params[1], &termsHash)
348 }
349 } else if err := request.ParseForm(); err != nil {
350 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "invalid form"})
351 return
352 } else {
353 code = request.FormValue("code")
354 }
355 if code == "" {
356 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "invite code is required"})
357 return
358 }
359 params := []json.RawMessage{json.RawMessage(fmt.Sprintf("%q", code))}
360 if termsHash != "" {
361 params = append(params, json.RawMessage(fmt.Sprintf("%q", termsHash)))
362 }
363 result, err := a.backend.Query(request.Context(), "claiminvite", params, actor)
364 if err != nil {
365 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()})
366 return
367 }
368 writeJSON(writer, http.StatusOK, map[string]any{"result": result})
369}
370
371func (a *App) peopleJSON(writer http.ResponseWriter, request *http.Request) {
372 actor, err := a.resolveActor(request)
373 if err != nil {
374 writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": err.Error()})
375 return
376 }
377 result, err := a.backend.Query(request.Context(), "listpeople", nil, actor)
378 if err != nil {
379 writeJSON(writer, http.StatusForbidden, map[string]string{"error": err.Error()})
380 return
381 }
382 writeJSON(writer, http.StatusOK, result)
383}
384
385func (a *App) connectJSON(writer http.ResponseWriter, request *http.Request) {
386 actor, actorErr := a.resolveActor(request)
387 if actorErr != nil || actor == "" {
388 writeJSON(writer, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
389 return
390 }
391 result, err := a.backend.Query(request.Context(), "listconnections", nil, actor)
392 if err != nil {
393 writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})
394 return
395 }
396 if request.URL.Query().Get("catalog") == "" {
397 writeJSON(writer, http.StatusOK, result)
398 return
399 }
400 // The Connect page reads the list and the card catalog with its session
401 // so showing the page never asks the signer for a signature.
402 catalog, err := a.backend.Query(request.Context(), "listconnectiontemplates", nil, actor)
403 if err != nil {
404 writeJSON(writer, http.StatusNotFound, map[string]string{"error": err.Error()})
405 return
406 }
407 writeJSON(writer, http.StatusOK, map[string]any{"connections": result, "catalog": catalog})
408}
409
410func (a *App) eventPage(writer http.ResponseWriter, request *http.Request) {
411 actor, actorErr := a.resolveActor(request)
412 if actorErr != nil {
413 http.Error(writer, "authentication required", http.StatusUnauthorized)
414 return
415 }
416 identifier := strings.TrimPrefix(request.URL.Path, "/e/")
417 address := strings.HasPrefix(request.URL.Path, "/a/")
418 if address {
419 identifier = strings.TrimPrefix(request.URL.Path, "/a/")
420 }
421 if a.socialReferencePage(writer, request, identifier, address, actor) {
422 return
423 }
424 method := "eventdetail"
425 params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q`, identifier))}
426 if address {
427 method = "queryevents"
428 params = []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"#d":[%q],"limit":1}`, identifier))}
429 }
430 result, err := a.backend.Query(request.Context(), method, params, actor)
431 if err != nil {
432 http.NotFound(writer, request)
433 return
434 }
435 data := PageData{Event: result, View: identifier, Tab: "event"}
436 data.Title = "Event " + shortID(identifier) + " | " + a.backend.Slug()
437 if address {
438 data.Feed = browseRows(result)
439 }
440 a.render(writer, request, data)
441}
442
443func (a *App) viewPage(writer http.ResponseWriter, request *http.Request) {
444 name := strings.TrimPrefix(request.URL.Path, "/view/")
445 writer.Header().Set("Cache-Control", "private, no-store")
446 actor, err := a.resolveActor(request)
447 if err != nil {
448 http.Error(writer, err.Error(), http.StatusUnauthorized)
449 return
450 }
451 result, err := a.backend.Query(request.Context(), "listview", []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q`, name))}, actor)
452 if err != nil {
453 status := http.StatusForbidden
454 if strings.HasPrefix(err.Error(), "auth-required:") {
455 status = http.StatusUnauthorized
456 }
457 if strings.HasPrefix(err.Error(), "not found:") {
458 status = http.StatusNotFound
459 }
460 writeJSON(writer, status, map[string]string{"error": err.Error()})
461 return
462 }
463 writeJSON(writer, http.StatusOK, result)
464}
465
466func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
467 actor, actorErr := a.resolveActor(request)
468 if actorErr != nil {
469 http.Error(writer, "authentication required", http.StatusUnauthorized)
470 return
471 }
472 path, method := request.URL.Path, ""
473 switch path {
474 case "/repos":
475 method = "browserepos"
476 case "/repo":
477 method = "browserepo"
478 case "/files":
479 method = "browsefiles"
480 case "/file":
481 method = "browsefile"
482 case "/approvals":
483 method = "browseapprovals"
484 case "/profile":
485 method = "browseprofile"
486 case "/wiki":
487 method = "browsewiki"
488 case "/manage/health":
489 method = "browsestatus"
490 }
491 room := roomRoute(path)
492 switch room.tab {
493 case "rooms":
494 method = "browserooms"
495 case "room":
496 method = "browseroom"
497 case "thread":
498 method = "browsethread"
499 }
500 if strings.HasPrefix(path, "/wiki/") {
501 method = "browsewikipage"
502 }
503 params := []json.RawMessage{}
504 pageQuery := request.URL.Query()
505 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}
506 if value := request.URL.Query().Get("limit"); value != "" {
507 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {
508 query["limit"] = parsed
509 }
510 }
511 switch method {
512 case "browsefiles":
513 view := request.URL.Query().Get("view")
514 if view == "" {
515 view = "library"
516 }
517 query["view"] = view
518 query["path"] = request.URL.Query().Get("path")
519 case "browserepo":
520 view := request.URL.Query().Get("view")
521 if view == "" || view == "home" {
522 view = "tree"
523 }
524 offset := 0
525 if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset")); parseErr == nil && parsed >= 0 {
526 offset = parsed
527 }
528 backendView := view
529 if view == "issues" {
530 method = "browseissues"
531 backendView = ""
532 }
533 if view == "prs" {
534 method = "browsepulls"
535 backendView = ""
536 }
537 if view == "issue" {
538 method = "browseissue"
539 backendView = ""
540 }
541 if view == "pr" {
542 method = "browsepull"
543 backendView = ""
544 }
545 eventID := request.URL.Query().Get("id")
546 if eventID == "" && (view == "issue" || view == "pr") {
547 eventID = request.URL.Query().Get("path")
548 }
549 query = map[string]any{"owner": request.URL.Query().Get("owner"), "repo": request.URL.Query().Get("repo"), "event": eventID, "q": request.URL.Query().Get("q"), "state": request.URL.Query().Get("status"), "label": request.URL.Query().Get("label"), "cursor": request.URL.Query().Get("cursor"), "ref": request.URL.Query().Get("ref"), "path": request.URL.Query().Get("path"), "view": backendView, "offset": offset, "limit": 100}
550 case "browsefile":
551 query = map[string]any{"hash": request.URL.Query().Get("hash")}
552 if query["hash"] == "" {
553 query["hash"] = request.URL.Query().Get("sha")
554 }
555 case "browseprofile":
556 query = map[string]any{"pubkey": actor}
557 case "browseapprovals":
558 state := request.URL.Query().Get("state")
559 if state == "" {
560 state = "all"
561 }
562 query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}
563 case "browsewiki":
564 query["author"] = request.URL.Query().Get("author")
565 case "browsewikipage":
566 // The name travels in the query as well so the page template can
567 // offer to create a page that does not exist yet.
568 pageQuery.Set("d", wikiPageName(path))
569 query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}
570 case "browsestatus":
571 query = map[string]any{}
572 case "browserooms":
573 query = map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 100}
574 case "browseroom", "browsethread":
575 query = map[string]any{"id": room.id, "event": room.event, "cursor": request.URL.Query().Get("cursor"), "limit": 50}
576 }
577 raw, _ := json.Marshal(query)
578 params = append(params, raw)
579 var tree []any
580 if method == "browserepo" && request.URL.Query().Get("view") == "file" {
581 tree = a.siblings(request.Context(), actor, request.URL.Query())
582 }
583 result, err, typedRoom := a.typedRoomResult(request.Context(), room, pageQuery, actor)
584 if !typedRoom {
585 result, err = a.backend.Query(request.Context(), method, params, actor)
586 }
587 if err != nil {
588 data := PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery, View: room.id}
589 if room.tab != "" {
590 data.Rooms = a.roomList(request.Context(), actor)
591 }
592 a.render(writer, request, data)
593 return
594 }
595 if method == "browserepo" && pageQuery.Get("view") == "" {
596 pageQuery.Set("view", "tree")
597 }
598 if method == "browseapprovals" && pageQuery.Get("id") != "" {
599 // A notification opens one request; show it even when it has left
600 // the first page.
601 result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))
602 }
603 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id}
604 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())
605 if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {
606 raw, _ := json.Marshal(map[string]any{"id": id})
607 if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge", []json.RawMessage{raw}, actor); mergeErr == nil {
608 data.Merge = merge
609 } else {
610 data.Notice = mergeErr.Error()
611 }
612 }
613 switch room.tab {
614 case "rooms":
615 data.Rooms = data.Feed
616 case "room", "thread":
617 data.Rooms = a.roomList(request.Context(), actor)
618 data.Activity = a.chatActivity(request.Context(), actor, room.id, plainString(valueMap(valueMap(result)["root"])["id"]))
619 }
620 if method == "browserepo" && pageQuery.Get("view") == "home" {
621 data.Readme = a.readme(request.Context(), actor, pageQuery)
622 }
623 data.Tree = tree
624 a.render(writer, request, data)
625}
626
627func browseTitle(path string, query url.Values, result any, slug string) string {
628 label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/profile": "Profile", "/wiki": "Wiki", "/rooms": "Rooms"}[path]
629 if strings.HasPrefix(path, "/wiki/") {
630 label = plainString(valueMap(result)["title"])
631 if label == "" {
632 label = query.Get("d")
633 }
634 label += " | wiki"
635 }
636 if room := roomRoute(path); room.tab == "room" || room.tab == "thread" {
637 label = "#" + room.id
638 if room.tab == "thread" {
639 label += " | thread"
640 }
641 }
642 if path == "/repo" {
643 label = query.Get("repo")
644 if label == "" {
645 label = plainString(valueMap(result)["identifier"])
646 }
647 if label == "" {
648 label = "Repository"
649 }
650 label += " | " + repoView(query)
651 }
652 if label == "" {
653 label = "Browse"
654 }
655 return label + " | " + slug
656}
657
658// readme renders README.md from the requested ref for the repository entry
659// page. A missing or binary README simply yields no content.
660func (a *App) readme(ctx context.Context, actor string, query url.Values) template.HTML {
661 for _, name := range []string{"README.md", "readme.md", "README"} {
662 raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": name, "view": "file", "limit": 1})
663 result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)
664 if err != nil {
665 continue
666 }
667 page := valueMap(result)
668 content, _ := page["content"].(string)
669 if binary, _ := page["binary"].(bool); binary || content == "" {
670 continue
671 }
672 return renderRepositoryMarkdownWith(content, query, a.blocks())
673 }
674 return ""
675}
676
677// siblings lists the directory that holds the file being previewed so the
678// tree pane stays populated beside the file.
679func (a *App) siblings(ctx context.Context, actor string, query url.Values) []any {
680 dir := ""
681 if slash := strings.LastIndex(query.Get("path"), "/"); slash >= 0 {
682 dir = query.Get("path")[:slash]
683 }
684 raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": dir, "view": "tree", "limit": 100})
685 result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)
686 if err != nil {
687 return nil
688 }
689 entries, _ := valueMap(result)["entries"].([]any)
690 return entries
691}
692
693func browseTab(path string) string {
694 if strings.HasPrefix(path, "/wiki/") {
695 return "wikipage"
696 }
697 if room := roomRoute(path); room.tab != "" {
698 return room.tab
699 }
700 switch path {
701 case "/wiki":
702 return "wiki"
703 case "/repos":
704 return "repos"
705 case "/repo":
706 return "repo"
707 case "/files":
708 return "files"
709 case "/file":
710 return "file"
711 case "/approvals":
712 return "approvals"
713 case "/profile":
714 return "profile"
715 default:
716 return "health"
717 }
718}
719func browseRows(value any) []any {
720 encoded, err := json.Marshal(value)
721 if err != nil {
722 return nil
723 }
724 var rows []any
725 if json.Unmarshal(encoded, &rows) == nil {
726 return rows
727 }
728 var object map[string]any
729 if json.Unmarshal(encoded, &object) == nil {
730 if items, ok := object["items"].([]any); ok {
731 return items
732 }
733 }
734 return []any{value}
735}
736
737func (a *App) invitePage(writer http.ResponseWriter, request *http.Request) {
738 code := strings.TrimPrefix(request.URL.Path, "/invite/")
739 a.render(writer, request, PageData{Invite: code, Tab: "invite"})
740}
741
742// render fills the shared page data, applies private relay masking, and
743// writes the page. Every HTML response goes through here.
744func (a *App) render(writer http.ResponseWriter, request *http.Request, data PageData) {
745 actor, _ := a.resolveActor(request)
746 if data.Title == "" {
747 data.Title = a.backend.Slug()
748 }
749 data.URL, data.Slug, data.Identity, data.Policy, data.Actor, data.Base = a.backend.URL(), a.backend.Slug(), a.backend.Identity(), a.backend.Policy(), actor, requestPrefix(request)
750 data.Owner = actor != "" && actor == data.Policy.Owner
751 if actor != "" && data.Tab == "account" {
752 if preferences, ok := a.backend.(AccountPreferencesReader); ok {
753 data.SharePresence = preferences.SharePresence(request.Context(), actor)
754 }
755 }
756 data.Version, data.Revision = a.version, a.revision
757 data.Methods = supportedMethods
758 if data.Query == nil {
759 data.Query = request.URL.Query()
760 }
761 data.Path = strings.TrimPrefix(request.URL.Path, data.Base)
762 a.privatePageData(&data, request, actor)
763 if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile" || data.Tab == "account") {
764 // Management pages and the profile editor are for signed-in people
765 // only. Guests see the sign-in page at the same address and come back
766 // after signing in.
767 data.Tab, data.Notice = "signin", "Sign in to manage this relay."
768 if data.Path == "/profile" {
769 data.Notice = "Sign in to edit your profile."
770 } else if data.Path == "/account" {
771 data.Notice = "Sign in to open your account."
772 }
773 data.Event, data.Feed, data.Error = nil, nil, ""
774 }
775 writer.Header().Set("Cache-Control", "private, no-store")
776 writer.Header().Set("content-type", "text/html; charset=utf-8")
777 var rendered bytes.Buffer
778 if err := a.tmpl.ExecuteTemplate(&rendered, "page", data); err != nil {
779 http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)
780 return
781 }
782 _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base)))
783}
784
785// manifest describes the installable app for this relay and tenant prefix.
786func (a *App) manifest(writer http.ResponseWriter, request *http.Request) {
787 name := a.backend.Policy().Name
788 if name == "" || privatePolicyEnabled(a.backend.Policy()) {
789 name = a.backend.Slug()
790 }
791 encoded, _ := json.Marshal(name)
792 body := strings.ReplaceAll(manifestTemplate, `"NAME"`, string(encoded))
793 body = strings.ReplaceAll(body, "BASE/", requestPrefix(request)+"/")
794 // A manifest carries one set of launch colours. The page links the
795 // variant that matches the active theme so the installed app's splash
796 // screen and status bar do not flash light on a dark phone.
797 theme, background := "#f4f2ec", "#fbfaf7"
798 if request.URL.Query().Get("theme") == "dark" {
799 theme, background = "#1e1d1a", "#171614"
800 }
801 body = strings.ReplaceAll(body, "THEME", theme)
802 body = strings.ReplaceAll(body, "BACKGROUND", background)
803 writer.Header().Set("content-type", "application/manifest+json; charset=utf-8")
804 _, _ = writer.Write([]byte(body))
805}
806
807func (a *App) qrImage(writer http.ResponseWriter, text string) {
808 if a.qr == nil || text == "" {
809 http.Error(writer, "QR generation is not configured", http.StatusNotImplemented)
810 return
811 }
812 image, err := a.qr(text)
813 if err != nil {
814 http.Error(writer, "QR generation failed", http.StatusInternalServerError)
815 return
816 }
817 writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")
818 _, _ = writer.Write(image)
819}
820
821func (a *App) page(writer http.ResponseWriter, request *http.Request) {
822 tab := tabForPath(request.URL.Path)
823 actor, err := a.resolveActor(request)
824 if err != nil {
825 actor = ""
826 }
827 data := PageData{Tab: tab, Query: request.URL.Query()}
828 if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" || tab == "sites" {
829 feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query())
830 if feedErr != nil {
831 data.Error = feedErr.Error()
832 } else if tab == "sites" {
833 data.Feed = a.siteRows(feed)
834 } else {
835 data.Feed = feed
836 }
837 }
838 if tab == "home" {
839 if result, err := a.backend.Query(request.Context(), "connections", nil, actor); err == nil {
840 data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})
841 }
842 }
843 if tab == "agents" && actor != "" {
844 a.agentsPage(request, actor, &data)
845 }
846 if tab == "people" && actor != "" {
847 a.peoplePage(request, actor, &data)
848 }
849 if tab == "views" && actor != "" {
850 // The listing is owner-only; anyone else keeps the built-in controls.
851 if result, err := a.backend.Query(request.Context(), "listcustomviews", nil, actor); err == nil {
852 data.CustomViews = browseRows(result)
853 }
854 }
855 if tab != "" {
856 data.Title = tab + " | " + a.backend.Slug()
857 }
858 a.render(writer, request, data)
859}
860
861// agentsPage loads the agent list for the table and cards, then the grant
862// and recent events of one agent: the one named by ?agent=, else the first.
863// Both queries are gated for the owner and moderators by the backend.
864func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {
865 result, err := a.backend.Query(request.Context(), "listagents", nil, actor)
866 if err != nil {
867 data.Error = err.Error()
868 return
869 }
870 data.Feed = browseRows(result)
871 // Callbacks are shown per agent; a failure here leaves the row empty
872 // rather than hiding the agents.
873 if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil {
874 data.Callbacks = browseRows(callbacks)
875 }
876 selected := request.URL.Query().Get("agent")
877 if selected == "" {
878 // Without a choice, show the first agent that can still publish.
879 for _, agent := range data.Feed {
880 if selected == "" || agentState(agent) == "active" {
881 selected = plainString(valueMap(agent)["pubkey"])
882 }
883 if agentState(agent) == "active" {
884 break
885 }
886 }
887 }
888 if selected == "" {
889 return
890 }
891 raw, _ := json.Marshal(map[string]any{"agent": selected})
892 detail, err := a.backend.Query(request.Context(), "browseagent", []json.RawMessage{raw}, actor)
893 if err != nil {
894 data.Error = err.Error()
895 return
896 }
897 data.Event = detail
898}
899
900// peoplePage loads the access requests for the owner and moderators. A
901// member's request is refused by the backend, and the section stays out.
902func (a *App) peoplePage(request *http.Request, actor string, data *PageData) {
903 result, err := a.backend.Query(request.Context(), "listjoinrequests", nil, actor)
904 if err != nil {
905 return
906 }
907 data.Review = true
908 for _, row := range browseRows(result) {
909 if plainString(valueMap(row)["pubkey"]) != "" {
910 data.Requests = append(data.Requests, row)
911 }
912 }
913}
914
915// pendingRequests keeps the access requests that wait for a decision.
916func pendingRequests(rows []any) []any {
917 var pending []any
918 for _, row := range rows {
919 if plainString(valueMap(row)["status"]) == "pending" {
920 pending = append(pending, row)
921 }
922 }
923 return pending
924}
925
926// decidedRequests keeps the last decided access requests, newest first as
927// the backend lists them.
928func decidedRequests(rows []any) []any {
929 var decided []any
930 for _, row := range rows {
931 if status := plainString(valueMap(row)["status"]); status != "" && status != "pending" {
932 decided = append(decided, row)
933 }
934 if len(decided) == 10 {
935 break
936 }
937 }
938 return decided
939}
940
941// agentState folds a grant's marks into one word for data-state: revoked
942// covers expired grants too, since neither lets the agent publish again
943// without a new grant.
944func agentState(agent any) string {
945 values := valueMap(agent)
946 switch {
947 case unixSeconds(values["revoked"]) > 0:
948 return "revoked"
949 case unixSeconds(values["expires"]) > 0 && unixSeconds(values["expires"]) <= time.Now().Unix():
950 return "revoked"
951 case values["paused"] == true:
952 return "paused"
953 default:
954 return "active"
955 }
956}
957
958// agentSince is the short time shown beside the state: when the grant was
959// revoked or expired, else the agent's newest event.
960func agentSince(agent any) string {
961 values := valueMap(agent)
962 if revoked := unixSeconds(values["revoked"]); revoked > 0 {
963 return short(revoked)
964 }
965 if expires := unixSeconds(values["expires"]); expires > 0 && expires <= time.Now().Unix() {
966 return "expired " + short(expires)
967 }
968 if last := short(values["lastEvent"]); last != "" {
969 return last
970 }
971 return ""
972}
973
974// agentLabel is the grant's name, or the shortened key when it has none.
975func agentLabel(agent any) string {
976 values := valueMap(agent)
977 if name := plainString(values["name"]); name != "" {
978 return name
979 }
980 return shortID(plainString(values["pubkey"]))
981}
982
983// scopeList joins one list from the grant scope: rooms or kinds.
984func scopeList(agent any, key string) string {
985 items, _ := valueMap(valueMap(agent)["scope"])[key].([]any)
986 parts := make([]string, 0, len(items))
987 for _, item := range items {
988 parts = append(parts, plainString(item))
989 }
990 return strings.Join(parts, ", ")
991}
992
993// agentScope summarises a grant on one line for the agent table.
994func agentScope(agent any) string {
995 scope := valueMap(valueMap(agent)["scope"])
996 var parts []string
997 if rooms := scopeList(agent, "rooms"); rooms != "" {
998 parts = append(parts, "rooms: "+rooms)
999 }
1000 if repos, _ := scope["repos"].([]any); len(repos) > 0 {
1001 names := make([]string, 0, len(repos))
1002 for _, repo := range repos {
1003 values := valueMap(repo)
1004 names = append(names, plainString(values["identifier"])+" ("+plainString(values["level"])+")")
1005 }
1006 parts = append(parts, "repos: "+strings.Join(names, ", "))
1007 }
1008 if wiki := plainString(scope["wiki"]); wiki != "" {
1009 parts = append(parts, "wiki: "+wiki)
1010 }
1011 if kinds := scopeList(agent, "kinds"); kinds != "" {
1012 parts = append(parts, "kinds: "+kinds)
1013 }
1014 if sites, _ := scope["sites"].([]any); len(sites) > 0 {
1015 labels := make([]string, 0, len(sites))
1016 for _, site := range sites {
1017 labels = append(labels, plainString(valueMap(site)["label"]))
1018 }
1019 parts = append(parts, "sites: "+strings.Join(labels, ", "))
1020 }
1021 if len(parts) == 0 {
1022 return "profile and relay list only"
1023 }
1024 return strings.Join(parts, " | ")
1025}
1026
1027// agentCounts tallies agents by state for the panel.
1028func agentCounts(agents []any) map[string]int {
1029 counts := map[string]int{"active": 0, "paused": 0, "revoked": 0}
1030 for _, agent := range agents {
1031 counts[agentState(agent)]++
1032 }
1033 return counts
1034}
1035
1036// callbacksFor picks the callbacks one key registered.
1037func callbacksFor(callbacks []any, pubkey string) []any {
1038 var out []any
1039 for _, callback := range callbacks {
1040 if plainString(valueMap(callback)["owner"]) == pubkey {
1041 out = append(out, callback)
1042 }
1043 }
1044 return out
1045}
1046
1047// callbackState is active or paused; a paused callback carries the reason
1048// in its lastStatus.
1049func callbackState(callback any) string {
1050 if valueMap(callback)["paused"] == true {
1051 return "paused"
1052 }
1053 return "active"
1054}
1055
1056// callbackKinds lists the kinds a callback's filter names.
1057func callbackKinds(callback any) string {
1058 kinds, _ := valueMap(valueMap(callback)["filter"])["kinds"].([]any)
1059 parts := make([]string, 0, len(kinds))
1060 for _, kind := range kinds {
1061 parts = append(parts, plainString(kind))
1062 }
1063 return strings.Join(parts, ", ")
1064}
1065
1066// callbackCounts tallies callbacks by state for the panel.
1067func callbackCounts(callbacks []any) map[string]int {
1068 counts := map[string]int{"active": 0, "paused": 0}
1069 for _, callback := range callbacks {
1070 counts[callbackState(callback)]++
1071 }
1072 return counts
1073}
1074
1075// dateAfter is the date field value for a day count from today, in UTC.
1076func dateAfter(days int) string {
1077 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")
1078}
1079
1080// agentByKey finds one grant in the agents list, for the form that replaces
1081// it. An unknown or empty key yields nil, so the form stays blank.
1082func agentByKey(agents []any, pubkey string) any {
1083 if !hexID.MatchString(pubkey) {
1084 return nil
1085 }
1086 for _, agent := range agents {
1087 if plainString(valueMap(agent)["pubkey"]) == pubkey {
1088 return agent
1089 }
1090 }
1091 return nil
1092}
1093
1094// repoLines renders a grant's repositories one per line, the way the form
1095// takes them: owner:identifier:level.
1096func repoLines(agent any) string {
1097 repos, _ := valueMap(valueMap(agent)["scope"])["repos"].([]any)
1098 lines := make([]string, 0, len(repos))
1099 for _, repo := range repos {
1100 r := valueMap(repo)
1101 lines = append(lines, plainString(r["owner"])+":"+plainString(r["identifier"])+":"+plainString(r["level"]))
1102 }
1103 return strings.Join(lines, "\n")
1104}
1105
1106// siteLines renders a grant's sites one per line, the way the form takes
1107// them: the label, then ttl=<days> and encrypted when set.
1108func siteLines(agent any) string {
1109 sites, _ := valueMap(valueMap(agent)["scope"])["sites"].([]any)
1110 lines := make([]string, 0, len(sites))
1111 for _, site := range sites {
1112 s := valueMap(site)
1113 line := plainString(s["label"])
1114 if ttl := plainString(s["ttl"]); ttl != "" && ttl != "0" {
1115 line += " ttl=" + ttl
1116 }
1117 if s["encrypted"] == true {
1118 line += " encrypted"
1119 }
1120 lines = append(lines, line)
1121 }
1122 return strings.Join(lines, "\n")
1123}
1124
1125// dateOf renders a unix time as the form's date value.
1126func dateOf(value any) string {
1127 seconds := unixSeconds(value)
1128 if seconds <= 0 {
1129 return ""
1130 }
1131 return time.Unix(seconds, 0).UTC().Format("2006-01-02")
1132}
1133
1134var supportedMethods = []string{
1135 "supportedmethods", "stats", "getpolicy", "setpolicy", "listaudit", "listviews", "listmembers", "listpeople", "listjoinrequests", "approvejoin", "denyjoin", "setmember", "allowpubkey", "unrulepubkey", "removemember", "createinvite", "listinvites", "revokeinvite", "listclaims", "createclaim", "deleteclaim", "removesubtree", "listbannedpubkeys", "listallowedpubkeys", "banpubkey", "listreports", "resolvereport", "banevent", "allowevent", "listeventsneedingmoderation", "blockip", "unblockip", "listblockedips", "exportconfig", "importconfig", "planconfig", "listblobs", "deleteblob", "listbannedevents", "deleteevent", "listrecentevents", "searchevents", "pinevent", "unpinevent", "listpins", "allowkind", "disallowkind", "unrulekind", "storagestats", "gitstorage", "setretention", "listretention", "purgekind", "listallowedkinds", "listblockedkinds", "notifytest", "resetrules", "listpresets", "listconnectiontemplates", "listconnections", "setconnections", "applypreset", "forkrelay", "pullfrom", "pullstatus", "listjobs", "deliverystatus", "addjob", "removejob", "runjob", "backfill", "transferowner", "listdumps", "deletedump", "dumpnow", "backupnow", "listbackups", "deletebackup", "changerelayname", "changerelaydescription", "changerelayicon", "adddomain", "setdomainsite", "checkdomain", "removedomain", "listdomains", "listcallbacks", "addcallback", "removecallback", "pausecallback", "resumecallback",
1136}
1137
1138func (a *App) card(writer http.ResponseWriter) {
1139 writer.Header().Set("content-type", "application/json; charset=utf-8")
1140 _ = json.NewEncoder(writer).Encode(map[string]any{"name": a.backend.Policy().Name, "description": a.backend.Policy().Description, "url": a.backend.URL(), "slug": a.backend.Slug(), "identity": a.backend.Identity()})
1141}
1142
1143func (a *App) rpc(writer http.ResponseWriter, request *http.Request) {
1144 if a.actor == nil {
1145 http.Error(writer, "authentication is not configured", http.StatusUnauthorized)
1146 return
1147 }
1148 actor, err := a.actor(request)
1149 if err != nil || actor == "" {
1150 http.Error(writer, "authentication required", http.StatusUnauthorized)
1151 return
1152 }
1153 var method string
1154 var params []json.RawMessage
1155 if strings.HasPrefix(request.Header.Get("content-type"), "application/json") {
1156 var body struct {
1157 Method string `json:"method"`
1158 Params []json.RawMessage `json:"params"`
1159 }
1160 if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
1161 http.Error(writer, "invalid JSON request", http.StatusBadRequest)
1162 return
1163 }
1164 method, params = body.Method, body.Params
1165 } else if err := request.ParseForm(); err != nil {
1166 http.Error(writer, "invalid form", http.StatusBadRequest)
1167 return
1168 } else {
1169 method = request.FormValue("method")
1170 params = make([]json.RawMessage, 0, len(request.Form["param"]))
1171 for _, value := range request.Form["param"] {
1172 params = append(params, json.RawMessage(value))
1173 }
1174 }
1175 if method == "" {
1176 http.Error(writer, "method is required", http.StatusBadRequest)
1177 return
1178 }
1179 result, err := a.backend.Query(request.Context(), method, params, actor)
1180 if err != nil {
1181 writeJSON(writer, http.StatusBadRequest, map[string]any{"error": err.Error()})
1182 return
1183 }
1184 writeJSON(writer, http.StatusOK, map[string]any{"result": result})
1185}
1186
1187func writeJSON(writer http.ResponseWriter, status int, value any) {
1188 writer.Header().Set("content-type", "application/json; charset=utf-8")
1189 writer.WriteHeader(status)
1190 _ = json.NewEncoder(writer).Encode(value)
1191}
1192
1193func (a *App) resolveActor(request *http.Request) (string, error) {
1194 if a.actor == nil {
1195 return "", nil
1196 }
1197 return a.actor(request)
1198}
1199
1200func pathPrefix(path string) string {
1201 parts := strings.Split(strings.Trim(path, "/"), "/")
1202 if len(parts) >= 2 && parts[0] == "r" && parts[1] != "" {
1203 return "/r/" + parts[1]
1204 }
1205 return ""
1206}
1207
1208func requestPrefix(request *http.Request) string {
1209 if request != nil && request.RequestURI != "" {
1210 if parsed, err := url.ParseRequestURI(request.RequestURI); err == nil {
1211 if prefix := pathPrefix(parsed.Path); prefix != "" {
1212 return prefix
1213 }
1214 }
1215 }
1216 if request == nil || request.URL == nil {
1217 return ""
1218 }
1219 return pathPrefix(request.URL.Path)
1220}
1221
1222func tabForPath(path string) string {
1223 path = strings.TrimPrefix(path, pathPrefix(path))
1224 if path == "/" || path == "" {
1225 return "home"
1226 }
1227 path = strings.Trim(path, "/")
1228 if strings.HasPrefix(path, "manage/") {
1229 path = strings.TrimPrefix(path, "manage/")
1230 }
1231 if strings.HasPrefix(path, "wiki/") {
1232 return "wikipage"
1233 }
1234 switch path {
1235 case "manage":
1236 return "owner"
1237 case "wiki":
1238 return "wiki"
1239 case "repos":
1240 return "repos"
1241 case "repo":
1242 return "repo"
1243 case "files":
1244 return "files"
1245 case "file":
1246 return "file"
1247 case "approvals":
1248 return "approvals"
1249 case "profile":
1250 return "profile"
1251 case "account":
1252 return "account"
1253 case "signin", "sites":
1254 return path
1255 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":
1256 return path
1257 case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms":
1258 return path
1259 default:
1260 return "home"
1261 }
1262}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs