clone

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

main
tinyrelay / tinyclient / approvals.go
approvals.go 9.7K
1package tinyclient23import (4 "context"5 "encoding/json"6 "strconv"7 "strings"8)910// approvalView is one request as the Approvals page renders it. The backend11// answers browseapprovals with typed items; this flattens what the template12// needs so the markup stays plain.13type approvalView struct {14 ID, Kind, Type, State, Asker string15 CreatedAt, Expires int6416 // Where the request was made: the room, the repository or the relay.17 Where string18 // Title is the subject, or the content when there is no subject; Body is19 // the content when a subject is present.20 Title, Body string21 // About summarizes the a or e reference in one mono line, and Open is22 // the relay path that shows it.23 About, Open, Coordinate string24 Decision string25 AnsweredAt int6426 AnswerContent string27 Grant *grantRequestView28 // Room is the h tag of a room request; the answer to a native29 // interaction must carry it.30 Room string31 // Native marks a Tiny agent interaction, answered with the choices the32 // room card offers: Options as radio or checkbox inputs by Selection, a33 // free-text answer when Freeform, and only text when Selection is text.34 // Question is set for a question request or interaction.35 Native, Multiple, Freeform, Question bool36 Interaction, Selection string37 Options []chatActivityOption38}3940type grantRequestView struct {41 Raw string42 Error string43 Rows []grantScopeRow44}4546type grantScopeRow struct{ Label, Before, After string }4748// approvalViews returns the items in the given states, in the order the49// backend listed them.50func approvalViews(value any, base string, states ...string) []approvalView {51 var views []approvalView52 for _, raw := range collaborationSlice(value, "items") {53 item := valueMap(raw)54 state := plainString(item["state"])55 wanted := len(states) == 056 for _, s := range states {57 wanted = wanted || s == state58 }59 if !wanted {60 continue61 }62 views = append(views, approvalViewFrom(item, base))63 }64 return views65}6667func approvalViewFrom(item map[string]any, base string) approvalView {68 view := approvalView{ID: plainString(item["id"]), Kind: plainString(item["kind"]), Type: plainString(item["type"]), State: plainString(item["state"]), Asker: plainString(item["asker"]), CreatedAt: unixSeconds(item["created_at"]), Expires: unixSeconds(item["expires"])}69 subject, content := strings.TrimSpace(plainString(item["subject"])), strings.TrimSpace(plainString(item["content"]))70 view.Title, view.Body = subject, content71 if subject == "" {72 view.Title, view.Body = content, ""73 }74 view.Where = "the relay"75 view.Open = "/e/" + view.ID76 if about := valueMap(item["about"]); len(about) > 0 {77 coordinate, referenced, kind := plainString(about["coordinate"]), plainString(about["event"]), plainString(about["kind"])78 var parts []string79 if coordinate != "" {80 parts = append(parts, "about "+coordinate)81 if fields := strings.SplitN(coordinate, ":", 3); len(fields) == 3 && fields[0] == "30617" {82 view.Coordinate = coordinate83 view.Where = "repository " + fields[2]84 }85 }86 if referenced != "" {87 label := "event " + shortID(referenced)88 if kind != "" {89 label = "kind " + kind + " " + shortID(referenced)90 }91 parts = append(parts, label)92 }93 view.About = strings.Join(parts, " | ")94 if url := plainString(about["url"]); url != "" {95 view.Open = relayPath(url, base)96 }97 }98 if room := plainString(item["room"]); room != "" {99 view.Where = "#" + room100 view.Room = room101 }102 if answer := valueMap(item["answer"]); len(answer) > 0 {103 view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))104 }105 view.Question = view.Type == "question"106 // The same test the room card applies: a selection with options, or a107 // text selection on an event tagged tinyagent.108 rawOptions := collaborationSlice(item, "options")109 if selection := plainString(item["selection"]); selection != "" && (len(rawOptions) > 0 || eventHasTag(item, "tinyagent")) {110 view.Native = true111 view.Interaction, view.Selection = plainString(item["interaction"]), selection112 view.Question = view.Interaction == "question"113 view.Multiple = selection == "multiple"114 view.Freeform = plainString(item["freeform"]) == "true" || selection == "text"115 for _, rawOption := range rawOptions {116 option := valueMap(rawOption)117 view.Options = append(view.Options, chatActivityOption{ID: plainString(option["id"]), Label: plainString(option["label"])})118 }119 view.AnswerContent = nativeActivityAnswer(view.AnswerContent, view.Options)120 }121 if view.Type == "grant" {122 raw := valueMap(item["grant"])123 rows := []grantScopeRow{}124 if len(raw) > 0 {125 rows = grantScopeRows(raw)126 } else {127 raw = map[string]any{"request_id": view.ID, "agent": view.Asker, "operator": joinJSONValues(item["asked"])}128 }129 encoded, _ := json.Marshal(raw)130 view.Grant = &grantRequestView{Raw: string(encoded), Error: plainString(item["grant_error"]), Rows: rows}131 }132 return view133}134135func grantScopeRows(review map[string]any) []grantScopeRow {136 before := valueMap(review["before"])137 after := valueMap(review["after"])138 bs, as := valueMap(before["scope"]), valueMap(after["scope"])139 rows := []grantScopeRow{{"agent", shortID(plainString(before["pubkey"])), shortID(plainString(after["pubkey"]))}, {"name", plainString(before["name"]), plainString(after["name"])}, {"rooms", joinJSONValues(bs["rooms"]), joinJSONValues(as["rooms"])}, {"repos", joinGrantRepos(bs["repos"]), joinGrantRepos(as["repos"])}, {"wiki", plainString(bs["wiki"]), plainString(as["wiki"])}, {"jobs", plainString(bs["jobs"]), plainString(as["jobs"])}, {"sites", joinGrantSites(bs["sites"]), joinGrantSites(as["sites"])}, {"kinds", joinJSONValues(bs["kinds"]), joinJSONValues(as["kinds"])}, {"rate", plainString(bs["rate"]), plainString(as["rate"])}}140 for i := range rows {141 if rows[i].Before == "" {142 rows[i].Before = "none"143 }144 if rows[i].After == "" {145 rows[i].After = "none"146 }147 }148 return rows149}150151func joinJSONValues(value any) string {152 // JSON numbers do not unmarshal into []string. Render both numeric kind153 // lists and textual lists through their JSON representation so the review154 // table never silently loses a proposed permission.155 var values []any156 if encoded, err := json.Marshal(value); err == nil {157 _ = json.Unmarshal(encoded, &values)158 }159 parts := make([]string, 0, len(values))160 for _, item := range values {161 switch typed := item.(type) {162 case string:163 parts = append(parts, typed)164 case float64:165 parts = append(parts, strconv.FormatInt(int64(typed), 10))166 default:167 if encoded, err := json.Marshal(item); err == nil {168 parts = append(parts, string(encoded))169 }170 }171 }172 return strings.Join(parts, ", ")173}174175func joinGrantRepos(value any) string {176 var repos []map[string]any177 if encoded, err := json.Marshal(value); err == nil {178 _ = json.Unmarshal(encoded, &repos)179 }180 parts := make([]string, 0, len(repos))181 for _, repo := range repos {182 parts = append(parts, plainString(repo["owner"])+":"+plainString(repo["identifier"])+":"+plainString(repo["level"]))183 }184 return strings.Join(parts, ", ")185}186187func joinGrantSites(value any) string {188 var sites []map[string]any189 if encoded, err := json.Marshal(value); err == nil {190 _ = json.Unmarshal(encoded, &sites)191 }192 parts := make([]string, 0, len(sites))193 for _, site := range sites {194 part := plainString(site["label"])195 if ttl := plainString(site["ttl"]); ttl != "" && ttl != "0" {196 part += " ttl=" + ttl197 }198 if site["encrypted"] == true {199 part += " encrypted"200 }201 parts = append(parts, part)202 }203 return strings.Join(parts, ", ")204}205206// includeApproval makes sure the request a notification opened is on the207// page: when the listing does not carry it, the single request is read and208// placed first. A request that cannot be read leaves the listing as it is.209func (a *App) includeApproval(ctx context.Context, actor string, result any, id string) any {210 page := valueMap(result)211 items := collaborationSlice(result, "items")212 for _, item := range items {213 if plainString(valueMap(item)["id"]) == id {214 return result215 }216 }217 raw, _ := json.Marshal(map[string]any{"id": id})218 detail, err := a.backend.Query(ctx, "browseapproval", []json.RawMessage{raw}, actor)219 if err != nil {220 return result221 }222 item := valueMap(detail)["item"]223 if item == nil {224 return result225 }226 page["items"] = append([]any{item}, items...)227 return page228}229230// relayPath turns an absolute relay URL into the path the page links to, so231// tenant prefixes are applied once when the page is written.232func relayPath(url, base string) string {233 base = strings.TrimRight(base, "/")234 if base != "" && strings.HasPrefix(url, base+"/") {235 return strings.TrimPrefix(url, base)236 }237 if i := strings.Index(url, "://"); i >= 0 {238 if slash := strings.Index(url[i+3:], "/"); slash >= 0 {239 return url[i+3+slash:]240 }241 return "/"242 }243 return url244}245246// approvalCounts reads the counts object the backend returns beside the247// items, defaulting every state to zero.248func approvalCounts(value any) map[string]int {249 counts := map[string]int{"open": 0, "answered": 0, "expired": 0}250 encoded, err := json.Marshal(valueMap(value)["counts"])251 if err == nil {252 _ = json.Unmarshal(encoded, &counts)253 }254 return counts255}256257// approvalDevices lists the caller's devices as "categories" strings.258func approvalDevices(value any) []string {259 var devices []string260 for _, raw := range collaborationSlice(value, "devices") {261 var categories []string262 encoded, err := json.Marshal(valueMap(raw)["categories"])263 if err == nil {264 _ = json.Unmarshal(encoded, &categories)265 }266 if len(categories) == 0 {267 devices = append(devices, "no categories")268 continue269 }270 devices = append(devices, strings.Join(categories, ", "))271 }272 return devices273}