| 1 | package daemon |
| 2 | |
| 3 | // Approval browsing: the requests addressed to the caller with their state |
| 4 | // and counts, and one request with every answer it received. Both read |
| 5 | // through the tenant gate with the caller's session, like the other browse |
| 6 | // methods. |
| 7 | |
| 8 | import ( |
| 9 | "context" |
| 10 | "encoding/json" |
| 11 | "errors" |
| 12 | "fmt" |
| 13 | "strings" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 17 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 18 | ) |
| 19 | |
| 20 | type approvalBrowseRequest struct { |
| 21 | Limit int `json:"limit"` |
| 22 | Cursor string `json:"cursor"` |
| 23 | State string `json:"state"` |
| 24 | ID string `json:"id"` |
| 25 | Event string `json:"event"` |
| 26 | } |
| 27 | |
| 28 | // approvalDevice is one of the caller's registered devices, as a fact for |
| 29 | // the panel: when it was registered and which categories it wakes for. |
| 30 | type approvalDevice struct { |
| 31 | CreatedAt int64 `json:"created_at"` |
| 32 | Categories []string `json:"categories"` |
| 33 | } |
| 34 | |
| 35 | func approvalBrowseMethod(method string) bool { |
| 36 | return method == "browseapprovals" || method == "browseapproval" |
| 37 | } |
| 38 | |
| 39 | func (t *Tenant) executeApprovals(ctx context.Context, actor, method string, params []json.RawMessage) (any, error) { |
| 40 | if actor == "" { |
| 41 | return nil, errors.New("auth-required: sign in to see requests addressed to you" ) |
| 42 | } |
| 43 | q := approvalBrowseRequest{} |
| 44 | if len(params) > 0 { |
| 45 | if err := json.Unmarshal(params[0], &q); err != nil { |
| 46 | return nil, fmt.Errorf("invalid: approval parameters: %w" , err) |
| 47 | } |
| 48 | } |
| 49 | if q.Limit <= 0 || q.Limit > 100 { |
| 50 | q.Limit = 50 |
| 51 | } |
| 52 | switch method { |
| 53 | case "browseapprovals" : |
| 54 | return t.browseApprovals(ctx, actor, q) |
| 55 | case "browseapproval" : |
| 56 | if q.ID == "" { |
| 57 | q.ID = q.Event |
| 58 | } |
| 59 | return t.browseApproval(ctx, actor, q.ID) |
| 60 | } |
| 61 | return nil, errors.New("unsupported: browse operation" ) |
| 62 | } |
| 63 | |
| 64 | // browseApprovals lists the requests addressed to the caller. The counts |
| 65 | // cover every request the walk saw, so the panel can say how many wait even |
| 66 | // when the page shows only one state. |
| 67 | func (t *Tenant) browseApprovals(ctx context.Context, actor string, q approvalBrowseRequest) (any, error) { |
| 68 | if q.State == "" { |
| 69 | q.State = "all" |
| 70 | } |
| 71 | if !containsString([]string{"open" , "answered" , "expired" , "all" }, q.State) { |
| 72 | return nil, errors.New("invalid: approval state" ) |
| 73 | } |
| 74 | cursor, err := parseCollaborationCursor(q.Cursor) |
| 75 | if err != nil { |
| 76 | return nil, errors.New("invalid: approval cursor" ) |
| 77 | } |
| 78 | session := browseSession(t, actor) |
| 79 | items := make([]approvalItem, 0, q.Limit) |
| 80 | counts := map[string]int{"open" : 0, "answered" : 0, "expired" : 0} |
| 81 | var oldest int64 |
| 82 | next := "" |
| 83 | for scanned := 0; scanned < approvalScan; scanned += 100 { |
| 84 | page, more, err := t.approvalsFor(ctx, actor, cursor, 100) |
| 85 | if err != nil { |
| 86 | return nil, err |
| 87 | } |
| 88 | for _, item := range page { |
| 89 | cursor = &storage.EventCursor{CreatedAt: item.CreatedAt, ID: item.ID} |
| 90 | if !t.gate.CanSee(ctx, item.Event, session, nil) { |
| 91 | continue |
| 92 | } |
| 93 | counts[item.State]++ |
| 94 | if item.State == "open" && (oldest == 0 || item.CreatedAt < oldest) { |
| 95 | oldest = item.CreatedAt |
| 96 | } |
| 97 | if q.State != "all" && q.State != item.State { |
| 98 | continue |
| 99 | } |
| 100 | if len(items) < q.Limit { |
| 101 | items = append(items, item) |
| 102 | } else if next == "" { |
| 103 | next = approvalCursor(items[len(items)-1]) |
| 104 | } |
| 105 | } |
| 106 | if !more || len(page) == 0 { |
| 107 | break |
| 108 | } |
| 109 | if next == "" && len(items) == q.Limit { |
| 110 | next = approvalCursor(items[len(items)-1]) |
| 111 | } |
| 112 | } |
| 113 | devices, err := t.approvalDevices(ctx, actor) |
| 114 | if err != nil { |
| 115 | return nil, err |
| 116 | } |
| 117 | return map[string]any{"items" : items, "next_cursor" : next, "counts" : counts, "oldest_open" : oldest, "devices" : devices}, nil |
| 118 | } |
| 119 | |
| 120 | // browseApproval returns one request with every reaction and reply from the |
| 121 | // asked keys, newest first. The asked people and the asker may read it. |
| 122 | func (t *Tenant) browseApproval(ctx context.Context, actor, id string) (any, error) { |
| 123 | if len(id) != 64 { |
| 124 | return nil, errors.New("invalid: approval id" ) |
| 125 | } |
| 126 | if item, ok, err := t.wikiApprovalByID(ctx, actor, id); err != nil { |
| 127 | return nil, err |
| 128 | } else if ok { |
| 129 | answers, err := t.approvalAnswers(ctx, []approvalItem{item}, time.Now().Unix()) |
| 130 | if err != nil { |
| 131 | return nil, err |
| 132 | } |
| 133 | approvalSettle(&item, answers[id], time.Now().Unix()) |
| 134 | if item.Type == approvalWikiProposal { |
| 135 | if err := t.settleWikiProposal(ctx, &item, t.wikiRoles()); err != nil { |
| 136 | return nil, err |
| 137 | } |
| 138 | } else if item.Type == approvalWikiMerge { |
| 139 | status, err := t.wikiMergeStatus(ctx, item.ID, actor) |
| 140 | if err != nil { |
| 141 | return nil, err |
| 142 | } |
| 143 | if status.Status == wikiMergeOpen { |
| 144 | item.State = "open" |
| 145 | item.Answer = nil |
| 146 | } else { |
| 147 | item.State = "answered" |
| 148 | item.Answer, err = t.wikiReactionAnswer(ctx, status.Reaction) |
| 149 | if err != nil { |
| 150 | return nil, err |
| 151 | } |
| 152 | } |
| 153 | } |
| 154 | history := answers[id] |
| 155 | if history == nil { |
| 156 | history = []event.Event{} |
| 157 | } |
| 158 | return map[string]any{"item" : item, "answers" : history}, nil |
| 159 | } |
| 160 | session := browseSession(t, actor) |
| 161 | rows, err := t.Query(ctx, []event.Filter{{IDs: []string{id}, Kinds: approvalKinds, Limit: intPtr(1)}}, session) |
| 162 | if err != nil { |
| 163 | return nil, err |
| 164 | } |
| 165 | if len(rows) != 1 { |
| 166 | return nil, errors.New("not found: request" ) |
| 167 | } |
| 168 | kind, ok := approvalRequest(rows[0]) |
| 169 | if !ok { |
| 170 | return nil, errors.New("not found: request" ) |
| 171 | } |
| 172 | item := approvalItemFrom(rows[0], kind, strings.TrimRight(t.publicURL, "/" )) |
| 173 | if item.Asker != actor && !containsString(item.Asked, actor) { |
| 174 | return nil, errors.New("restricted: this request is not addressed to you" ) |
| 175 | } |
| 176 | now := time.Now().Unix() |
| 177 | answers, err := t.approvalAnswers(ctx, []approvalItem{item}, now) |
| 178 | if err != nil { |
| 179 | return nil, err |
| 180 | } |
| 181 | approvalSettle(&item, answers[item.ID], now) |
| 182 | t.reviewGrantApproval(ctx, &item, now) |
| 183 | history := answers[item.ID] |
| 184 | if history == nil { |
| 185 | history = []event.Event{} |
| 186 | } |
| 187 | return map[string]any{"item" : item, "answers" : history}, nil |
| 188 | } |
| 189 | |
| 190 | // approvalDevices lists the caller's registered devices with the categories |
| 191 | // each one chose. Endpoints and keys stay out of the result. |
| 192 | func (t *Tenant) approvalDevices(ctx context.Context, pubkey string) ([]approvalDevice, error) { |
| 193 | rows, err := t.store.DB().QueryContext(ctx, "SELECT created_at, categories FROM web_push WHERE pubkey=? ORDER BY created_at" , pubkey) |
| 194 | if err != nil { |
| 195 | return nil, err |
| 196 | } |
| 197 | defer rows.Close() |
| 198 | devices := []approvalDevice{} |
| 199 | for rows.Next() { |
| 200 | var device approvalDevice |
| 201 | var raw string |
| 202 | if err := rows.Scan(&device.CreatedAt, &raw); err != nil { |
| 203 | return nil, err |
| 204 | } |
| 205 | device.Categories = pushCategories |
| 206 | if allowed := decodeCategories(raw); allowed != nil { |
| 207 | device.Categories = []string{} |
| 208 | for _, category := range pushCategories { |
| 209 | if allowed[category] { |
| 210 | device.Categories = append(device.Categories, category) |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | devices = append(devices, device) |
| 215 | } |
| 216 | return devices, rows.Err() |
| 217 | } |