clone

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

feat/tinyagent
tinyrelay / tinyclient / approvals.go
approvals.go 8.2K
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}2930type grantRequestView struct {31 Raw string32 Error string33 Rows []grantScopeRow34}3536type grantScopeRow struct{ Label, Before, After string }3738// approvalViews returns the items in the given states, in the order the39// backend listed them.40func approvalViews(value any, base string, states ...string) []approvalView {41 var views []approvalView42 for _, raw := range collaborationSlice(value, "items") {43 item := valueMap(raw)44 state := plainString(item["state"])45 wanted := len(states) == 046 for _, s := range states {47 wanted = wanted || s == state48 }49 if !wanted {50 continue51 }52 views = append(views, approvalViewFrom(item, base))53 }54 return views55}5657func approvalViewFrom(item map[string]any, base string) approvalView {58 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"])}59 subject, content := strings.TrimSpace(plainString(item["subject"])), strings.TrimSpace(plainString(item["content"]))60 view.Title, view.Body = subject, content61 if subject == "" {62 view.Title, view.Body = content, ""63 }64 view.Where = "the relay"65 view.Open = "/e/" + view.ID66 if about := valueMap(item["about"]); len(about) > 0 {67 coordinate, referenced, kind := plainString(about["coordinate"]), plainString(about["event"]), plainString(about["kind"])68 var parts []string69 if coordinate != "" {70 parts = append(parts, "about "+coordinate)71 if fields := strings.SplitN(coordinate, ":", 3); len(fields) == 3 && fields[0] == "30617" {72 view.Coordinate = coordinate73 view.Where = "repository " + fields[2]74 }75 }76 if referenced != "" {77 label := "event " + shortID(referenced)78 if kind != "" {79 label = "kind " + kind + " " + shortID(referenced)80 }81 parts = append(parts, label)82 }83 view.About = strings.Join(parts, " | ")84 if url := plainString(about["url"]); url != "" {85 view.Open = relayPath(url, base)86 }87 }88 if room := plainString(item["room"]); room != "" {89 view.Where = "#" + room90 }91 if answer := valueMap(item["answer"]); len(answer) > 0 {92 view.Decision, view.AnsweredAt, view.AnswerContent = plainString(answer["decision"]), unixSeconds(answer["created_at"]), strings.TrimSpace(plainString(answer["content"]))93 }94 if view.Type == "grant" {95 raw := valueMap(item["grant"])96 rows := []grantScopeRow{}97 if len(raw) > 0 {98 rows = grantScopeRows(raw)99 } else {100 raw = map[string]any{"request_id": view.ID, "agent": view.Asker, "operator": joinJSONValues(item["asked"])}101 }102 encoded, _ := json.Marshal(raw)103 view.Grant = &grantRequestView{Raw: string(encoded), Error: plainString(item["grant_error"]), Rows: rows}104 }105 return view106}107108func grantScopeRows(review map[string]any) []grantScopeRow {109 before := valueMap(review["before"])110 after := valueMap(review["after"])111 bs, as := valueMap(before["scope"]), valueMap(after["scope"])112 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"])}}113 for i := range rows {114 if rows[i].Before == "" {115 rows[i].Before = "none"116 }117 if rows[i].After == "" {118 rows[i].After = "none"119 }120 }121 return rows122}123124func joinJSONValues(value any) string {125 // JSON numbers do not unmarshal into []string. Render both numeric kind126 // lists and textual lists through their JSON representation so the review127 // table never silently loses a proposed permission.128 var values []any129 if encoded, err := json.Marshal(value); err == nil {130 _ = json.Unmarshal(encoded, &values)131 }132 parts := make([]string, 0, len(values))133 for _, item := range values {134 switch typed := item.(type) {135 case string:136 parts = append(parts, typed)137 case float64:138 parts = append(parts, strconv.FormatInt(int64(typed), 10))139 default:140 if encoded, err := json.Marshal(item); err == nil {141 parts = append(parts, string(encoded))142 }143 }144 }145 return strings.Join(parts, ", ")146}147148func joinGrantRepos(value any) string {149 var repos []map[string]any150 if encoded, err := json.Marshal(value); err == nil {151 _ = json.Unmarshal(encoded, &repos)152 }153 parts := make([]string, 0, len(repos))154 for _, repo := range repos {155 parts = append(parts, plainString(repo["owner"])+":"+plainString(repo["identifier"])+":"+plainString(repo["level"]))156 }157 return strings.Join(parts, ", ")158}159160func joinGrantSites(value any) string {161 var sites []map[string]any162 if encoded, err := json.Marshal(value); err == nil {163 _ = json.Unmarshal(encoded, &sites)164 }165 parts := make([]string, 0, len(sites))166 for _, site := range sites {167 part := plainString(site["label"])168 if ttl := plainString(site["ttl"]); ttl != "" && ttl != "0" {169 part += " ttl=" + ttl170 }171 if site["encrypted"] == true {172 part += " encrypted"173 }174 parts = append(parts, part)175 }176 return strings.Join(parts, ", ")177}178179// includeApproval makes sure the request a notification opened is on the180// page: when the listing does not carry it, the single request is read and181// placed first. A request that cannot be read leaves the listing as it is.182func (a *App) includeApproval(ctx context.Context, actor string, result any, id string) any {183 page := valueMap(result)184 items := collaborationSlice(result, "items")185 for _, item := range items {186 if plainString(valueMap(item)["id"]) == id {187 return result188 }189 }190 raw, _ := json.Marshal(map[string]any{"id": id})191 detail, err := a.backend.Query(ctx, "browseapproval", []json.RawMessage{raw}, actor)192 if err != nil {193 return result194 }195 item := valueMap(detail)["item"]196 if item == nil {197 return result198 }199 page["items"] = append([]any{item}, items...)200 return page201}202203// relayPath turns an absolute relay URL into the path the page links to, so204// tenant prefixes are applied once when the page is written.205func relayPath(url, base string) string {206 base = strings.TrimRight(base, "/")207 if base != "" && strings.HasPrefix(url, base+"/") {208 return strings.TrimPrefix(url, base)209 }210 if i := strings.Index(url, "://"); i >= 0 {211 if slash := strings.Index(url[i+3:], "/"); slash >= 0 {212 return url[i+3+slash:]213 }214 return "/"215 }216 return url217}218219// approvalCounts reads the counts object the backend returns beside the220// items, defaulting every state to zero.221func approvalCounts(value any) map[string]int {222 counts := map[string]int{"open": 0, "answered": 0, "expired": 0}223 encoded, err := json.Marshal(valueMap(value)["counts"])224 if err == nil {225 _ = json.Unmarshal(encoded, &counts)226 }227 return counts228}229230// approvalDevices lists the caller's devices as "categories" strings.231func approvalDevices(value any) []string {232 var devices []string233 for _, raw := range collaborationSlice(value, "devices") {234 var categories []string235 encoded, err := json.Marshal(valueMap(raw)["categories"])236 if err == nil {237 _ = json.Unmarshal(encoded, &categories)238 }239 if len(categories) == 0 {240 devices = append(devices, "no categories")241 continue242 }243 devices = append(devices, strings.Join(categories, ", "))244 }245 return devices246}