| 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. |
| 19 | package console |
| 20 | |
| 21 | import ( |
| 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. |
| 34 | type 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. |
| 64 | func 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. |
| 69 | func (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. |
| 86 | func 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". |
| 94 | func 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. |
| 111 | func (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. |
| 168 | func (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. |
| 192 | func (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. |
| 223 | func (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. |
| 243 | func ago(t time.Time) string { |
| 244 | if t.IsZero() { |
| 245 | return "—" |
| 246 | } |
| 247 | return humanAge(time.Since(t)) + " ago" |
| 248 | } |
| 249 | |