| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/subtle" |
| 6 | "errors" |
| 7 | "log" |
| 8 | "net/http" |
| 9 | "net/url" |
| 10 | "strings" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/provision" |
| 14 | ) |
| 15 | |
| 16 | // The browser door's answer on a read-only console (apiRetryNoKey's twin). |
| 17 | const ( |
| 18 | browserRetryNoKeyTitle = "Retry unavailable" |
| 19 | browserRetryNoKey = "No signing key on this host, so this console cannot request a retry." |
| 20 | // The flashes for errRetryUnsupported and errRetryUnsaved |
| 21 | // (apiRetryUnsupported's and apiRetryUnsaved's twins). |
| 22 | browserRetryUnsupported = "This instance's box is too old to take a retry. Update the box, then try again." |
| 23 | browserRetryUnsaved = "This instance's box has not saved its failure yet. Try again in a minute." |
| 24 | ) |
| 25 | |
| 26 | // handleInstanceRetry is the instance page's Retry (spec §9.2): the browser |
| 27 | // twin of `carlos instances retry`, shaped like the restart action: session, |
| 28 | // CSRF, the byte-identical 404, a confirmation page, then the write. It |
| 29 | // reads and writes through the CLI door's helpers (retryHostBuild, |
| 30 | // requestHostRetry), so the two cannot disagree about what is held, and |
| 31 | // both sign the same grant. |
| 32 | func (s *Server) handleInstanceRetry(w http.ResponseWriter, r *http.Request) { |
| 33 | addr, raw, acct, role, ok := s.account(w, r) |
| 34 | if !ok { |
| 35 | return |
| 36 | } |
| 37 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 38 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 39 | return |
| 40 | } |
| 41 | app := r.PathValue("app") |
| 42 | host := strings.TrimSpace(r.PathValue("host")) |
| 43 | has, err := s.store.HasAppIn(acct.ID, app) |
| 44 | if err != nil { |
| 45 | s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| 46 | return |
| 47 | } |
| 48 | if !has || !provision.ValidHostShape(host) || s.cfg.Releases == nil { |
| 49 | http.NotFound(w, r) |
| 50 | return |
| 51 | } |
| 52 | if s.cfg.Key == nil { |
| 53 | // A retry is a grant the root key signs (spec §11): a read-only |
| 54 | // console asks nothing, not even the confirmation. |
| 55 | s.renderMessage(w, r, http.StatusServiceUnavailable, browserRetryNoKeyTitle, browserRetryNoKey) |
| 56 | return |
| 57 | } |
| 58 | back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host) |
| 59 | flash := func(kind, msg string) { |
| 60 | s.setFlash(w, kind, msg) |
| 61 | http.Redirect(w, r, back, http.StatusSeeOther) |
| 62 | } |
| 63 | ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| 64 | defer cancel() |
| 65 | held, err := s.retryHostBuild(ctx, acct, app, host) |
| 66 | switch { |
| 67 | case errors.Is(err, errRetryNoRecord): |
| 68 | http.NotFound(w, r) |
| 69 | return |
| 70 | case errors.Is(err, errRetryNotHeld): |
| 71 | flash("err", strings.ReplaceAll(swapCopy.NotHeld, "{host}", host)) |
| 72 | return |
| 73 | case errors.Is(err, errRetryUnsupported): |
| 74 | flash("err", browserRetryUnsupported) |
| 75 | return |
| 76 | case errors.Is(err, errRetryUnsaved): |
| 77 | flash("err", browserRetryUnsaved) |
| 78 | return |
| 79 | case err != nil: |
| 80 | log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 81 | flash("err", swapCopy.RetryFailed) |
| 82 | return |
| 83 | } |
| 84 | fill := strings.NewReplacer("{host}", host, "{build}", held.build) |
| 85 | line := swapCopy.RetryLine |
| 86 | if held.declined { |
| 87 | line = swapCopy.RetryLineDeclined |
| 88 | } |
| 89 | if !confirmed(r) { |
| 90 | s.renderConfirm(w, r, addr, raw, acct, role, app, &ConfirmView{ |
| 91 | Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelURL: back, |
| 92 | Title: fill.Replace(swapCopy.RetryTitle), Lines: []string{fill.Replace(line)}, |
| 93 | Button: swapCopy.RetryButton, |
| 94 | }) |
| 95 | return |
| 96 | } |
| 97 | if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { |
| 98 | flash("err", swapCopy.RetryLimited) |
| 99 | return |
| 100 | } |
| 101 | if _, err := s.requestHostRetry(ctx, acct.Sqid, app, host, held); err != nil { |
| 102 | log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 103 | flash("err", swapCopy.RetryFailed) |
| 104 | return |
| 105 | } |
| 106 | s.auditActivity(acct, app, addr, Activity{Action: "retry", TargetType: "instance", Target: host, Detail: fill.Replace(swapCopy.Audit)}) |
| 107 | flash("ok", fill.Replace(swapCopy.RetryDone)) |
| 108 | } |
| 109 | |