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 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"] = parsed509 }510 }511 switch method {512 case "browsefiles":513 view := request.URL.Query().Get("view")514 if view == "" {515 view = "library"516 }517 query["view"] = view518 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 := 0525 if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset")); parseErr == nil && parsed >= 0 {526 offset = parsed527 }528 backendView := view529 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 can567 // 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 []any580 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 return594 }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 left600 // 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 = merge609 } else {610 data.Notice = mergeErr.Error()611 }612 }613 switch room.tab {614 case "rooms":615 data.Rooms = data.Feed616 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 = tree624 a.render(writer, request, data)625}626627func 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.id638 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 + " | " + slug656}657658// readme renders README.md from the requested ref for the repository entry659// 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 continue666 }667 page := valueMap(result)668 content, _ := page["content"].(string)669 if binary, _ := page["binary"].(bool); binary || content == "" {670 continue671 }672 return renderRepositoryMarkdownWith(content, query, a.blocks())673 }674 return ""675}676677// siblings lists the directory that holds the file being previewed so the678// 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 nil688 }689 entries, _ := valueMap(result)["entries"].([]any)690 return entries691}692693func browseTab(path string) string {694 if strings.HasPrefix(path, "/wiki/") {695 return "wikipage"696 }697 if room := roomRoute(path); room.tab != "" {698 return room.tab699 }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 nil723 }724 var rows []any725 if json.Unmarshal(encoded, &rows) == nil {726 return rows727 }728 var object map[string]any729 if json.Unmarshal(encoded, &object) == nil {730 if items, ok := object["items"].([]any); ok {731 return items732 }733 }734 return []any{value}735}736737func (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}741742// render fills the shared page data, applies private relay masking, and743// 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.Owner751 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.revision757 data.Methods = supportedMethods758 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 people765 // only. Guests see the sign-in page at the same address and come back766 // 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.Buffer778 if err := a.tmpl.ExecuteTemplate(&rendered, "page", data); err != nil {779 http.Error(writer, "render page: "+err.Error(), http.StatusInternalServerError)780 return781 }782 _, _ = writer.Write([]byte(injectBase(rendered.String(), data.Base)))783}784785// 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().Name788 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 the795 // variant that matches the active theme so the installed app's splash796 // 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}806807func (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 return811 }812 image, err := a.qr(text)813 if err != nil {814 http.Error(writer, "QR generation failed", http.StatusInternalServerError)815 return816 }817 writer.Header().Set("content-type", "image/svg+xml; charset=utf-8")818 _, _ = writer.Write(image)819}820821func (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 = feed836 }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}860861// agentsPage loads the agent list for the table and cards, then the grant862// 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 return869 }870 data.Feed = browseRows(result)871 // Callbacks are shown per agent; a failure here leaves the row empty872 // 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 break885 }886 }887 }888 if selected == "" {889 return890 }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 return896 }897 data.Event = detail898}899900// peoplePage loads the access requests for the owner and moderators. A901// 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 return906 }907 data.Review = true908 for _, row := range browseRows(result) {909 if plainString(valueMap(row)["pubkey"]) != "" {910 data.Requests = append(data.Requests, row)911 }912 }913}914915// pendingRequests keeps the access requests that wait for a decision.916func pendingRequests(rows []any) []any {917 var pending []any918 for _, row := range rows {919 if plainString(valueMap(row)["status"]) == "pending" {920 pending = append(pending, row)921 }922 }923 return pending924}925926// decidedRequests keeps the last decided access requests, newest first as927// the backend lists them.928func decidedRequests(rows []any) []any {929 var decided []any930 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 break936 }937 }938 return decided939}940941// agentState folds a grant's marks into one word for data-state: revoked942// covers expired grants too, since neither lets the agent publish again943// 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}957958// agentSince is the short time shown beside the state: when the grant was959// 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 last970 }971 return ""972}973974// 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 name979 }980 return shortID(plainString(values["pubkey"]))981}982983// 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}992993// 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 []string997 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}10261027// 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 counts1034}10351036// callbacksFor picks the callbacks one key registered.1037func callbacksFor(callbacks []any, pubkey string) []any {1038 var out []any1039 for _, callback := range callbacks {1040 if plainString(valueMap(callback)["owner"]) == pubkey {1041 out = append(out, callback)1042 }1043 }1044 return out1045}10461047// callbackState is active or paused; a paused callback carries the reason1048// in its lastStatus.1049func callbackState(callback any) string {1050 if valueMap(callback)["paused"] == true {1051 return "paused"1052 }1053 return "active"1054}10551056// 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}10651066// 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 counts1073}10741075// 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}10791080// agentByKey finds one grant in the agents list, for the form that replaces1081// 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 nil1085 }1086 for _, agent := range agents {1087 if plainString(valueMap(agent)["pubkey"]) == pubkey {1088 return agent1089 }1090 }1091 return nil1092}10931094// repoLines renders a grant's repositories one per line, the way the form1095// 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}11051106// siteLines renders a grant's sites one per line, the way the form takes1107// 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=" + ttl1116 }1117 if s["encrypted"] == true {1118 line += " encrypted"1119 }1120 lines = append(lines, line)1121 }1122 return strings.Join(lines, "\n")1123}11241125// 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}11331134var 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}11371138func (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}11421143func (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 return1147 }1148 actor, err := a.actor(request)1149 if err != nil || actor == "" {1150 http.Error(writer, "authentication required", http.StatusUnauthorized)1151 return1152 }1153 var method string1154 var params []json.RawMessage1155 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 return1163 }1164 method, params = body.Method, body.Params1165 } else if err := request.ParseForm(); err != nil {1166 http.Error(writer, "invalid form", http.StatusBadRequest)1167 return1168 } 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 return1178 }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 return1183 }1184 writeJSON(writer, http.StatusOK, map[string]any{"result": result})1185}11861187func 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}11921193func (a *App) resolveActor(request *http.Request) (string, error) {1194 if a.actor == nil {1195 return "", nil1196 }1197 return a.actor(request)1198}11991200func 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}12071208func 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 prefix1213 }1214 }1215 }1216 if request == nil || request.URL == nil {1217 return ""1218 }1219 return pathPrefix(request.URL.Path)1220}12211222func 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 path1255 case "people", "agents", "moderation", "rules", "identity", "connect", "data", "sync", "views", "health", "owner":1256 return path1257 case "inbox", "outbox", "private", "chat", "media", "search", "articles", "dm", "quiet", "site", "marmot", "grasp", "terms":1258 return path1259 default:1260 return "home"1261 }1262}