| 1 | package tinyclient |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strconv" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | // approvalView is one request as the Approvals page renders it. The backend |
| 11 | // answers browseapprovals with typed items; this flattens what the template |
| 12 | // needs so the markup stays plain. |
| 13 | type approvalView struct { |
| 14 | ID, Kind, Type, State, Asker string |
| 15 | CreatedAt, Expires int64 |
| 16 | // Where the request was made: the room, the repository or the relay. |
| 17 | Where string |
| 18 | // Title is the subject, or the content when there is no subject; Body is |
| 19 | // the content when a subject is present. |
| 20 | Title, Body string |
| 21 | // About summarizes the a or e reference in one mono line, and Open is |
| 22 | // the relay path that shows it. |
| 23 | About, Open, Coordinate string |
| 24 | Decision string |
| 25 | AnsweredAt int64 |
| 26 | AnswerContent string |
| 27 | Grant *grantRequestView |
| 28 | } |
| 29 | |
| 30 | type grantRequestView struct { |
| 31 | Raw string |
| 32 | Error string |
| 33 | Rows []grantScopeRow |
| 34 | } |
| 35 | |
| 36 | type grantScopeRow struct{ Label, Before, After string } |
| 37 | |
| 38 | // approvalViews returns the items in the given states, in the order the |
| 39 | // backend listed them. |
| 40 | func approvalViews(value any, base string, states ...string) []approvalView { |
| 41 | var views []approvalView |
| 42 | for _, raw := range collaborationSlice(value, "items" ) { |
| 43 | item := valueMap(raw) |
| 44 | state := plainString(item["state" ]) |
| 45 | wanted := len(states) == 0 |
| 46 | for _, s := range states { |
| 47 | wanted = wanted || s == state |
| 48 | } |
| 49 | if !wanted { |
| 50 | continue |
| 51 | } |
| 52 | views = append(views, approvalViewFrom(item, base)) |
| 53 | } |
| 54 | return views |
| 55 | } |
| 56 | |
| 57 | func 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, content |
| 61 | if subject == "" { |
| 62 | view.Title, view.Body = content, "" |
| 63 | } |
| 64 | view.Where = "the relay" |
| 65 | view.Open = "/e/" + view.ID |
| 66 | if about := valueMap(item["about" ]); len(about) > 0 { |
| 67 | coordinate, referenced, kind := plainString(about["coordinate" ]), plainString(about["event" ]), plainString(about["kind" ]) |
| 68 | var parts []string |
| 69 | if coordinate != "" { |
| 70 | parts = append(parts, "about " +coordinate) |
| 71 | if fields := strings.SplitN(coordinate, ":" , 3); len(fields) == 3 && fields[0] == "30617" { |
| 72 | view.Coordinate = coordinate |
| 73 | 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 = "#" + room |
| 90 | } |
| 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 view |
| 106 | } |
| 107 | |
| 108 | func 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 rows |
| 122 | } |
| 123 | |
| 124 | func joinJSONValues(value any) string { |
| 125 | // JSON numbers do not unmarshal into []string. Render both numeric kind |
| 126 | // lists and textual lists through their JSON representation so the review |
| 127 | // table never silently loses a proposed permission. |
| 128 | var values []any |
| 129 | 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 | } |
| 147 | |
| 148 | func joinGrantRepos(value any) string { |
| 149 | var repos []map[string]any |
| 150 | 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 | } |
| 159 | |
| 160 | func joinGrantSites(value any) string { |
| 161 | var sites []map[string]any |
| 162 | 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=" + ttl |
| 170 | } |
| 171 | if site["encrypted" ] == true { |
| 172 | part += " encrypted" |
| 173 | } |
| 174 | parts = append(parts, part) |
| 175 | } |
| 176 | return strings.Join(parts, ", " ) |
| 177 | } |
| 178 | |
| 179 | // includeApproval makes sure the request a notification opened is on the |
| 180 | // page: when the listing does not carry it, the single request is read and |
| 181 | // placed first. A request that cannot be read leaves the listing as it is. |
| 182 | func (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 result |
| 188 | } |
| 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 result |
| 194 | } |
| 195 | item := valueMap(detail)["item" ] |
| 196 | if item == nil { |
| 197 | return result |
| 198 | } |
| 199 | page["items" ] = append([]any{item}, items...) |
| 200 | return page |
| 201 | } |
| 202 | |
| 203 | // relayPath turns an absolute relay URL into the path the page links to, so |
| 204 | // tenant prefixes are applied once when the page is written. |
| 205 | func 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 url |
| 217 | } |
| 218 | |
| 219 | // approvalCounts reads the counts object the backend returns beside the |
| 220 | // items, defaulting every state to zero. |
| 221 | func 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 counts |
| 228 | } |
| 229 | |
| 230 | // approvalDevices lists the caller's devices as "categories" strings. |
| 231 | func approvalDevices(value any) []string { |
| 232 | var devices []string |
| 233 | for _, raw := range collaborationSlice(value, "devices" ) { |
| 234 | var categories []string |
| 235 | 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 | continue |
| 242 | } |
| 243 | devices = append(devices, strings.Join(categories, ", " )) |
| 244 | } |
| 245 | return devices |
| 246 | } |