clone

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

feat/tinyagent
tinyrelay / internal / daemon / approvals_browse.go
approvals_browse.go 6.5K
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 "strings"14 "time"1516 "github.com/FelineStateMachine/tinyrelay/internal/event"17 "github.com/FelineStateMachine/tinyrelay/internal/storage"18)1920type 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}2728// approvalDevice is one of the caller's registered devices, as a fact for29// the panel: when it was registered and which categories it wakes for.30type approvalDevice struct {31 CreatedAt int64 `json:"created_at"`32 Categories []string `json:"categories"`33}3435func approvalBrowseMethod(method string) bool {36 return method == "browseapprovals" || method == "browseapproval"37}3839func (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 = 5051 }52 switch method {53 case "browseapprovals":54 return t.browseApprovals(ctx, actor, q)55 case "browseapproval":56 if q.ID == "" {57 q.ID = q.Event58 }59 return t.browseApproval(ctx, actor, q.ID)60 }61 return nil, errors.New("unsupported: browse operation")62}6364// browseApprovals lists the requests addressed to the caller. The counts65// cover every request the walk saw, so the panel can say how many wait even66// when the page shows only one state.67func (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 int6482 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, err87 }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 continue92 }93 counts[item.State]++94 if item.State == "open" && (oldest == 0 || item.CreatedAt < oldest) {95 oldest = item.CreatedAt96 }97 if q.State != "all" && q.State != item.State {98 continue99 }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 break108 }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, err116 }117 return map[string]any{"items": items, "next_cursor": next, "counts": counts, "oldest_open": oldest, "devices": devices}, nil118}119120// browseApproval returns one request with every reaction and reply from the121// asked keys, newest first. The asked people and the asker may read it.122func (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, err128 } else if ok {129 answers, err := t.approvalAnswers(ctx, []approvalItem{item}, time.Now().Unix())130 if err != nil {131 return nil, err132 }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, err137 }138 } else if item.Type == approvalWikiMerge {139 status, err := t.wikiMergeStatus(ctx, item.ID, actor)140 if err != nil {141 return nil, err142 }143 if status.Status == wikiMergeOpen {144 item.State = "open"145 item.Answer = nil146 } else {147 item.State = "answered"148 item.Answer, err = t.wikiReactionAnswer(ctx, status.Reaction)149 if err != nil {150 return nil, err151 }152 }153 }154 history := answers[id]155 if history == nil {156 history = []event.Event{}157 }158 return map[string]any{"item": item, "answers": history}, nil159 }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, err164 }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, err180 }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}, nil188}189190// approvalDevices lists the caller's registered devices with the categories191// each one chose. Endpoints and keys stay out of the result.192func (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, err196 }197 defer rows.Close()198 devices := []approvalDevice{}199 for rows.Next() {200 var device approvalDevice201 var raw string202 if err := rows.Scan(&device.CreatedAt, &raw); err != nil {203 return nil, err204 }205 device.Categories = pushCategories206 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}