clone

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

feat/ui-updates
tinyrelay / tinyclient / webui.go
webui.go 43.0K
1package tinyclient23import (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"1516 "github.com/FelineStateMachine/tinyrelay/internal/policy"17 "github.com/FelineStateMachine/tinyrelay/internal/views"18 "github.com/skip2/go-qrcode"19)2021// signerJS is the vendored MIT licensed Nostr signer bundle used for NIP-4622// and Nostr Connect browser flows. It contains no relay branding or secrets.23//24//go:embed signer.js25var signerJS []byte2627// fixiJS is the vendored BSD-0 all-in-one bundle. It provides optional28// hypermedia fragments, focus-preserving swaps, small form state helpers, SSE29// handling, and fetch wrappers for pages that opt in with fx-* attributes.30//31//go:embed fixi.js32var fixiJS []byte3334// Backend supplies relay identity and the method calls used by page handlers.35// Query receives the request context, method name, JSON arguments and actor36// public key, and returns the method's JSON-shaped result. Policy, URL, slug37// 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() Policy41 URL() string42 Slug() string43 Identity() string44}4546// AccountPreferencesReader provides private, account-scoped preferences to47// server-rendered account pages. It is optional for small embedders.48type AccountPreferencesReader interface {49 SharePresence(context.Context, string) bool50}5152// CustomViewSource is the optional backend summary of the custom views the53// renderers show in place of fenced code blocks: names and languages only.54type CustomViewSource interface {55 CustomViews() []CustomView56}5758// 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)6162// QRProvider encodes text as an image for pages that show a connection QR.63type QRProvider func(text string) ([]byte, error)6465type App struct {66 backend Backend67 actor ActorResolver68 qr QRProvider69 tmpl *template.Template70 version string71 revision string72}7374type Options struct {75 Actor ActorResolver76 QR QRProvider77 // Version and Revision identify the relay build on the health page.78 Version, Revision string79}8081type PageData struct {82 Activity chatActivityPage83 SharePresence bool84 Version string85 Revision string86 Title string87 URL string88 Slug string89 Identity string90 Policy policy.Policy91 Actor string92 Owner bool93 Tab string94 Notice string95 Error string96 Methods []string97 Event any98 Invite string99 View string100 Feed []any101 Base string102 Query url.Values103 Private bool104 Path string105 Readme template.HTML106 Tree []any107 // Merge is the browsewikimerge detail shown by a wiki page compare view.108 Merge any109 // Connections lists the ways to open this relay in client apps that the110 // viewer may see, with link placeholders already resolved.111 Connections []any112 // Rooms lists the rooms the viewer may see; the rooms rail renders it.113 Rooms []any114 // Callbacks lists the event callbacks shown beside each agent.115 Callbacks []any116 // CustomViews lists the owner's custom views on the Views page.117 CustomViews []any118 // Requests lists the access requests on the People page, pending first;119 // Review is true when the viewer may decide them.120 Requests []any121 Review bool122}123124func New(backend Backend, options Options) (*App, error) {125 if backend == nil {126 return nil, fmt.Errorf("tinyclient backend is required")127 }128 qr := options.QR129 if qr == nil {130 qr = defaultQR131 }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, err136 }137 a.tmpl = tmpl138 return a, nil139}140141// 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 nil147 }148 renderer := views.NewRenderer(source.CustomViews())149 if len(renderer.Languages) == 0 {150 return nil151 }152 return renderer.Block153}154155func (a *App) markdown(source string) template.HTML {156 return renderMarkdownWith(source, a.blocks())157}158159func (a *App) chatMarkdown(source string) template.HTML {160 return renderChatMarkdownWith(source, a.blocks())161}162163func 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.Builder170 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()), nil181}182183func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {184 if request.URL.Path == BackendPath {185 a.BackendHandler().ServeHTTP(writer, request)186 return187 }188 if strings.HasPrefix(request.URL.Path, "/avatars/") {189 serveAvatar(writer, request)190 return191 }192 if a.denyPrivateEndpoint(writer, request) {193 return194 }195 if a.handleChatRoute(writer, request) || a.handleControlRoute(writer, request) || a.handleWebMCPRoute(writer, request) || a.handleBrowseRoute(writer, request) {196 return197 }198 if a.handleSocialRoute(writer, request) {199 return200 }201 if request.Method != http.MethodGet && request.Method != http.MethodHead {202 http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)203 return204 }205 if request.URL.Path == "/card.json" {206 a.card(writer)207 return208 }209 if request.URL.Path == "/people" {210 a.peopleJSON(writer, request)211 return212 }213 if request.URL.Path == "/connect.json" {214 a.connectJSON(writer, request)215 return216 }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 return221 }222 a.eventPage(writer, request)223 return224 }225 if strings.HasPrefix(request.URL.Path, "/view/") {226 a.viewPage(writer, request)227 return228 }229 if strings.HasPrefix(request.URL.Path, "/invite/") {230 a.invitePage(writer, request)231 return232 }233 if serveEmbeddedAsset(writer, request, request.URL.Path) {234 return235 }236 if request.URL.Path == "/open" {237 a.openLink(writer, request)238 return239 }240 if request.URL.Path == "/manifest.webmanifest" {241 a.manifest(writer, request)242 return243 }244 if request.URL.Path == "/qr.svg" {245 a.qrImage(writer, request.URL.Query().Get("text"))246 return247 }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 return252 }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 return257 }258 if request.URL.Path == "/articles.json" {259 if !a.pageFeatureEnabled(request.URL.Path) {260 http.NotFound(writer, request)261 return262 }263 a.articlesJSON(writer, request)264 return265 }266 if request.URL.Path == "/feed" || request.URL.Path == "/feed.xml" {267 if !a.pageFeatureEnabled(request.URL.Path) {268 http.NotFound(writer, request)269 return270 }271 a.feed(writer, request)272 return273 }274 a.page(writer, request)275}276277func (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 return282 }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 return290 }291 var previous string292 writeUpdate := func() bool {293 currentActor, actorErr := a.resolveActor(request)294 if actorErr != nil || currentActor == "" || currentActor != actor {295 return false296 }297 result, queryErr := a.backend.Query(request.Context(), "listjobs", nil, currentActor)298 fragment := jobsFragment(result, queryErr)299 if fragment == previous {300 return true301 }302 previous = fragment303 _, writeErr := fmt.Fprintf(writer, "data: %s\n\n", fragment)304 flusher.Flush()305 return writeErr == nil306 }307 if !writeUpdate() {308 return309 }310 ticker := time.NewTicker(2 * time.Second)311 defer ticker.Stop()312 for {313 select {314 case <-request.Context().Done():315 return316 case <-ticker.C:317 if !writeUpdate() {318 return319 }320 }321 }322}323324func (a *App) joinPolicy(writer http.ResponseWriter) {325 writeJSON(writer, http.StatusOK, map[string]string{"terms": a.backend.Policy().JoinTerms})326}327328func (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 return333 }334 var code, termsHash string335 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 return342 }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 return352 } else {353 code = request.FormValue("code")354 }355 if code == "" {356 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "invite code is required"})357 return358 }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 return367 }368 writeJSON(writer, http.StatusOK, map[string]any{"result": result})369}370371func (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 return376 }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 return381 }382 writeJSON(writer, http.StatusOK, result)383}384385func (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 return390 }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 return395 }396 if request.URL.Query().Get("catalog") == "" {397 writeJSON(writer, http.StatusOK, result)398 return399 }400 // The Connect page reads the list and the card catalog with its session401 // 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 return406 }407 writeJSON(writer, http.StatusOK, map[string]any{"connections": result, "catalog": catalog})408}409410func (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 return415 }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 return423 }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 return434 }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}442443func (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 return450 }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.StatusForbidden454 if strings.HasPrefix(err.Error(), "auth-required:") {455 status = http.StatusUnauthorized456 }457 if strings.HasPrefix(err.Error(), "not found:") {458 status = http.StatusNotFound459 }460 writeJSON(writer, status, map[string]string{"error": err.Error()})461 return462 }463 writeJSON(writer, http.StatusOK, result)464}465466func (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 return471 }472 path, method := request.URL.Path, ""473 pageQuery := request.URL.Query()474 for key := range routeKeys {475 pageQuery.Del(key)476 }477 repo := parseRepoRoute(path)478 switch {479 case path == "/repos":480 method = "browserepos"481 case repo.ok:482 method = "browserepo"483 owner := a.person(request.Context(), repo.owner)484 if owner == "" {485 http.NotFound(writer, request)486 return487 }488 pageQuery.Set("owner", owner)489 pageQuery.Set("handle", a.handle(request.Context(), owner))490 pageQuery.Set("repo", repo.repo)491 pageQuery.Set("view", repo.view)492 if repo.path != "" {493 pageQuery.Set("path", repo.path)494 }495 if repo.id != "" {496 pageQuery.Set("id", repo.id)497 }498 if repo.view == "commit" {499 pageQuery.Set("ref", repo.id)500 }501 case path == "/files":502 method = "browsefiles"503 case filesView(path) != "":504 method = "browsefiles"505 pageQuery.Set("view", filesView(path))506 case fileHash(path) != "":507 method = "browsefile"508 pageQuery.Set("hash", fileHash(path))509 case path == "/approvals":510 method = "browseapprovals"511 case approvalID(path) != "":512 method = "browseapprovals"513 pageQuery.Set("id", approvalID(path))514 case path == "/profile":515 method = "browseprofile"516 case path == "/wiki":517 method = "browsewiki"518 case path == "/manage/health":519 method = "browsestatus"520 }521 room := roomRoute(path)522 switch room.tab {523 case "rooms":524 method = "browserooms"525 case "room":526 method = "browseroom"527 case "thread":528 method = "browsethread"529 }530 if strings.HasPrefix(path, "/wiki/") {531 method = "browsewikipage"532 }533 params := []json.RawMessage{}534 query := map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 50, "q": request.URL.Query().Get("q")}535 if value := request.URL.Query().Get("limit"); value != "" {536 if parsed, parseErr := strconv.Atoi(value); parseErr == nil && parsed > 0 {537 query["limit"] = parsed538 }539 }540 switch method {541 case "browsefiles":542 view := pageQuery.Get("view")543 if view == "" {544 view = "library"545 }546 query["view"] = view547 query["path"] = request.URL.Query().Get("path")548 case "browserepo":549 view := pageQuery.Get("view")550 if view == "home" {551 view = "tree"552 }553 offset := 0554 if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset")); parseErr == nil && parsed >= 0 {555 offset = parsed556 }557 backendView := view558 if view == "issues" {559 method = "browseissues"560 backendView = ""561 }562 if view == "prs" {563 method = "browsepulls"564 backendView = ""565 }566 if view == "issue" {567 method = "browseissue"568 backendView = ""569 }570 if view == "pr" {571 method = "browsepull"572 backendView = ""573 }574 query = map[string]any{"owner": pageQuery.Get("owner"), "repo": pageQuery.Get("repo"), "event": pageQuery.Get("id"), "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": pageQuery.Get("ref"), "path": pageQuery.Get("path"), "view": backendView, "offset": offset, "limit": 100}575 case "browsefile":576 query = map[string]any{"hash": pageQuery.Get("hash")}577 case "browseprofile":578 query = map[string]any{"pubkey": actor}579 case "browseapprovals":580 state := request.URL.Query().Get("state")581 if state == "" {582 state = "all"583 }584 query = map[string]any{"cursor": query["cursor"], "limit": query["limit"], "state": state}585 case "browsewiki":586 query["author"] = request.URL.Query().Get("author")587 case "browsewikipage":588 // The name travels in the query as well so the page template can589 // offer to create a page that does not exist yet.590 pageQuery.Set("d", wikiPageName(path))591 if id := wikiMergeID(path); id != "" {592 pageQuery.Set("merge", id)593 }594 query = map[string]any{"d": pageQuery.Get("d"), "author": request.URL.Query().Get("author"), "version": request.URL.Query().Get("version")}595 case "browsestatus":596 query = map[string]any{}597 case "browserooms":598 query = map[string]any{"cursor": request.URL.Query().Get("cursor"), "limit": 100}599 case "browseroom", "browsethread":600 query = map[string]any{"id": room.id, "event": room.event, "cursor": request.URL.Query().Get("cursor"), "limit": 50}601 }602 raw, _ := json.Marshal(query)603 params = append(params, raw)604 var tree []any605 if method == "browserepo" && pageQuery.Get("view") == "file" {606 tree = a.siblings(request.Context(), actor, pageQuery)607 }608 result, err, typedRoom := a.typedRoomResult(request.Context(), room, pageQuery, actor)609 if !typedRoom {610 result, err = a.backend.Query(request.Context(), method, params, actor)611 }612 if err != nil {613 data := PageData{Tab: browseTab(path), Error: err.Error(), Query: pageQuery, View: room.id}614 if room.tab != "" {615 data.Rooms = a.roomList(request.Context(), actor)616 }617 a.render(writer, request, data)618 return619 }620 if method == "browseapprovals" && pageQuery.Get("id") != "" {621 // A notification opens one request; show it even when it has left622 // the first page.623 result = a.includeApproval(request.Context(), actor, result, pageQuery.Get("id"))624 }625 data := PageData{Tab: browseTab(path), Feed: browseRows(result), Event: result, Query: pageQuery, View: room.id}626 if method == "browserepos" {627 a.addHandles(request.Context(), data.Feed, "owner")628 }629 data.Title = browseTitle(path, pageQuery, result, a.backend.Slug())630 if id := pageQuery.Get("merge"); method == "browsewikipage" && len(id) == 64 {631 raw, _ := json.Marshal(map[string]any{"id": id})632 if merge, mergeErr := a.backend.Query(request.Context(), "browsewikimerge", []json.RawMessage{raw}, actor); mergeErr == nil {633 data.Merge = merge634 } else {635 data.Notice = mergeErr.Error()636 }637 }638 switch room.tab {639 case "rooms":640 data.Rooms = data.Feed641 case "room", "thread":642 data.Rooms = a.roomList(request.Context(), actor)643 data.Activity = a.chatActivity(request.Context(), actor, room.id, plainString(valueMap(valueMap(result)["root"])["id"]))644 }645 if method == "browserepo" && pageQuery.Get("view") == "home" {646 data.Readme = a.readme(request.Context(), actor, pageQuery)647 }648 data.Tree = tree649 a.render(writer, request, data)650}651652func browseTitle(path string, query url.Values, result any, slug string) string {653 label := map[string]string{"/repos": "Repositories", "/files": "Files", "/file": "File", "/approvals": "Approvals", "/profile": "Profile", "/wiki": "Wiki", "/rooms": "Rooms"}[path]654 if strings.HasPrefix(path, "/wiki/") {655 label = plainString(valueMap(result)["title"])656 if label == "" {657 label = query.Get("d")658 }659 label += " | wiki"660 }661 if room := roomRoute(path); room.tab == "room" || room.tab == "thread" {662 label = "#" + room.id663 if room.tab == "thread" {664 label += " | thread"665 }666 }667 if parseRepoRoute(path).ok {668 label = query.Get("repo")669 if label == "" {670 label = plainString(valueMap(result)["identifier"])671 }672 if label == "" {673 label = "Repository"674 }675 label += " | " + repoView(query)676 }677 if label == "" {678 label = "Browse"679 }680 return label + " | " + slug681}682683// readme renders README.md from the requested ref for the repository entry684// page. A missing or binary README simply yields no content.685func (a *App) readme(ctx context.Context, actor string, query url.Values) template.HTML {686 for _, name := range []string{"README.md", "readme.md", "README"} {687 raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": name, "view": "file", "limit": 1})688 result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)689 if err != nil {690 continue691 }692 page := valueMap(result)693 content, _ := page["content"].(string)694 if binary, _ := page["binary"].(bool); binary || content == "" {695 continue696 }697 return renderRepositoryMarkdownWith(content, query, a.blocks())698 }699 return ""700}701702// siblings lists the directory that holds the file being previewed so the703// tree pane stays populated beside the file.704func (a *App) siblings(ctx context.Context, actor string, query url.Values) []any {705 dir := ""706 if slash := strings.LastIndex(query.Get("path"), "/"); slash >= 0 {707 dir = query.Get("path")[:slash]708 }709 raw, _ := json.Marshal(map[string]any{"owner": query.Get("owner"), "repo": query.Get("repo"), "ref": query.Get("ref"), "path": dir, "view": "tree", "limit": 100})710 result, err := a.backend.Query(ctx, "browserepo", []json.RawMessage{raw}, actor)711 if err != nil {712 return nil713 }714 entries, _ := valueMap(result)["entries"].([]any)715 return entries716}717718func browseTab(path string) string {719 if strings.HasPrefix(path, "/wiki/") {720 return "wikipage"721 }722 if room := roomRoute(path); room.tab != "" {723 return room.tab724 }725 switch path {726 case "/wiki":727 return "wiki"728 case "/repos":729 return "repos"730 case "/files":731 return "files"732 case "/approvals":733 return "approvals"734 case "/profile":735 return "profile"736 }737 switch {738 case parseRepoRoute(path).ok:739 return "repo"740 case filesView(path) != "":741 return "files"742 case fileHash(path) != "":743 return "file"744 case approvalID(path) != "":745 return "approvals"746 }747 return "health"748}749func browseRows(value any) []any {750 encoded, err := json.Marshal(value)751 if err != nil {752 return nil753 }754 var rows []any755 if json.Unmarshal(encoded, &rows) == nil {756 return rows757 }758 var object map[string]any759 if json.Unmarshal(encoded, &object) == nil {760 if items, ok := object["items"].([]any); ok {761 return items762 }763 }764 return []any{value}765}766767func (a *App) invitePage(writer http.ResponseWriter, request *http.Request) {768 code := strings.TrimPrefix(request.URL.Path, "/invite/")769 a.render(writer, request, PageData{Invite: code, Tab: "invite"})770}771772// render fills the shared page data, applies private relay masking, and773// writes the page. Every HTML response goes through here.774func (a *App) render(writer http.ResponseWriter, request *http.Request, data PageData) {775 actor, _ := a.resolveActor(request)776 if data.Title == "" {777 data.Title = a.backend.Slug()778 }779 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)780 data.Owner = actor != "" && actor == data.Policy.Owner781 if actor != "" && data.Tab == "account" {782 if preferences, ok := a.backend.(AccountPreferencesReader); ok {783 data.SharePresence = preferences.SharePresence(request.Context(), actor)784 }785 }786 data.Version, data.Revision = a.version, a.revision787 data.Methods = supportedMethods788 if data.Query == nil {789 data.Query = request.URL.Query()790 }791 data.Path = strings.TrimPrefix(request.URL.Path, data.Base)792 a.privatePageData(&data, request, actor)793 if !data.Private && data.Actor == "" && (railKind(data.Tab) == "manage" || data.Tab == "profile" || data.Tab == "account") {794 // Management pages and the profile editor are for signed-in people795 // only. Guests see the sign-in page at the same address and come back796 // after signing in.797 data.Tab, data.Notice = "signin", "Sign in to manage this relay."798 if data.Path == "/profile" {799 data.Notice = "Sign in to edit your profile."800 } else if data.Path == "/account" {801 data.Notice = "Sign in to open your account."802 }803 data.Event, data.Feed, data.Error = nil, nil, ""804 }805 if !data.Private && data.Actor != "" && railKind(data.Tab) == "manage" && !data.Owner && !a.readAdmitted(request.Context(), actor) {806 // A signed-in key the relay does not admit gets a short notice instead807 // of management forms that would only fail once signed.808 data.Tab = "manage-restricted"809 data.Event, data.Feed, data.Error = nil, nil, ""810 }811 writer.Header().Set("Cache-Control", "private, no-store")812 writer.Header().Set("content-type", "text/html; charset=utf-8")813 var rendered bytes.Buffer814 if err := a.tmpl.ExecuteTemplate(&rendered, "page", data); err != nil {815 http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)816 return817 }818 _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base)))819}820821// manifest describes the installable app for this relay and tenant prefix.822func (a *App) manifest(writer http.ResponseWriter, request *http.Request) {823 name := a.backend.Policy().Name824 if name == "" || privatePolicyEnabled(a.backend.Policy()) {825 name = a.backend.Slug()826 }827 encoded, _ := json.Marshal(name)828 body := strings.ReplaceAll(manifestTemplate, `"NAME"`, string(encoded))829 body = strings.ReplaceAll(body, "BASE/", requestPrefix(request)+"/")830 // A manifest carries one set of launch colours. The page links the831 // variant that matches the active theme so the installed app's splash832 // screen and status bar do not flash light on a dark phone.833 theme, background := "#f4f2ec", "#fbfaf7"834 if request.URL.Query().Get("theme") == "dark" {835 theme, background = "#1e1d1a", "#171614"836 }837 body = strings.ReplaceAll(body, "THEME", theme)838 body = strings.ReplaceAll(body, "BACKGROUND", background)839 writer.Header().Set("content-type", "application/manifest+json; charset=utf-8")840 _, _ = writer.Write([]byte(body))841}842843func (a *App) qrImage(writer http.ResponseWriter, text string) {844 if a.qr == nil || text == "" {845 http.Error(writer, "QR generation is not configured", http.StatusNotImplemented)846 return847 }848 image, err := a.qr(text)849 if err != nil {850 http.Error(writer, "QR generation failed", http.StatusInternalServerError)851 return852 }853 writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")854 _, _ = writer.Write(image)855}856857func (a *App) page(writer http.ResponseWriter, request *http.Request) {858 tab := tabForPath(request.URL.Path)859 if tab == "home" && strings.Trim(strings.TrimPrefix(request.URL.Path, pathPrefix(request.URL.Path)), "/") != "" {860 http.NotFound(writer, request)861 return862 }863 actor, err := a.resolveActor(request)864 if err != nil {865 actor = ""866 }867 data := PageData{Tab: tab, Query: request.URL.Query()}868 if tab == "inbox" || tab == "outbox" || tab == "search" || tab == "articles" || tab == "sites" {869 feed, feedErr := a.publicFeed(request.Context(), tab, actor, request.URL.Query())870 if feedErr != nil {871 data.Error = feedErr.Error()872 } else if tab == "sites" {873 data.Feed = a.siteRows(feed)874 } else {875 data.Feed = feed876 }877 }878 if tab == "home" {879 if result, err := a.backend.Query(request.Context(), "connections", nil, actor); err == nil {880 data.Connections = expandConnections(browseRows(result), PageData{URL: a.backend.URL(), Identity: a.backend.Identity(), Policy: a.backend.Policy(), Actor: actor})881 }882 }883 if tab == "agents" && actor != "" {884 a.agentsPage(request, actor, &data)885 }886 if tab == "people" && actor != "" {887 a.peoplePage(request, actor, &data)888 }889 if tab == "views" && actor != "" {890 // The listing is owner-only; anyone else keeps the built-in controls.891 if result, err := a.backend.Query(request.Context(), "listcustomviews", nil, actor); err == nil {892 data.CustomViews = browseRows(result)893 }894 }895 if tab != "" {896 data.Title = tab + " | " + a.backend.Slug()897 }898 a.render(writer, request, data)899}900901// agentsPage loads the agent list for the table and cards, then the grant902// and recent events of one agent: the one named by ?agent=, else the first.903// Both queries are gated for the owner and moderators by the backend.904func (a *App) agentsPage(request *http.Request, actor string, data *PageData) {905 result, err := a.backend.Query(request.Context(), "listagents", nil, actor)906 if err != nil {907 data.Error = err.Error()908 return909 }910 data.Feed = browseRows(result)911 // Callbacks are shown per agent; a failure here leaves the row empty912 // rather than hiding the agents.913 if callbacks, err := a.backend.Query(request.Context(), "listcallbacks", nil, actor); err == nil {914 data.Callbacks = browseRows(callbacks)915 }916 a.addHandles(request.Context(), data.Feed, "pubkey")917 selected := ""918 if rest, ok := strings.CutPrefix(request.URL.Path, "/manage/agents/"); ok {919 selected = a.person(request.Context(), rest)920 if selected == "" {921 data.Error = "not found: agent " + rest922 return923 }924 }925 if selected == "" {926 // Without a choice, show the first agent that can still publish.927 for _, agent := range data.Feed {928 if selected == "" || agentState(agent) == "active" {929 selected = plainString(valueMap(agent)["pubkey"])930 }931 if agentState(agent) == "active" {932 break933 }934 }935 }936 if selected == "" {937 return938 }939 raw, _ := json.Marshal(map[string]any{"agent": selected})940 detail, err := a.backend.Query(request.Context(), "browseagent", []json.RawMessage{raw}, actor)941 if err != nil {942 data.Error = err.Error()943 return944 }945 data.Event = detail946}947948// peoplePage loads the access requests for the owner and moderators. A949// member's request is refused by the backend, and the section stays out.950func (a *App) peoplePage(request *http.Request, actor string, data *PageData) {951 result, err := a.backend.Query(request.Context(), "listjoinrequests", nil, actor)952 if err != nil {953 return954 }955 data.Review = true956 for _, row := range browseRows(result) {957 if plainString(valueMap(row)["pubkey"]) != "" {958 data.Requests = append(data.Requests, row)959 }960 }961}962963// pendingRequests keeps the access requests that wait for a decision.964func pendingRequests(rows []any) []any {965 var pending []any966 for _, row := range rows {967 if plainString(valueMap(row)["status"]) == "pending" {968 pending = append(pending, row)969 }970 }971 return pending972}973974// decidedRequests keeps the last decided access requests, newest first as975// the backend lists them.976func decidedRequests(rows []any) []any {977 var decided []any978 for _, row := range rows {979 if status := plainString(valueMap(row)["status"]); status != "" && status != "pending" {980 decided = append(decided, row)981 }982 if len(decided) == 10 {983 break984 }985 }986 return decided987}988989// agentState folds a grant's marks into one word for data-state: revoked990// covers expired grants too, since neither lets the agent publish again991// without a new grant.992func agentState(agent any) string {993 values := valueMap(agent)994 switch {995 case unixSeconds(values["revoked"]) > 0:996 return "revoked"997 case unixSeconds(values["expires"]) > 0 && unixSeconds(values["expires"]) <= time.Now().Unix():998 return "revoked"999 case values["paused"] == true:1000 return "paused"1001 default:1002 return "active"1003 }1004}10051006// agentSince is the short time shown beside the state: when the grant was1007// revoked or expired, else the agent's newest event.1008func agentSince(agent any) string {1009 values := valueMap(agent)1010 if revoked := unixSeconds(values["revoked"]); revoked > 0 {1011 return short(revoked)1012 }1013 if expires := unixSeconds(values["expires"]); expires > 0 && expires <= time.Now().Unix() {1014 return "expired " + short(expires)1015 }1016 if last := short(values["lastEvent"]); last != "" {1017 return last1018 }1019 return ""1020}10211022// agentLabel is the grant's name, or the shortened key when it has none.1023func agentLabel(agent any) string {1024 values := valueMap(agent)1025 if name := plainString(values["name"]); name != "" {1026 return name1027 }1028 return shortID(plainString(values["pubkey"]))1029}10301031// scopeList joins one list from the grant scope: rooms or kinds.1032func scopeList(agent any, key string) string {1033 items, _ := valueMap(valueMap(agent)["scope"])[key].([]any)1034 parts := make([]string, 0, len(items))1035 for _, item := range items {1036 parts = append(parts, plainString(item))1037 }1038 return strings.Join(parts, ", ")1039}10401041// agentScope summarises a grant on one line for the agent table.1042func agentScope(agent any) string {1043 scope := valueMap(valueMap(agent)["scope"])1044 var parts []string1045 if rooms := scopeList(agent, "rooms"); rooms != "" {1046 parts = append(parts, "rooms: "+rooms)1047 }1048 if repos, _ := scope["repos"].([]any); len(repos) > 0 {1049 names := make([]string, 0, len(repos))1050 for _, repo := range repos {1051 values := valueMap(repo)1052 names = append(names, plainString(values["identifier"])+" ("+plainString(values["level"])+")")1053 }1054 parts = append(parts, "repos: "+strings.Join(names, ", "))1055 }1056 if wiki := plainString(scope["wiki"]); wiki != "" {1057 parts = append(parts, "wiki: "+wiki)1058 }1059 if kinds := scopeList(agent, "kinds"); kinds != "" {1060 parts = append(parts, "kinds: "+kinds)1061 }1062 if sites, _ := scope["sites"].([]any); len(sites) > 0 {1063 labels := make([]string, 0, len(sites))1064 for _, site := range sites {1065 labels = append(labels, plainString(valueMap(site)["label"]))1066 }1067 parts = append(parts, "sites: "+strings.Join(labels, ", "))1068 }1069 if len(parts) == 0 {1070 return "profile and relay list only"1071 }1072 return strings.Join(parts, " | ")1073}10741075// agentCounts tallies agents by state for the panel.1076func agentCounts(agents []any) map[string]int {1077 counts := map[string]int{"active": 0, "paused": 0, "revoked": 0}1078 for _, agent := range agents {1079 counts[agentState(agent)]++1080 }1081 return counts1082}10831084// callbacksFor picks the callbacks one key registered.1085func callbacksFor(callbacks []any, pubkey string) []any {1086 var out []any1087 for _, callback := range callbacks {1088 if plainString(valueMap(callback)["owner"]) == pubkey {1089 out = append(out, callback)1090 }1091 }1092 return out1093}10941095// callbackState is active or paused; a paused callback carries the reason1096// in its lastStatus.1097func callbackState(callback any) string {1098 if valueMap(callback)["paused"] == true {1099 return "paused"1100 }1101 return "active"1102}11031104// callbackKinds lists the kinds a callback's filter names.1105func callbackKinds(callback any) string {1106 kinds, _ := valueMap(valueMap(callback)["filter"])["kinds"].([]any)1107 parts := make([]string, 0, len(kinds))1108 for _, kind := range kinds {1109 parts = append(parts, plainString(kind))1110 }1111 return strings.Join(parts, ", ")1112}11131114// callbackCounts tallies callbacks by state for the panel.1115func callbackCounts(callbacks []any) map[string]int {1116 counts := map[string]int{"active": 0, "paused": 0}1117 for _, callback := range callbacks {1118 counts[callbackState(callback)]++1119 }1120 return counts1121}11221123// dateAfter is the date field value for a day count from today, in UTC.1124func dateAfter(days int) string {1125 return time.Now().UTC().AddDate(0, 0, days).Format("2006-01-02")1126}11271128// agentByKey finds one grant in the agents list, for the form that replaces1129// it. An unknown or empty key yields nil, so the form stays blank.1130func agentByKey(agents []any, pubkey string) any {1131 if !hexID.MatchString(pubkey) {1132 return nil1133 }1134 for _, agent := range agents {1135 if plainString(valueMap(agent)["pubkey"]) == pubkey {1136 return agent1137 }1138 }1139 return nil1140}11411142// repoLines renders a grant's repositories one per line, the way the form1143// takes them: owner:identifier:level.1144func repoLines(agent any) string {1145 repos, _ := valueMap(valueMap(agent)["scope"])["repos"].([]any)1146 lines := make([]string, 0, len(repos))1147 for _, repo := range repos {1148 r := valueMap(repo)1149 lines = append(lines, plainString(r["owner"])+":"+plainString(r["identifier"])+":"+plainString(r["level"]))1150 }1151 return strings.Join(lines, "\n")1152}11531154// siteLines renders a grant's sites one per line, the way the form takes1155// them: the label, then ttl=<days> and encrypted when set.1156func siteLines(agent any) string {1157 sites, _ := valueMap(valueMap(agent)["scope"])["sites"].([]any)1158 lines := make([]string, 0, len(sites))1159 for _, site := range sites {1160 s := valueMap(site)1161 line := plainString(s["label"])1162 if ttl := plainString(s["ttl"]); ttl != "" && ttl != "0" {1163 line += " ttl=" + ttl1164 }1165 if s["encrypted"] == true {1166 line += " encrypted"1167 }1168 lines = append(lines, line)1169 }1170 return strings.Join(lines, "\n")1171}11721173// dateOf renders a unix time as the form's date value.1174func dateOf(value any) string {1175 seconds := unixSeconds(value)1176 if seconds <= 0 {1177 return ""1178 }1179 return time.Unix(seconds, 0).UTC().Format("2006-01-02")1180}11811182var supportedMethods = []string{1183 "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",1184}11851186func (a *App) card(writer http.ResponseWriter) {1187 writer.Header().Set("content-type", "application/json; charset=utf-8")1188 _ = 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()})1189}11901191func (a *App) rpc(writer http.ResponseWriter, request *http.Request) {1192 if a.actor == nil {1193 http.Error(writer, "authentication is not configured", http.StatusUnauthorized)1194 return1195 }1196 actor, err := a.actor(request)1197 if err != nil || actor == "" {1198 http.Error(writer, "authentication required", http.StatusUnauthorized)1199 return1200 }1201 var method string1202 var params []json.RawMessage1203 if strings.HasPrefix(request.Header.Get("content-type"), "application/json") {1204 var body struct {1205 Method string `json:"method"`1206 Params []json.RawMessage `json:"params"`1207 }1208 if err := json.NewDecoder(request.Body).Decode(&body); err != nil {1209 http.Error(writer, "invalid JSON request", http.StatusBadRequest)1210 return1211 }1212 method, params = body.Method, body.Params1213 } else if err := request.ParseForm(); err != nil {1214 http.Error(writer, "invalid form", http.StatusBadRequest)1215 return1216 } else {1217 method = request.FormValue("method")1218 params = make([]json.RawMessage, 0, len(request.Form["param"]))1219 for _, value := range request.Form["param"] {1220 params = append(params, json.RawMessage(value))1221 }1222 }1223 if method == "" {1224 http.Error(writer, "method is required", http.StatusBadRequest)1225 return1226 }1227 result, err := a.backend.Query(request.Context(), method, params, actor)1228 if err != nil {1229 writeJSON(writer, http.StatusBadRequest, map[string]any{"error": err.Error()})1230 return1231 }1232 writeJSON(writer, http.StatusOK, map[string]any{"result": result})1233}12341235func writeJSON(writer http.ResponseWriter, status int, value any) {1236 writer.Header().Set("content-type", "application/json; charset=utf-8")1237 writer.WriteHeader(status)1238 _ = json.NewEncoder(writer).Encode(value)1239}12401241func (a *App) resolveActor(request *http.Request) (string, error) {1242 if a.actor == nil {1243 return "", nil1244 }1245 return a.actor(request)1246}12471248func pathPrefix(path string) string {1249 parts := strings.Split(strings.Trim(path, "/"), "/")1250 if len(parts) >= 2 && parts[0] == "r" && parts[1] != "" {1251 return "/r/" + parts[1]1252 }1253 return ""1254}12551256func requestPrefix(request *http.Request) string {1257 if request != nil && request.RequestURI != "" {1258 if parsed, err := url.ParseRequestURI(request.RequestURI); err == nil {1259 if prefix := pathPrefix(parsed.Path); prefix != "" {1260 return prefix1261 }1262 }1263 }1264 if request == nil || request.URL == nil {1265 return ""1266 }1267 return pathPrefix(request.URL.Path)1268}12691270// readAdmitted reports whether the relay lets actor read at all: the private1271// service check when it is enabled, and the members-only read gate otherwise.1272func (a *App) readAdmitted(ctx context.Context, actor string) bool {1273 if reader, ok := a.backend.(PrivateReader); ok {1274 if err := reader.ReadAllowed(ctx, actor); err != nil {1275 return false1276 }1277 }1278 if a.backend.Policy().Reads != "members" {1279 return true1280 }1281 if _, err := a.backend.Query(ctx, "browserooms", []json.RawMessage{json.RawMessage(`{"limit":1}`)}, actor); err != nil {1282 return !restricted(err.Error()) && !authRequired(err.Error())1283 }1284 return true1285}12861287func tabForPath(path string) string {1288 path = strings.TrimPrefix(path, pathPrefix(path))1289 if path == "/" || path == "" {1290 return "home"1291 }1292 path = strings.Trim(path, "/")1293 if strings.HasPrefix(path, "manage/") {1294 path = strings.TrimPrefix(path, "manage/")1295 }1296 if strings.HasPrefix(path, "wiki/") {1297 return "wikipage"1298 }1299 switch {1300 case parseRepoRoute("/" + path).ok:1301 return "repo"1302 case filesView("/"+path) != "":1303 return "files"1304 case fileHash("/"+path) != "":1305 return "file"1306 case approvalID("/"+path) != "":1307 return "approvals"1308 case strings.HasPrefix(path, "agents/"):1309 return "agents"1310 }1311 switch path {1312 case "manage":1313 return "owner"1314 case "wiki":1315 return "wiki"1316 case "repos":1317 return "repos"1318 case "files":1319 return "files"1320 case "approvals":1321 return "approvals"1322 case "profile":1323 return "profile"1324 case "account":1325 return "account"1326 case "signin", "sites":1327 return path1328 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":1329 return path1330 case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms":1331 return path1332 default:1333 return "home"1334 }1335}