carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The action confirm step: a server-rendered interstitial between clicking a
2// button that changes what serves and it happening. One page, one card: what
3// is about to happen in plain sentences, one button that does it, one link
4// that does not.
5//
6// This is a UI affordance, not a gate — the security gates (session, CSRF,
7// owner 404, requireFreshAuth) neither move nor weaken for it, and a
8// hand-written POST with confirmed=1 meets exactly the doors a button does.
9// Placement inside each handler is therefore load-bearing:
10//
11// - AFTER CSRF and input validation, so the card describes a request that
12// would otherwise proceed, never an invalid one.
13// - AFTER any owner-only 404, never before it: a member probing an
14// owner-only action must meet the byte-identical 404, not a confirm card
15// that concedes the action exists for somebody.
16// - BEFORE requireFreshAuth, so the passkey round trip replays the POST
17// confirmed — confirm asks "do you mean it?", step-up asks "is it you?",
18// in that order, each exactly once.
19package console
20
21import (
22 "context"
23 "errors"
24 "net/http"
25 "net/url"
26 "os"
27 "strconv"
28 "time"
29
30 "github.com/carlosframework/platform/internal/release"
31)
32
33// ConfirmView is everything the interstitial renders.
34type ConfirmView struct {
35 Title string
36 // Lines are the card's sentences: current state, then what changes.
37 Lines []string
38 // Warn is the one red line, for the part that must not be skimmed past.
39 Warn string
40 // Note is the quiet line after the button — what happens next (a bake
41 // window, a passkey prompt), muted.
42 Note string
43 // Action is the POST target; Fields the hidden name/value pairs carried
44 // through, csrf included. The confirmed=1 marker is the template's.
45 Action string
46 Fields [][2]string
47 // Checkbox, optional: a real choice that belongs at confirm time (the
48 // promote-to-stable bake opt-in). Rendered only when Name is non-empty.
49 CheckboxName string
50 CheckboxLabel string
51 CheckboxChecked bool
52 Button string
53 Danger bool
54 // CancelTab is which app tab the cancel link lands on ("" = Overview);
55 // renderConfirm derives the URL from it, since only the assembled page
56 // knows the account's link segment.
57 CancelTab string
58 CancelURL string
59}
60
61// confirmed reports whether this POST already carries the confirm step's
62// approval. The zero value of a form nobody edited is absence, so any other
63// value stays unconfirmed.
64func confirmed(r *http.Request) bool { return r.FormValue("confirmed") == "1" }
65
66// renderConfirm renders the interstitial with the account chrome, titled by
67// the action. 200, not a redirect: the card IS this POST's response, and the
68// resubmit it hosts is a fresh POST of the same fields plus confirmed=1.
69func (s *Server) renderConfirm(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app string, cv *ConfirmView) {
70 p := s.accountPage(w, r, addr, raw, acct, role)
71 p.Title = cv.Title
72 p.AppName = app
73 p.ActiveNav = "apps"
74 if cv.CancelURL == "" {
75 cv.CancelURL = p.Nav + "/apps/" + url.PathEscape(app)
76 if cv.CancelTab != "" {
77 cv.CancelURL += "/" + cv.CancelTab
78 }
79 }
80 p.Confirm = cv
81 s.render(w, http.StatusOK, "confirm_action", p)
82}
83
84// backTab maps the action forms' back field onto the tab a cancel link (and
85// nothing else) should land on. Only values the redirects themselves honor.
86func backTab(r *http.Request) string {
87 if tab := releaseBackTab(r); tab != "" {
88 return tab
89 }
90 return ""
91}
92
93// holdText renders a bake hold the way the card speaks: "~24h".
94func holdText(d time.Duration) string {
95 return "~" + strconv.Itoa(int(d.Hours())) + "h"
96}
97
98// confirmPromote is the promote door's card. Everything here is best-effort
99// reading for the card's sentences — the confirmed POST re-validates from
100// scratch, so a read that fails here costs precision, never safety.
101//
102// pipe is the caller's own already-read copy (handlePromote reads it before
103// the confirmed check, to gate the owner-only rung correctly) — not read
104// again here, so the card and the write it precedes can never disagree about
105// which pipeline they saw. Its hold window, and whether a bake applies at
106// all, mirror handlePromote's post-confirm logic (holdWindow, and
107// `pipe != nil || baking`) exactly: a pipelined channel bakes on its OWN
108// window regardless of the legacy production flag (spec §3), and the
109// legacy enable_bake checkbox — which only ever flips that flag — has
110// nothing to offer a pipelined channel, so it stays hidden for one.
111func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, version string, pipe *release.Pipeline) {
112 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
113 defer cancel()
114 cv := &ConfirmView{
115 Title: "Promote " + version + " to " + channel + "?",
116 Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/promote",
117 Fields: [][2]string{
118 {"csrf", raw}, {"channel", channel}, {"version", version}, {"back", r.FormValue("back")},
119 },
120 Button: "Promote " + version,
121 CancelTab: backTab(r),
122 }
123 cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
124 switch {
125 case errors.Is(err, os.ErrNotExist):
126 cv.Lines = append(cv.Lines, "This creates channel "+channel+", serving "+version+" as soon as a host follows it.")
127 case err != nil:
128 cv.Lines = append(cv.Lines, "After this, "+channel+" serves "+version+".")
129 case cur.Version == version:
130 cv.Lines = append(cv.Lines, channel+" already serves "+version+" — confirming changes nothing.")
131 default:
132 cv.Lines = append(cv.Lines,
133 channel+" serves "+cur.Version+" now.",
134 "After this, it serves "+version+".")
135 }
136 rung, _ := release.Rung(channel)
137 stable := rung == release.RungStable
138 if stable {
139 cv.Warn = "Stable is what every user of " + app + " gets — this puts " + version + " in front of everyone."
140 }
141 // Whether the promotion bakes, and whether to offer turning the bake on:
142 // the same flag logic the confirmed POST applies, read here only to make
143 // the card tell the truth about what happens next.
144 flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
145 baking := ferr != nil || (flag != nil && flag.Production)
146 if hold := holdWindow(pipe, channel); hold > 0 && (pipe != nil || baking) {
147 cv.Lines = append(cv.Lines, "This app has the safety delay on: each copy waits "+holdText(hold)+" on "+channel+" before running "+version+".")
148 }
149 if stable && pipe == nil && ferr == nil && !baking {
150 cv.CheckboxName = "enable_bake"
151 cv.CheckboxChecked = true
152 cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)"
153 }
154 // The legacy rung's name carries the passkey requirement implicitly
155 // (promoteGate); a pipelined channel carries it explicitly, on ANY name
156 // — "production" in the Edge→Production template needs the same warning
157 // "stable" gets, or the passkey prompt that promoteGate still enforces
158 // right after this card would land as a surprise.
159 if stable || (pipe != nil && channelRules(pipe, channel).Passkey) {
160 cv.Note = "You'll confirm it's really you with your passkey."
161 }
162 s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
163}
164
165// confirmRollback: the card knows both versions because the handler read the
166// pointer before asking — a rollback card that cannot say where it is going
167// has no business asking anyone to confirm it.
168func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, cur, target string) {
169 cv := &ConfirmView{
170 Title: "Roll back " + channel + " to " + target + "?",
171 Lines: []string{
172 channel + " serves " + cur + " now.",
173 "Rolling back re-points it to " + target + ", the version before this one.",
174 },
175 Note: "Rollbacks skip the safety delay — it rolls out within a couple of minutes.",
176 Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback",
177 // target pins what the card promised: the confirmed POST refuses if
178 // the channel's history has moved on since this was rendered.
179 Fields: [][2]string{
180 {"csrf", raw}, {"channel", channel}, {"target", target}, {"back", r.FormValue("back")},
181 },
182 Button: "Roll back to " + target,
183 Danger: true,
184 CancelTab: backTab(r),
185 }
186 s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
187}
188
189// confirmFastTrack reads the channel best-effort for the card's sentences;
190// the confirmed POST re-validates (and still refuses a channel with no bake
191// window) from scratch.
192func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel string) {
193 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
194 defer cancel()
195 cv := &ConfirmView{
196 Title: "Skip the safety delay on " + channel + "?",
197 Warn: "The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users.",
198 Note: "You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge.",
199 Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/fasttrack",
200 Fields: [][2]string{
201 {"csrf", raw}, {"channel", channel},
202 },
203 Button: "Skip the delay",
204 Danger: true,
205 }
206 if cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel); err == nil {
207 cv.Title = "Skip the safety delay for " + cur.Version + " on " + channel + "?"
208 cv.Lines = append(cv.Lines,
209 channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.",
210 "Skipping waives the rest of it: "+cur.Version+" rolls out within a couple of minutes.")
211 cv.Button = "Skip the delay for " + cur.Version
212 // Pin the version the card names — the waiver must not land on a
213 // build promoted after this was rendered.
214 cv.Fields = append(cv.Fields, [2]string{"version", cur.Version})
215 } else {
216 cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: its current version rolls out within a couple of minutes.")
217 }
218 s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
219}
220
221// confirmRetireCanary: the handler has already established the canary exists
222// and nothing follows it, so the card states exactly those two facts.
223func (s *Server) confirmRetireCanary(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, name string) {
224 cv := &ConfirmView{
225 Title: "Retire canary " + name + "?",
226 Lines: []string{
227 "Deletes channel canary/" + name + " — the pointer, not any shipped release.",
228 "No host follows it, so nothing serving changes.",
229 },
230 Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/canary/retire",
231 Fields: [][2]string{
232 {"csrf", raw}, {"name", name}, {"back", r.FormValue("back")},
233 },
234 Button: "Retire " + name,
235 Danger: true,
236 CancelTab: backTab(r),
237 }
238 s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
239}
240
241// ago mirrors the template helper of the same name for the card builders'
242// sentences: humanAge's coarse resolution, dashing out the zero time.
243func ago(t time.Time) string {
244 if t.IsZero() {
245 return "—"
246 }
247 return humanAge(time.Since(t)) + " ago"
248}
249