| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/subtle" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/provision" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // The bounds are spec ruling 3: default 7 days (temporaryTTLDefault, |
| 19 | // tempstore.go), at least an hour, at most 30 days, and at most 20 live |
| 20 | // temporary apps in an account. |
| 21 | const ( |
| 22 | temporaryTTLMin = time.Hour |
| 23 | temporaryTTLMax = 30 * 24 * time.Hour |
| 24 | temporaryAppCap = 20 |
| 25 | ) |
| 26 | |
| 27 | var errTTLBounds = errors.New("ttl_seconds must be between 1 hour (3600) and 30 days (2592000)") |
| 28 | |
| 29 | // temporaryTTL reads a request's ttl_seconds. Compared as seconds before any |
| 30 | // multiplication, so no value can overflow a Duration on its way to being |
| 31 | // refused. 0 is the default only where allowDefault says so (create). |
| 32 | func temporaryTTL(seconds int64, allowDefault bool) (time.Duration, error) { |
| 33 | if seconds == 0 && allowDefault { |
| 34 | return temporaryTTLDefault, nil |
| 35 | } |
| 36 | if seconds < int64(temporaryTTLMin/time.Second) || seconds > int64(temporaryTTLMax/time.Second) { |
| 37 | return 0, errTTLBounds |
| 38 | } |
| 39 | return time.Duration(seconds) * time.Second, nil |
| 40 | } |
| 41 | |
| 42 | // validTemporaryName is the name rule on top of release.ValidApp: in the tmp- |
| 43 | // namespace, something after the prefix, and a DNS label, because the name |
| 44 | // becomes the first label of the app's platform host. |
| 45 | func validTemporaryName(name string) bool { |
| 46 | return release.IsTemporaryName(name) && name != release.TemporaryAppPrefix && |
| 47 | release.ValidApp(name) && provision.ValidHostShape(name) |
| 48 | } |
| 49 | |
| 50 | // temporaryCapError is the cap's refusal. It lists each live temporary app |
| 51 | // with its expiry, so the person refused can see what to keep or delete. |
| 52 | type temporaryCapError struct{ Live []AppLife } |
| 53 | |
| 54 | func (e *temporaryCapError) Error() string { |
| 55 | var b strings.Builder |
| 56 | fmt.Fprintf(&b, "this account already has %d temporary apps, the most it can hold. "+ |
| 57 | "Make one permanent with carlos apps keep, or delete one with carlos apps delete. They are:", len(e.Live)) |
| 58 | for _, l := range e.Live { |
| 59 | fmt.Fprintf(&b, "\n %s expires %s", l.App, time.Unix(l.ExpiresAt, 0).UTC().Format("2006-01-02 15:04 UTC")) |
| 60 | } |
| 61 | return b.String() |
| 62 | } |
| 63 | |
| 64 | // temporaryCapCheck refuses when the account is at the cap. The CALLER holds |
| 65 | // s.lockAccount(sqid), which is what makes count-then-insert exact (spec §3). |
| 66 | func (s *Server) temporaryCapCheck(accountID int64) error { |
| 67 | live, err := s.store.LiveTemporaryApps(accountID) |
| 68 | if err != nil { |
| 69 | return err |
| 70 | } |
| 71 | if len(live) >= temporaryAppCap { |
| 72 | return &temporaryCapError{Live: live} |
| 73 | } |
| 74 | return nil |
| 75 | } |
| 76 | |
| 77 | // accountAppHasRoutes is appHasRoutes (appsettings.go) scoped to one |
| 78 | // account: any route in THIS console's registry whose account |
| 79 | // (release.RouteAccount) is sqid and whose app is app, platform apex |
| 80 | // included. A registry that cannot be opened or listed is an error, never |
| 81 | // "no routes" (spec §3, astra 5.1). It is a second line behind the tmp- |
| 82 | // namespace, not the proof: routes hand-added on other boxes are invisible |
| 83 | // here. |
| 84 | func (s *Server) accountAppHasRoutes(sqid, app string) (bool, error) { |
| 85 | if s.cfg.Registry == "" { |
| 86 | return false, errors.New("console: no registry configured") |
| 87 | } |
| 88 | reg, err := openRegistry(s.cfg.Registry) |
| 89 | if err != nil { |
| 90 | return false, err |
| 91 | } |
| 92 | routes, err := reg.List() |
| 93 | if err != nil { |
| 94 | return false, err |
| 95 | } |
| 96 | for _, rt := range routes { |
| 97 | if rt.App == app && release.RouteAccount(rt) == sqid { |
| 98 | return true, nil |
| 99 | } |
| 100 | } |
| 101 | return false, nil |
| 102 | } |
| 103 | |
| 104 | // handleAPICreateTemporaryApp is POST /api/cli/apps/{account}/new-temporary |
| 105 | // (spec §3): a FRESH claim of a tmp- name, never an adoption, with the expiry |
| 106 | // in the same insert. It shares claimApp's checks through claimAppWith and |
| 107 | // takes no control_plane, place or build_probe: its strict decoder refuses |
| 108 | // them by name. It takes /new's optional region and timezone fields, read |
| 109 | // the same way, and echoes the stored region the way /new does. |
| 110 | func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Request) { |
| 111 | if !s.cfg.TemporaryApps { |
| 112 | http.NotFound(w, r) |
| 113 | return |
| 114 | } |
| 115 | p, acct, ok := s.apiPrincipalAccount(w, r) |
| 116 | if !ok { |
| 117 | return |
| 118 | } |
| 119 | // An app-scoped credential claiming a new name is account authority it |
| 120 | // was not given: the byte-identical 404, exactly as /new (apicli.go). |
| 121 | if p.Kind == PrincipalService && p.App != "" { |
| 122 | http.NotFound(w, r) |
| 123 | return |
| 124 | } |
| 125 | addr := auditActor(p, acct) |
| 126 | now := time.Now() |
| 127 | if !s.allowTemporaryCreate(p, now) { |
| 128 | jsonError(w, http.StatusTooManyRequests, |
| 129 | "app creation limit reached for today. Try again tomorrow, or deploy to an app you already have") |
| 130 | return |
| 131 | } |
| 132 | var req struct { |
| 133 | Name string `json:"name"` |
| 134 | TTLSeconds int64 `json:"ttl_seconds"` |
| 135 | Region string `json:"region"` |
| 136 | Timezone string `json:"timezone"` |
| 137 | } |
| 138 | if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| 139 | return |
| 140 | } |
| 141 | name := normalizeAppName(req.Name) |
| 142 | if !validTemporaryName(name) { |
| 143 | jsonError(w, http.StatusBadRequest, |
| 144 | "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") |
| 145 | return |
| 146 | } |
| 147 | ttl, err := temporaryTTL(req.TTLSeconds, true) |
| 148 | if err != nil { |
| 149 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 150 | return |
| 151 | } |
| 152 | if s.cfg.Releases == nil { |
| 153 | jsonError(w, http.StatusServiceUnavailable, |
| 154 | "this console has no deployment bucket, so it cannot create a temporary app") |
| 155 | return |
| 156 | } |
| 157 | // The same optional region fields /new takes, read the same way. |
| 158 | rq, ref := s.newAppRegionReq(r, req.Region, req.Timezone) |
| 159 | if ref != nil { |
| 160 | jsonError(w, ref.Status, ref.Msg) |
| 161 | return |
| 162 | } |
| 163 | defer s.lockAccount(acct.Sqid)() |
| 164 | if err := s.temporaryCapCheck(acct.ID); err != nil { |
| 165 | var capErr *temporaryCapError |
| 166 | if errors.As(err, &capErr) { |
| 167 | writeJSON(w, http.StatusConflict, map[string]any{"error": capErr.Error(), "code": "cap"}) |
| 168 | return |
| 169 | } |
| 170 | log.Printf("console: temporary create %s: count: %v", acct.Sqid, err) |
| 171 | jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| 172 | return |
| 173 | } |
| 174 | // A temporary app is a running thing too (plans 3a); the account lock is |
| 175 | // already held. |
| 176 | if err := s.checkRunningThings(r.Context(), acct, 1); err != nil { |
| 177 | if isPlanCap(err) { |
| 178 | writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"}) |
| 179 | return |
| 180 | } |
| 181 | log.Printf("console: temporary create %s: plan cap: %v", acct.Sqid, err) |
| 182 | jsonError(w, http.StatusInternalServerError, "could not count this account's temporary apps. Nothing was created. Try again") |
| 183 | return |
| 184 | } |
| 185 | switch wired, err := s.accountAppHasRoutes(acct.Sqid, name); { |
| 186 | case err != nil: |
| 187 | log.Printf("console: temporary create %s/%s: routes: %v", acct.Sqid, name, err) |
| 188 | jsonError(w, http.StatusBadGateway, "could not check the routes for that name, so nothing was created. Try again") |
| 189 | return |
| 190 | case wired: |
| 191 | writeJSON(w, http.StatusConflict, map[string]any{ |
| 192 | "error": "a route in this account already serves " + name + ", so it cannot be a temporary app. Choose another name", |
| 193 | "code": "name_taken", |
| 194 | }) |
| 195 | return |
| 196 | } |
| 197 | outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl)) |
| 198 | switch outcome { |
| 199 | case claimOK: |
| 200 | case claimInvalid: |
| 201 | jsonError(w, http.StatusBadRequest, |
| 202 | "a temporary app's name is tmp- followed by lowercase letters, digits and dashes, 32 characters in all, ending in a letter or digit") |
| 203 | return |
| 204 | case claimReserved: |
| 205 | writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is reserved", "code": "name_taken"}) |
| 206 | return |
| 207 | case claimTaken: |
| 208 | writeJSON(w, http.StatusConflict, map[string]any{"error": "that name is taken", "code": "name_taken"}) |
| 209 | return |
| 210 | case claimApexHeld: |
| 211 | writeJSON(w, http.StatusConflict, map[string]any{ |
| 212 | "error": "that name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), held), |
| 213 | "code": "name_taken", |
| 214 | }) |
| 215 | return |
| 216 | case claimStoreFailed: |
| 217 | jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again") |
| 218 | return |
| 219 | default: |
| 220 | jsonError(w, http.StatusInternalServerError, "could not create the app. Try again") |
| 221 | return |
| 222 | } |
| 223 | life, err := s.store.AppLife(acct.ID, name) |
| 224 | if err != nil || life == nil || !life.Temporary() { |
| 225 | log.Printf("console: temporary create %s/%s: read back expiry: %v %+v", acct.Sqid, name, err, life) |
| 226 | jsonError(w, http.StatusInternalServerError, |
| 227 | "the app was created but its expiry could not be read back. Check carlos apps list before you ship to it") |
| 228 | return |
| 229 | } |
| 230 | expires := time.Unix(life.ExpiresAt, 0).UTC() |
| 231 | s.audit(acct, name, addr, "create", "created temporary app "+name+", expiring "+expires.Format("2006-01-02 15:04 UTC")+" (cli)") |
| 232 | out := map[string]any{ |
| 233 | "account": acct.Sqid, |
| 234 | "app": name, |
| 235 | "url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name), |
| 236 | "expires_at": expires.Format(time.RFC3339), |
| 237 | } |
| 238 | s.echoCreatedRegion(r.Context(), acct, name, out) |
| 239 | writeJSON(w, http.StatusOK, out) |
| 240 | } |
| 241 | |
| 242 | var ( |
| 243 | errAppGone = errors.New("console: no such app") |
| 244 | errTemporaryInTrash = errors.New("console: the app is in the trash") |
| 245 | errNotTemporary = errors.New("console: the app is permanent") |
| 246 | ) |
| 247 | |
| 248 | // temporaryLive takes the app's lock, settles, and returns the row if it is |
| 249 | // a live temporary app. The caller runs its update under the same lock. |
| 250 | // Settling first is what makes a keep after a failed expiry step 4 find the |
| 251 | // app in the Trash and refuse ("restore it first"), rather than succeed on a |
| 252 | // row the bucket has already trashed (spec §5). |
| 253 | func (s *Server) temporaryLive(ctx context.Context, acct *Account, app string, now time.Time) (*AppLife, error) { |
| 254 | if err := s.settleApp(ctx, acct.ID, acct.Sqid, app, now, s.doorEvidence); err != nil { |
| 255 | return nil, err |
| 256 | } |
| 257 | life, err := s.store.AppLife(acct.ID, app) |
| 258 | switch { |
| 259 | case err != nil: |
| 260 | return nil, err |
| 261 | case life == nil: |
| 262 | return nil, errAppGone |
| 263 | case life.Trashed() || life.Restoring: |
| 264 | return nil, errTemporaryInTrash |
| 265 | case !life.Temporary(): |
| 266 | return nil, errNotTemporary |
| 267 | } |
| 268 | return life, nil |
| 269 | } |
| 270 | |
| 271 | // keepApp is `carlos apps keep` and the Keep button: expires_at = 0. |
| 272 | func (s *Server) keepApp(ctx context.Context, acct *Account, app string, now time.Time) error { |
| 273 | defer s.lockApp(acct.Sqid, app)() |
| 274 | if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { |
| 275 | return err |
| 276 | } |
| 277 | ok, err := s.store.KeepAppContext(ctx, acct.ID, app) |
| 278 | if err != nil { |
| 279 | return err |
| 280 | } |
| 281 | if !ok { |
| 282 | return errTemporaryInTrash |
| 283 | } |
| 284 | return nil |
| 285 | } |
| 286 | |
| 287 | // setAppTTL is `carlos apps ttl`: expires_at = now + ttl, live temporary |
| 288 | // apps only (spec §7). It never sets an expiry on a permanent row. |
| 289 | func (s *Server) setAppTTL(ctx context.Context, acct *Account, app string, ttl time.Duration, now time.Time) (time.Time, error) { |
| 290 | defer s.lockApp(acct.Sqid, app)() |
| 291 | if _, err := s.temporaryLive(ctx, acct, app, now); err != nil { |
| 292 | return time.Time{}, err |
| 293 | } |
| 294 | exp := now.Add(ttl).Truncate(time.Second) |
| 295 | ok, err := s.store.SetAppExpiryContext(ctx, acct.ID, app, exp) |
| 296 | if err != nil { |
| 297 | return time.Time{}, err |
| 298 | } |
| 299 | if !ok { |
| 300 | return time.Time{}, errTemporaryInTrash |
| 301 | } |
| 302 | return exp, nil |
| 303 | } |
| 304 | |
| 305 | // lifecycleRefusal maps keepApp/setAppTTL's errors onto the JSON answer. |
| 306 | // It reports whether it wrote one. |
| 307 | func lifecycleRefusal(w http.ResponseWriter, r *http.Request, app string, err error) bool { |
| 308 | switch { |
| 309 | case err == nil: |
| 310 | return false |
| 311 | case errors.Is(err, errAppGone): |
| 312 | http.NotFound(w, r) |
| 313 | case errors.Is(err, errTemporaryInTrash): |
| 314 | jsonError(w, http.StatusConflict, app+" is in the Trash. Restore it first with: carlos apps restore "+app) |
| 315 | case errors.Is(err, errNotTemporary): |
| 316 | jsonError(w, http.StatusConflict, app+" is permanent. It has no expiry to move or keep") |
| 317 | default: |
| 318 | log.Printf("console: lifecycle %s: %v", app, err) |
| 319 | jsonError(w, http.StatusInternalServerError, "could not change "+app+". Nothing was changed. Try again") |
| 320 | } |
| 321 | return true |
| 322 | } |
| 323 | |
| 324 | func (s *Server) handleAPIAppKeep(w http.ResponseWriter, r *http.Request) { |
| 325 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 326 | if !ok { |
| 327 | return |
| 328 | } |
| 329 | p, _ := principalFrom(r.Context()) |
| 330 | if !s.allowWrite(p, time.Now()) { |
| 331 | jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") |
| 332 | return |
| 333 | } |
| 334 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 335 | defer cancel() |
| 336 | if lifecycleRefusal(w, r, app, s.keepApp(ctx, acct, app, time.Now())) { |
| 337 | return |
| 338 | } |
| 339 | s.audit(acct, app, addr, "keep", "made "+app+" permanent (cli)") |
| 340 | writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": nil}) |
| 341 | } |
| 342 | |
| 343 | func (s *Server) handleAPIAppTTL(w http.ResponseWriter, r *http.Request) { |
| 344 | addr, acct, app, ok := s.apiAccountApp(w, r) |
| 345 | if !ok { |
| 346 | return |
| 347 | } |
| 348 | p, _ := principalFrom(r.Context()) |
| 349 | if !s.allowWrite(p, time.Now()) { |
| 350 | jsonError(w, http.StatusTooManyRequests, "too many requests. Wait a minute and try again") |
| 351 | return |
| 352 | } |
| 353 | var req struct { |
| 354 | TTLSeconds int64 `json:"ttl_seconds"` |
| 355 | } |
| 356 | if !readJSONStrictLimit(w, r, &req, 8<<10) { |
| 357 | return |
| 358 | } |
| 359 | ttl, err := temporaryTTL(req.TTLSeconds, false) |
| 360 | if err != nil { |
| 361 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 362 | return |
| 363 | } |
| 364 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 365 | defer cancel() |
| 366 | exp, err := s.setAppTTL(ctx, acct, app, ttl, time.Now()) |
| 367 | if lifecycleRefusal(w, r, app, err) { |
| 368 | return |
| 369 | } |
| 370 | s.audit(acct, app, addr, "ttl", "set "+app+" to expire "+exp.UTC().Format("2006-01-02 15:04 UTC")+" (cli)") |
| 371 | writeJSON(w, http.StatusOK, map[string]any{"app": app, "expires_at": exp.UTC().Format(time.RFC3339)}) |
| 372 | } |
| 373 | |
| 374 | // handleAPIAppList is GET /api/cli/apps/{account}/list (spec §7): every app |
| 375 | // the caller can see in the account, trashed included, with its lifecycle |
| 376 | // times. New rather than a change to GET /api/cli/apps, whose "apps" field is |
| 377 | // a []string that clients decode as such. An app-scoped service sees its own |
| 378 | // app only, the filter handleAPIApps applies (apicli.go:532). |
| 379 | func (s *Server) handleAPIAppList(w http.ResponseWriter, r *http.Request) { |
| 380 | p, acct, ok := s.apiPrincipalAccount(w, r) |
| 381 | if !ok { |
| 382 | return |
| 383 | } |
| 384 | lives, err := s.store.AppLivesIn(acct.ID) |
| 385 | if err != nil { |
| 386 | log.Printf("console: api list %s: %v", acct.Sqid, err) |
| 387 | jsonError(w, http.StatusInternalServerError, "could not load apps") |
| 388 | return |
| 389 | } |
| 390 | stamp := func(unix int64) any { |
| 391 | if unix == 0 { |
| 392 | return nil |
| 393 | } |
| 394 | return time.Unix(unix, 0).UTC().Format(time.RFC3339) |
| 395 | } |
| 396 | out := make([]map[string]any, 0, len(lives)) |
| 397 | for _, l := range lives { |
| 398 | if p.Kind == PrincipalService && p.App != "" && l.App != p.App { |
| 399 | continue |
| 400 | } |
| 401 | entry := map[string]any{"app": l.App, "deleted_at": stamp(l.DeletedAt), "expires_at": stamp(l.ExpiresAt), "purge_after": nil} |
| 402 | if l.Trashed() { |
| 403 | entry["purge_after"] = effectivePurgeAt(l.DeletedAt, l.PurgeAfter).Format(time.RFC3339) |
| 404 | } |
| 405 | out = append(out, entry) |
| 406 | } |
| 407 | writeJSON(w, http.StatusOK, out) |
| 408 | } |
| 409 | |
| 410 | // handleAppKeep is the app page's Keep button (spec §7): any member, CSRF, |
| 411 | // no passkey, because keep only makes the app safer. |
| 412 | func (s *Server) handleAppKeep(w http.ResponseWriter, r *http.Request) { |
| 413 | addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| 414 | if !ok { |
| 415 | return |
| 416 | } |
| 417 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 418 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 419 | return |
| 420 | } |
| 421 | back := func(kind, msg string) { |
| 422 | s.setFlash(w, kind, msg) |
| 423 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| 424 | } |
| 425 | ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| 426 | defer cancel() |
| 427 | switch err := s.keepApp(ctx, acct, app, time.Now()); { |
| 428 | case err == nil: |
| 429 | case errors.Is(err, errNotTemporary): |
| 430 | back("ok", app+" is already permanent.") |
| 431 | return |
| 432 | case errors.Is(err, errTemporaryInTrash), errors.Is(err, errAppGone): |
| 433 | back("err", app+" is in the Trash. Restore it from the Trash page first.") |
| 434 | return |
| 435 | default: |
| 436 | log.Printf("console: keep %s/%s: %v", acct.Sqid, app, err) |
| 437 | back("err", "Could not keep "+app+". Nothing was changed. Try again.") |
| 438 | return |
| 439 | } |
| 440 | s.audit(acct, app, addr, "keep", "made "+app+" permanent") |
| 441 | back("ok", app+" is permanent now. It will not expire.") |
| 442 | } |
| 443 | |