package console import ( "context" "crypto/subtle" "errors" "log" "net/http" "net/url" "strings" "time" "github.com/carlosframework/platform/internal/provision" ) // The browser door's answer on a read-only console (apiRetryNoKey's twin). const ( browserRetryNoKeyTitle = "Retry unavailable" browserRetryNoKey = "No signing key on this host, so this console cannot request a retry." // The flashes for errRetryUnsupported and errRetryUnsaved // (apiRetryUnsupported's and apiRetryUnsaved's twins). browserRetryUnsupported = "This instance's box is too old to take a retry. Update the box, then try again." browserRetryUnsaved = "This instance's box has not saved its failure yet. Try again in a minute." ) // handleInstanceRetry is the instance page's Retry (spec §9.2): the browser // twin of `carlos instances retry`, shaped like the restart action: session, // CSRF, the byte-identical 404, a confirmation page, then the write. It // reads and writes through the CLI door's helpers (retryHostBuild, // requestHostRetry), so the two cannot disagree about what is held, and // both sign the same grant. func (s *Server) handleInstanceRetry(w http.ResponseWriter, r *http.Request) { addr, raw, acct, role, ok := s.account(w, r) if !ok { return } if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") return } app := r.PathValue("app") host := strings.TrimSpace(r.PathValue("host")) has, err := s.store.HasAppIn(acct.ID, app) if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") return } if !has || !provision.ValidHostShape(host) || s.cfg.Releases == nil { http.NotFound(w, r) return } if s.cfg.Key == nil { // A retry is a grant the root key signs (spec §11): a read-only // console asks nothing, not even the confirmation. s.renderMessage(w, r, http.StatusServiceUnavailable, browserRetryNoKeyTitle, browserRetryNoKey) return } back := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/instances/" + url.PathEscape(host) flash := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, back, http.StatusSeeOther) } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() held, err := s.retryHostBuild(ctx, acct, app, host) switch { case errors.Is(err, errRetryNoRecord): http.NotFound(w, r) return case errors.Is(err, errRetryNotHeld): flash("err", strings.ReplaceAll(swapCopy.NotHeld, "{host}", host)) return case errors.Is(err, errRetryUnsupported): flash("err", browserRetryUnsupported) return case errors.Is(err, errRetryUnsaved): flash("err", browserRetryUnsaved) return case err != nil: log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err) flash("err", swapCopy.RetryFailed) return } fill := strings.NewReplacer("{host}", host, "{build}", held.build) line := swapCopy.RetryLine if held.declined { line = swapCopy.RetryLineDeclined } if !confirmed(r) { s.renderConfirm(w, r, addr, raw, acct, role, app, &ConfirmView{ Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelURL: back, Title: fill.Replace(swapCopy.RetryTitle), Lines: []string{fill.Replace(line)}, Button: swapCopy.RetryButton, }) return } if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { flash("err", swapCopy.RetryLimited) return } if _, err := s.requestHostRetry(ctx, acct.Sqid, app, host, held); err != nil { log.Printf("console: browser retry %s/%s/%s: %v", acct.Sqid, app, host, err) flash("err", swapCopy.RetryFailed) return } s.auditActivity(acct, app, addr, Activity{Action: "retry", TargetType: "instance", Target: host, Detail: fill.Replace(swapCopy.Audit)}) flash("ok", fill.Replace(swapCopy.RetryDone)) }