package console import ( "context" "crypto/subtle" "errors" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The bounds are spec ruling 3: default 7 days (temporaryTTLDefault, // tempstore.go), at least an hour, at most 30 days, and at most 20 live // temporary apps in an account. const ( temporaryTTLMin = time.Hour temporaryTTLMax = 30 * 24 * time.Hour temporaryAppCap = 20 ) var errTTLBounds = errors.New("ttl_seconds must be between 1 hour (3600) and 30 days (2592000)") // temporaryTTL reads a request's ttl_seconds. Compared as seconds before any // multiplication, so no value can overflow a Duration on its way to being // refused. 0 is the default only where allowDefault says so (create). func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error) { if seconds == 0 && allowDefault { return temporaryTTLDefault, nil } if seconds < int64(temporaryTTLMin/time.Second) || seconds > int64(temporaryTTLMax/time.Second) { return 0, errTTLBounds } return time.Duration(seconds) * time.Second, nil } // validTemporaryName is the name rule on top of release.ValidApp: in the tmp- // namespace, something after the prefix, and a DNS label, because the name // becomes the first label of the app's platform host. func validTemporaryName(name string) bool { return release.IsTemporaryName(name) && name != release.TemporaryAppPrefix && release.ValidApp(name) && provision.ValidHostShape(name) } // temporaryCapError is the cap's refusal. It lists each live temporary app // with its expiry, so the person refused can see what to keep or delete. type temporaryCapError struct{ Live []AppLife } func (e *temporaryCapError) Error() string { var b strings.Builder fmt.Fprintf(&b, "this account already has %d temporary apps, the most it can hold. "+ "Make one permanent with carlos apps keep, or delete one with carlos apps delete. They are:", len(e.Live)) for _, l := range e.Live { fmt.Fprintf(&b, "\n %s expires %s", l.App, time.Unix(l.ExpiresAt, 0).UTC().Format("2006-01-02 15:04 UTC")) } return b.String() } // temporaryCapCheck refuses when the account is at the cap. The CALLER holds // s.lockAccount(sqid), which is what makes count-then-insert exact (spec §3). func (s *Server) temporaryCapCheck(accountID int64) error { live, err := s.store.LiveTemporaryApps(accountID) if err != nil { return err } if len(live) >= temporaryAppCap { return &temporaryCapError{Live: live} } return nil } // accountAppHasRoutes is appHasRoutes (appsettings.go) scoped to one // account: any route in THIS console's registry whose account // (release.RouteAccount) is sqid and whose app is app, platform apex // included. A registry that cannot be opened or listed is an error, never // "no routes" (spec §3, astra 5.1). It is a second line behind the tmp- // namespace, not the proof: routes hand-added on other boxes are invisible // here. func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error) { if s.cfg.Registry == "" { return false, errors.New("console: no registry configured") } reg, err := openRegistry(s.cfg.Registry) if err != nil { return false, err } routes, err := reg.List() if err != nil { return false, err } for _, rt := range routes { if rt.App == app && release.RouteAccount(rt) == sqid { return true, nil } } return false, nil } // handleAPICreateTemporaryApp is POST /api/cli/apps/{account}/new-temporary // (spec §3): a FRESH claim of a tmp- name, never an adoption, with the expiry // in the same insert. It shares claimApp's checks through claimAppWith and // takes no control_plane, place or build_probe: its strict decoder refuses // them by name. It takes /new's optional region and timezone fields, read // the same way, and echoes the stored region the way /new does. func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request) { if !s.cfg.TemporaryApps { http.NotFound(w, r) return } p, acct, ok := s.apiPrincipalAccount(w, r) if !ok { return } // An app-scoped credential claiming a new name is account authority it // was not given: the byte-identical 404, exactly as /new (apicli.go). if p.Kind == PrincipalService && p.App != "" { http.NotFound(w, r) return } addr := auditActor(p, acct) now := time.Now() if !s.allowTemporaryCreate(p, now) { jsonError(w, http.StatusTooManyRequests, "app creation limit reached for today. Try again tomorrow, or deploy to an app you already have") return } var req struct { Name string `json:"name"` TTLSeconds int64 `json:"ttl_seconds"` Region string `json:"region"` Timezone string `json:"timezone"` } if !readJSONStrictLimit(w, r, &req, 8<<10) { return } name := normalizeAppName(req.Name) if !validTemporaryName(name) { jsonError(w, http.StatusBadRequest, "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") return } ttl, err := temporaryTTL(req.TTLSeconds, true) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "this console has no deployment bucket, so it cannot create a temporary app") return } // The same optional region fields /new takes, read the same way. rq, ref := s.newAppRegionReq(r, req.Region, req.Timezone) if ref != nil { jsonError(w, ref.Status, ref.Msg) return } defer s.lockAccount(acct.Sqid)() if err := s.temporaryCapCheck(acct.ID); err != nil { var capErr *temporaryCapError if errors.As(err, &capErr) { writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) return } log.Printf("console: temporary create %s: count: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") return } // A temporary app is a running thing too (plans 3a); the account lock is // already held. if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { if isPlanCap(err) { writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) return } log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") return } switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); { case err != nil: log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err) jsonError(w, http.StatusBadGateway, "could not check the routes for that name, so nothing was created. Try again") return case wired: writeJSON(w, http.StatusConflict, map[string]any{ "error": "a route in this account already serves " + name + ", so it cannot be a temporary app. Choose another name", "code": "name_taken", }) return } outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl)) switch outcome { case claimOK: case claimInvalid: jsonError(w, http.StatusBadRequest, "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") return case claimReserved: writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is reserved", "code": "name_taken"}) return case claimTaken: writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is taken", "code": "name_taken"}) return case claimApexHeld: writeJSON(w, http.StatusConflict, map[string]any{ "error": "that name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held), "code": "name_taken", }) return case claimStoreFailed: jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again") return default: jsonError(w, http.StatusInternalServerError, "could not create the app. Try again") return } life, err := s.store.AppLife(acct.ID, name) if err != nil || life == nil || !life.Temporary() { log.Printf("console: temporary create %s/%s: read back expiry: %v %+v", acct.Sqid, name, err, life) jsonError(w, http.StatusInternalServerError, "the app was created but its expiry could not be read back. Check carlos apps list before you ship to it") return } expires := time.Unix(life.ExpiresAt, 0).UTC() s.audit(acct, name, addr, "create", "created temporary app "+name+", expiring "+expires.Format("2006-01-02 15:04 UTC")+" (cli)") out := map[string]any{ "account": acct.Sqid, "app": name, "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), "expires_at": expires.Format(time.RFC3339), } s.echoCreatedRegion(r.Context(), acct, name, out) writeJSON(w, http.StatusOK, out) } var ( errAppGone = errors.New("console: no such app") errTemporaryInTrash = errors.New("console: the app is in the trash") errNotTemporary = errors.New("console: the app is permanent") ) // temporaryLive takes the app's lock, settles, and returns the row if it is // a live temporary app. The caller runs its update under the same lock. // Settling first is what makes a keep after a failed expiry step 4 find the // app in the Trash and refuse ("restore it first"), rather than succeed on a // row the bucket has already trashed (spec §5). func (s *Server) temporaryLive(ctx context.Context, acct *Account, app string, now time.Time) (*AppLife, error) { if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { return nil, err } life, err := s.store.AppLife(acct.ID, app) switch { case err != nil: return nil, err case life == nil: return nil, errAppGone case life.Trashed() || life.Restoring: return nil, errTemporaryInTrash case !life.Temporary(): return nil, errNotTemporary } return life, nil } // keepApp is `carlos apps keep` and the Keep button: expires_at = 0. func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error { defer s.lockApp(acct.Sqid, app)() if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { return err } ok, err := s.store.KeepAppContext(ctx, acct.ID, app) if err != nil { return err } if !ok { return errTemporaryInTrash } return nil } // setAppTTL is `carlos apps ttl`: expires_at = now + ttl, live temporary // apps only (spec §7). It never sets an expiry on a permanent row. func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error) { defer s.lockApp(acct.Sqid, app)() if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { return time.Time{}, err } exp := now.Add(ttl).Truncate(time.Second) ok, err := s.store.SetAppExpiryContext(ctx, acct.ID, app, exp) if err != nil { return time.Time{}, err } if !ok { return time.Time{}, errTemporaryInTrash } return exp, nil } // lifecycleRefusal maps keepApp/setAppTTL's errors onto the JSON answer. // It reports whether it wrote one. func lifecycleRefusal(w http.ResponseWriter, r *http.Request, app string, err error) bool { switch { case err == nil: return false case errors.Is(err, errAppGone): http.NotFound(w, r) case errors.Is(err, errTemporaryInTrash): jsonError(w, http.StatusConflict, app+" is in the Trash. Restore it first with: carlos apps restore "+app) case errors.Is(err, errNotTemporary): jsonError(w, http.StatusConflict, app+" is permanent. It has no expiry to move or keep") default: log.Printf("console: lifecycle %s: %v", app, err) jsonError(w, http.StatusInternalServerError, "could not change "+app+". Nothing was changed. Try again") } return true } func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() if lifecycleRefusal(w, r, app, s.keepApp(ctx, acct, app, time.Now())) { return } s.audit(acct, app, addr, "keep", "made "+app+" permanent (cli)") writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": nil}) } func (s *Server) handleAPIAppTTL(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") return } var req struct { TTLSeconds int64 `json:"ttl_seconds"` } if !readJSONStrictLimit(w, r, &req, 8<<10) { return } ttl, err := temporaryTTL(req.TTLSeconds, false) if err != nil { jsonError(w, http.StatusBadRequest, err.Error()) return } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() exp, err := s.setAppTTL(ctx, acct, app, ttl, time.Now()) if lifecycleRefusal(w, r, app, err) { return } s.audit(acct, app, addr, "ttl", "set "+app+" to expire "+exp.UTC().Format("2006-01-02 15:04 UTC")+" (cli)") writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": exp.UTC().Format(time.RFC3339)}) } // handleAPIAppList is GET /api/cli/apps/{account}/list (spec §7): every app // the caller can see in the account, trashed included, with its lifecycle // times. New rather than a change to GET /api/cli/apps, whose "apps" field is // a []string that clients decode as such. An app-scoped service sees its own // app only, the filter handleAPIApps applies (apicli.go:532). func (s *Server) handleAPIAppList(w http.ResponseWriter, r *http.Request) { p, acct, ok := s.apiPrincipalAccount(w, r) if !ok { return } lives, err := s.store.AppLivesIn(acct.ID) if err != nil { log.Printf("console: api list %s: %v", acct.Sqid, err) jsonError(w, http.StatusInternalServerError, "could not load apps") return } stamp := func(unix int64) any { if unix == 0 { return nil } return time.Unix(unix, 0).UTC().Format(time.RFC3339) } out := make([]map[string]any, 0, len(lives)) for _, l := range lives { if p.Kind == PrincipalService && p.App != "" && l.App != p.App { continue } entry := map[string]any{"app": l.App, "deleted_at": stamp(l.DeletedAt), "expires_at": stamp(l.ExpiresAt), "purge_after": nil} if l.Trashed() { entry["purge_after"] = effectivePurgeAt(l.DeletedAt, l.PurgeAfter).Format(time.RFC3339) } out = append(out, entry) } writeJSON(w, http.StatusOK, out) } // handleAppKeep is the app page's Keep button (spec §7): any member, CSRF, // no passkey, because keep only makes the app safer. func (s *Server) handleAppKeep(w http.ResponseWriter, r *http.Request) { addr, raw, acct, _, app, ok := s.accountApp(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 } back := func(kind, msg string) { s.setFlash(w, kind, msg) http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() switch err := s.keepApp(ctx, acct, app, time.Now()); { case err == nil: case errors.Is(err, errNotTemporary): back("ok", app+" is already permanent.") return case errors.Is(err, errTemporaryInTrash), errors.Is(err, errAppGone): back("err", app+" is in the Trash. Restore it from the Trash page first.") return default: log.Printf("console: keep %s/%s: %v", acct.Sqid, app, err) back("err", "Could not keep "+app+". Nothing was changed. Try again.") return } s.audit(acct, app, addr, "keep", "made "+app+" permanent") back("ok", app+" is permanent now. It will not expire.") }