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