carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "errors"
5 "fmt"
6 "log"
7 "net/http"
8 "slices"
9 "strings"
10 "time"
11
12 "github.com/carlosframework/platform/internal/plans"
13)
14
15// The account plan door (plans-caps-packs spec §4, §8.7, §9.4): a fleet
16// operator reads or sets an account's plan, packs and sponsored limits.
17// Everyone else gets 404, the store-approval doors' convention. With no
18// catalog configured it answers 409: plans are off.
19
20type accountPlanReply struct {
21 Account string `json:"account"`
22 CatalogID string `json:"catalog_id"`
23 Plan string `json:"plan"`
24 Packs map[string]int `json:"packs"`
25 Source string `json:"source"`
26 Sponsored *plans.Limits `json:"sponsored,omitempty"`
27 Reason string `json:"reason,omitempty"`
28 UpdatedBy string `json:"updated_by,omitempty"`
29 Revision int64 `json:"revision"`
30 PublishedRevision int64 `json:"published_revision"`
31 Limits *plans.Limits `json:"limits,omitempty"`
32 LimitsError string `json:"limits_error,omitempty"`
33}
34
35type setAccountPlanRequest struct {
36 Plan string `json:"plan"`
37 Packs map[string]int `json:"packs"`
38 Sponsored *plans.Limits `json:"sponsored"`
39 Reason string `json:"reason"`
40}
41
42// accountPlanDoor is the shared gate: an operator, a known account, and a
43// catalog. It writes the refusal itself.
44func (s *Server) accountPlanDoor(w http.ResponseWriter, r *http.Request) (string, *Account, bool) {
45 addr, ok := s.cliUser(w, r)
46 if !ok {
47 return "", nil, false
48 }
49 isOperator, err := s.store.IsOperator(addr)
50 if err != nil {
51 log.Printf("console: api account plan: is-operator: %v", err)
52 http.NotFound(w, r)
53 return "", nil, false
54 }
55 if !isOperator {
56 http.NotFound(w, r)
57 return "", nil, false
58 }
59 acct, err := s.store.AccountBySqid(r.PathValue("account"))
60 if err != nil {
61 log.Printf("console: api account plan: account lookup: %v", err)
62 http.NotFound(w, r)
63 return "", nil, false
64 }
65 if acct == nil {
66 http.NotFound(w, r)
67 return "", nil, false
68 }
69 if s.cfg.PlanCatalog == nil {
70 jsonError(w, http.StatusConflict, planCopyNoCatalog)
71 return "", nil, false
72 }
73 return addr, acct, true
74}
75
76func (s *Server) handleAPIAccountPlanGet(w http.ResponseWriter, r *http.Request) {
77 _, acct, ok := s.accountPlanDoor(w, r)
78 if !ok {
79 return
80 }
81 reply, err := s.accountPlanReplyFor(acct)
82 if err != nil {
83 log.Printf("console: api account plan %s: %v", acct.Sqid, err)
84 jsonError(w, http.StatusInternalServerError, planCopySaveFailed)
85 return
86 }
87 writeJSON(w, http.StatusOK, reply)
88}
89
90func (s *Server) handleAPIAccountPlanSet(w http.ResponseWriter, r *http.Request) {
91 addr, acct, ok := s.accountPlanDoor(w, r)
92 if !ok {
93 return
94 }
95 if !s.allowWrite(Principal{Address: addr}, time.Now()) {
96 jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
97 return
98 }
99 var req setAccountPlanRequest
100 // Strict: a misspelt sponsored limit must be refused, not dropped.
101 if !readJSONStrictLimit(w, r, &req, 8<<10) {
102 return
103 }
104 reason := strings.TrimSpace(req.Reason)
105 if reason == "" {
106 jsonError(w, http.StatusBadRequest, planCopyReasonRequired)
107 return
108 }
109 if msg := planRequestProblem(s.cfg.PlanCatalog, req); msg != "" {
110 jsonError(w, http.StatusBadRequest, msg)
111 return
112 }
113 if _, err := s.cfg.PlanCatalog.Effective(req.Plan, req.Packs, req.Sponsored); err != nil {
114 jsonError(w, http.StatusBadRequest, err.Error())
115 return
116 }
117 source := PlanSourceOperator
118 if req.Sponsored != nil {
119 source = PlanSourceSponsored
120 }
121 now := time.Now()
122 if _, err := s.store.SetAccountPlan(acct.ID, req.Plan, req.Packs, source, req.Sponsored, reason, addr, now); err != nil {
123 if errors.Is(err, ErrAccountGone) {
124 http.NotFound(w, r)
125 return
126 }
127 log.Printf("console: api account plan %s: save: %v", acct.Sqid, err)
128 jsonError(w, http.StatusInternalServerError, planCopySaveFailed)
129 return
130 }
131 if row, err := s.store.AccountPlan(acct.ID); err != nil {
132 log.Printf("console: api account plan %s: reread: %v", acct.Sqid, err)
133 } else if row != nil && s.cfg.Releases != nil {
134 // Best effort: the pass retries anything this misses.
135 if err := s.publishAccountLimits(r.Context(), *row, now); err != nil {
136 log.Printf("console: api account plan %s: publish: %v", acct.Sqid, err)
137 }
138 }
139 reply, err := s.accountPlanReplyFor(acct)
140 if err != nil {
141 log.Printf("console: api account plan %s: %v", acct.Sqid, err)
142 jsonError(w, http.StatusInternalServerError, planCopySaveFailed)
143 return
144 }
145 writeJSON(w, http.StatusOK, reply)
146}
147
148// planRequestProblem names what is wrong with a requested plan and packs in
149// words an operator can act on, or "" when the catalog can price it.
150func planRequestProblem(cat *plans.Catalog, req setAccountPlanRequest) string {
151 p, ok := cat.Plans[req.Plan]
152 if !ok {
153 return fmt.Sprintf(planCopyUnknownPlan, req.Plan)
154 }
155 names := make([]string, 0, len(req.Packs))
156 for name := range req.Packs {
157 names = append(names, name)
158 }
159 slices.Sort(names)
160 for _, name := range names {
161 pk, ok := cat.Packs[name]
162 if !ok {
163 return fmt.Sprintf(planCopyUnknownPack, name)
164 }
165 if !slices.Contains(p.Packs, name) {
166 return fmt.Sprintf(planCopyPackNotOffered, req.Plan, name)
167 }
168 if q := req.Packs[name]; q < 1 || q > pk.MaxQuantity {
169 return fmt.Sprintf(planCopyPackQuantity, name, pk.MaxQuantity)
170 }
171 }
172 return ""
173}
174
175func (s *Server) accountPlanReplyFor(acct *Account) (accountPlanReply, error) {
176 cat := s.cfg.PlanCatalog
177 row, err := s.store.AccountPlan(acct.ID)
178 if err != nil {
179 return accountPlanReply{}, err
180 }
181 reply := accountPlanReply{Account: acct.Sqid, CatalogID: cat.ID(), Plan: cat.DefaultPlan,
182 Packs: map[string]int{}, Source: PlanSourceDefault}
183 if row != nil {
184 reply.Plan, reply.Packs, reply.Source = row.Plan, row.Packs, row.Source
185 reply.Sponsored, reply.Reason, reply.UpdatedBy = row.Sponsored, row.Reason, row.UpdatedBy
186 reply.Revision, reply.PublishedRevision = row.Revision, row.PublishedRevision
187 if row.PublishedCatalog != cat.ID() {
188 reply.PublishedRevision = 0
189 }
190 }
191 if l, err := cat.Effective(reply.Plan, reply.Packs, reply.Sponsored); err != nil {
192 reply.LimitsError = err.Error()
193 } else {
194 reply.Limits = &l
195 }
196 return reply, nil
197}
198