From 4e5c1d1e98d00228fd91ee6b9131be75fb98059e Mon Sep 17 00:00:00 2001 From: DanMat Date: Thu, 3 Sep 2026 09:25:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20natural=20add-to-cart=20flow=20?= =?UTF-8?q?=E2=80=94=20return-to-origin=20+=20cart=20summary=20+=20flashes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add-to-cart now redirects BACK to the page you were on (a server-composed, allow-listed origin from the form's `return`=shop|product + filter fields — never an echoed path/URL, per ADR 0026), with an `?added=` flash, so browsing stays natural instead of bouncing to /cart every time. - StorefrontCart: return-to-origin in mutating(); `?notice=` on failures (unavailable/expired) and checkout failures (empty/stock/expired); a read-only summary() (count = Σ line qty, total) that never mints a cart. - StorefrontResolver: resolves `added` via CatalogReadPort->get (canonical {sku,name}, active-only — never reflects the raw query), a validated `notice` enum, and a `cart_summary` closure for the header pill (section pages only). - Default templates: hidden return fields, flash bar, is-added state. Security (reviewed green): open-redirect closed by fixed path prefixes + http_build_query; count is section-page-only so it can't be baked into a path-cached page; added/notice never reflect raw input. Regression tests added. PHPStan max + phpunit (23) + cs-fixer green. Co-Authored-By: Claude Opus 4.8 --- src/StorefrontCart.php | 189 +++++++++++++++++++++++-------- src/StorefrontPlugin.php | 7 +- src/StorefrontResolver.php | 65 +++++++++-- templates/shop-cart.php | 11 ++ templates/shop-index.php | 22 ++++ templates/shop-product.php | 17 +++ tests/StorefrontCartTest.php | 90 ++++++++++++++- tests/StorefrontResolverTest.php | 40 +++++++ 8 files changed, 381 insertions(+), 60 deletions(-) diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php index e0738a4..148994d 100644 --- a/src/StorefrontCart.php +++ b/src/StorefrontCart.php @@ -16,11 +16,15 @@ * * Security posture (ADR 0026): the cart is authorised by an opaque, server-random * cookie token (`HttpOnly`+`SameSite=Lax`+`Secure`); price is resolved server-side - * by the port; the money action (checkout) is CSRF-verified with the per-cart - * secret, and cart mutations are CSRF-verified whenever a real cart exists (a - * first "bootstrap" add mints the cart and is covered by `SameSite=Lax`). Viewing - * a page never mints a cart. Cart/checkout/order pages are marked **private** so a - * CDN never serves one visitor's cart to another. + * by the port; checkout is CSRF-verified with the per-cart secret, and cart + * mutations are CSRF-verified whenever a real cart exists (a first "bootstrap" add + * mints the cart and is covered by `SameSite=Lax`). Viewing never mints a cart. + * Cart/checkout/order pages are marked **private** so a CDN never serves one + * visitor's cart to another. + * + * Add-to-cart redirects **back to where you were** (a server-composed, allow-listed + * origin — never a submitted path, honouring the ADR-0026 server-fixed-redirect + * rule) with an `?added=` flash, so browsing stays natural. */ final class StorefrontCart { @@ -30,6 +34,9 @@ final class StorefrontCart private const COOKIE_TTL = 14 * 86400; private const ORDER_TTL = 3600; + /** The only notice codes a template will render — anything else is ignored (no reflection). */ + private const NOTICES = ['unavailable', 'expired', 'empty', 'stock']; + /** @param \Closure():?CartPort $cart resolved per request; null when Commerce is absent */ public function __construct(private \Closure $cart) { @@ -40,14 +47,16 @@ public function __construct(private \Closure $cart) /** The `/cart` page — the cart priced live, with update/remove forms. Private. */ public function cartSection(Request $request): PageView { - $port = ($this->cart)(); - $meta = $this->existing($request, $port); + $port = ($this->cart)(); + $meta = $this->existing($request, $port); $contents = ($port !== null && $meta !== null) ? $port->contents($meta['token']) : ['lines' => [], 'total' => '0.00', 'count' => 0]; return new PageView('shop-cart', [ - 'cart' => $contents, - 'csrf' => $meta['csrf'] ?? '', - 'available' => $port !== null, + 'cart' => $contents, + 'csrf' => $meta['csrf'] ?? '', + 'available' => $port !== null, + 'notice' => $this->notice($request), + 'cart_summary' => $this->summaryOf($contents), ], ['title' => 'Your cart'], 200, true); } @@ -59,9 +68,10 @@ public function checkoutSection(Request $request): PageView $contents = ($port !== null && $meta !== null) ? $port->contents($meta['token']) : ['lines' => [], 'total' => '0.00', 'count' => 0]; return new PageView('shop-checkout', [ - 'cart' => $contents, - 'csrf' => $meta['csrf'] ?? '', - 'available' => $port !== null, + 'cart' => $contents, + 'csrf' => $meta['csrf'] ?? '', + 'available' => $port !== null, + 'cart_summary' => $this->summaryOf($contents), ], ['title' => 'Checkout'], 200, true); } @@ -81,11 +91,39 @@ public function orderSection(Request $request): ?PageView // --- actions (POST /ext) -------------------------------------------- + /** POST add: {sku, qty}. Mints the cart on first add; returns to the origin page with a flash. */ + public function add(Request $request): Response + { + $sku = trim((string) ($request->input('sku') ?? '')); + $origin = $this->originUrl($request); + return $this->mutating( + $request, + static function (CartPort $port, string $token) use ($request, $sku): void { + $port->add($token, $sku, (string) ($request->input('qty') ?? '1')); + }, + $origin, + $sku, + ); + } + + /** POST update: {sku, qty} (qty 0 removes). Stays on /cart. */ + public function update(Request $request): Response + { + return $this->mutating( + $request, + static function (CartPort $port, string $token) use ($request): void { + $port->setQty($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '0')); + }, + '/cart', + null, + ); + } + /** * POST checkout: place the cart as an order (server-side prices, atomic stock - * reservation) and confirm. Always CSRF-verified (a cart always pre-exists at - * checkout). On success, redirect to the private confirmation and drop the - * one-time order cookie; on any failure, back to the cart. + * reservation) and confirm. Always CSRF-verified. On success, redirect to the + * private confirmation with the one-time order cookie; on failure, back to the + * cart with a notice saying why. */ public function checkout(Request $request): Response { @@ -95,11 +133,11 @@ public function checkout(Request $request): Response } $token = $request->cookie(self::COOKIE); if ($token === null) { - return Response::redirect('/cart'); + return Response::redirect('/cart?notice=expired'); } $meta = $port->getOrCreate($token); if ($meta['token'] !== $token || !$port->csrfOk($token, $request->input('_cart_csrf'))) { - return Response::redirect('/cart'); + return Response::redirect('/cart?notice=expired'); } $customer = [ @@ -108,44 +146,49 @@ public function checkout(Request $request): Response ]; try { $ref = $port->checkout($token, $customer); - } catch (\InvalidArgumentException | \RuntimeException) { - // Empty cart, or stock that vanished between browsing and checkout — - // send them back to the cart rather than 500. - return Response::redirect('/cart'); + } catch (\InvalidArgumentException) { + return Response::redirect('/cart?notice=empty'); + } catch (\RuntimeException) { + // Stock vanished between browsing and checkout (a failed reservation). + return Response::redirect('/cart?notice=stock'); } return Response::redirect('/order/' . rawurlencode($ref)) ->withCookie(self::ORDER_COOKIE, $ref, self::ORDER_TTL); } - /** POST add: {sku, qty}. Mints the cart on first add (sets the cookie). */ - public function add(Request $request): Response - { - return $this->mutating($request, function (CartPort $port, string $token) use ($request): void { - $port->add($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '1')); - }); - } + // --- read-only summary (for the header pill) ------------------------ - /** POST update: {sku, qty} (qty 0 removes). */ - public function update(Request $request): Response + /** + * The visitor's cart summary — item **count** (Σ line qty) and total — or null + * when there's no cart or it's empty. Read-only: it never mints a cart. Used + * only on section pages (never the cached content pages), so it can't leak + * across visitors. + * + * @return array{count:int,total:string}|null + */ + public function summary(Request $request): ?array { - return $this->mutating($request, function (CartPort $port, string $token) use ($request): void { - $port->setQty($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '0')); - }); + $port = ($this->cart)(); + $meta = $this->existing($request, $port); + if ($port === null || $meta === null) { + return null; + } + return $this->summaryOf($port->contents($meta['token'])); } // --- helpers --------------------------------------------------------- /** * Run a cart mutation with the cart-token cookie + CSRF discipline, then - * redirect to /cart. If the request already carried a real cart, its CSRF - * token is required; a first add (no/stale cookie → a freshly minted cart) is - * allowed and covered by SameSite=Lax. The (possibly new) token is always - * re-set as the cookie. + * redirect to `$origin` (with an `?added=` flash on a successful add, or a + * `notice=` on failure). A pre-existing cart must present its CSRF token; a + * first add (no/stale cookie → a freshly minted cart) is allowed and covered + * by SameSite=Lax. The (possibly new) token is re-set as the cookie. * - * @param \Closure(CartPort,string):void $do + * @param \Closure(CartPort,string):mixed $do */ - private function mutating(Request $request, \Closure $do): Response + private function mutating(Request $request, \Closure $do, string $origin, ?string $addedSku): Response { $port = ($this->cart)(); if ($port === null) { @@ -155,20 +198,74 @@ private function mutating(Request $request, \Closure $do): Response $meta = $port->getOrCreate($incoming); $token = $meta['token']; - // A pre-existing cart must present its CSRF token; a bootstrap mint need not. $preExisting = $incoming !== null && $incoming === $token; if ($preExisting && !$port->csrfOk($token, $request->input('_cart_csrf'))) { - return Response::redirect('/cart'); + return Response::redirect($this->withQuery($origin, 'notice', 'expired')); } try { $do($port, $token); } catch (\InvalidArgumentException) { - // A bad qty / unavailable item: fall through to the cart, which shows - // the current state — the storefront never 500s on user input. + // A bad qty / unavailable item — never a 500; tell them, keep them put. + return Response::redirect($this->withQuery($origin, 'notice', 'unavailable')) + ->withCookie(self::COOKIE, $token, self::COOKIE_TTL); + } + + $dest = ($addedSku !== null && $addedSku !== '') ? $this->withQuery($origin, 'added', $addedSku) : $origin; + return Response::redirect($dest)->withCookie(self::COOKIE, $token, self::COOKIE_TTL); + } + + /** + * The page to return to after an add — composed **server-side** from the form's + * own allow-listed fields (`return` enum + the storefront's filter fields), + * never from a submitted URL/path, so it can only ever be an on-site + * `/shop…` or `/shop/{sku}` or `/cart`. `http_build_query` URL-encodes every + * value, so a `//evil` or CRLF in a filter becomes an inert encoded query value. + */ + private function originUrl(Request $request): string + { + $return = (string) ($request->input('return') ?? ''); + if ($return === 'product') { + $sku = trim((string) ($request->input('sku') ?? '')); + return $sku === '' ? '/cart' : '/shop/' . rawurlencode($sku); + } + if ($return === 'shop') { + $page = (int) ($request->input('page') ?? 1); + $query = array_filter([ + 'category' => trim((string) ($request->input('category') ?? '')), + 'q' => trim((string) ($request->input('q') ?? '')), + 'sort' => trim((string) ($request->input('sort') ?? '')), + 'page' => $page > 1 ? (string) $page : '', + ], static fn (string $v): bool => $v !== ''); + return '/shop' . ($query === [] ? '' : '?' . http_build_query($query)); } + return '/cart'; + } + + /** Append a single URL-encoded query param, choosing `?` or `&`. */ + private function withQuery(string $url, string $key, string $value): string + { + return $url . (str_contains($url, '?') ? '&' : '?') . $key . '=' . rawurlencode($value); + } - return Response::redirect('/cart')->withCookie(self::COOKIE, $token, self::COOKIE_TTL); + /** A validated notice code from the query, or null — never reflects raw input. */ + private function notice(Request $request): ?string + { + $n = (string) ($request->query('notice') ?? ''); + return in_array($n, self::NOTICES, true) ? $n : null; + } + + /** + * @param array{lines:list>,total:string,count:int} $contents + * @return array{count:int,total:string}|null + */ + private function summaryOf(array $contents): ?array + { + $count = 0; + foreach ($contents['lines'] as $line) { + $count += (int) $line['qty']; + } + return $count > 0 ? ['count' => $count, 'total' => $contents['total']] : null; } /** @@ -183,8 +280,6 @@ public function existing(Request $request, ?CartPort $port): ?array if ($token === null || $token === '' || $port === null) { return null; } - // getOrCreate returns the existing cart for a valid token (no INSERT); a - // stale token mints a fresh empty one, which is harmless and rare. $meta = $port->getOrCreate($token); return $meta['token'] === $token ? $meta : null; } diff --git a/src/StorefrontPlugin.php b/src/StorefrontPlugin.php index 35d72fb..f51209b 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -43,8 +43,13 @@ public function register(PluginContext $context): void // The current cart's CSRF token, for add-to-cart forms on the shop pages. $cartCsrf = static fn (Request $r): string => $cart->existing($r, $cartPort())['csrf'] ?? ''; + // The cart summary (count + total) for the header pill — read-only, never + // mints a cart, and passed ONLY into section PageViews (never the + // path-cached content pages), so a count can't leak across visitors. + $cartSummary = static fn (Request $r): ?array => $cart->summary($r); + // The themed public sections (ADR 0023): the catalog at /shop, and the cart. - $context->pages()->register('shop', new StorefrontResolver($port, $cartCsrf), $templates); + $context->pages()->register('shop', new StorefrontResolver($port, $cartCsrf, $cartSummary), $templates); $context->pages()->register('cart', $cart->cartSection(...), $templates); $context->pages()->register('checkout', $cart->checkoutSection(...), $templates); $context->pages()->register('order', $cart->orderSection(...), $templates); diff --git a/src/StorefrontResolver.php b/src/StorefrontResolver.php index 0be5599..40755bc 100644 --- a/src/StorefrontResolver.php +++ b/src/StorefrontResolver.php @@ -26,12 +26,19 @@ final class StorefrontResolver /** The handle this section is mounted at, and its URL prefix. */ private const HANDLE = 'shop'; + /** The only notice codes a template will render — anything else is ignored (no reflection). */ + private const NOTICES = ['unavailable', 'expired', 'empty', 'stock']; + /** - * @param \Closure():?CatalogReadPort $port resolved per request; null when Inventory is absent - * @param ?\Closure(Request):string $cartCsrf the current cart's CSRF token (or '') for add-to-cart forms + * @param \Closure():?CatalogReadPort $port resolved per request; null when Inventory is absent + * @param ?\Closure(Request):string $cartCsrf the current cart's CSRF token (or '') for add-to-cart forms + * @param ?\Closure(Request):(array{count:int,total:string}|null) $cartSummary the visitor's cart count/total (or null) */ - public function __construct(private \Closure $port, private ?\Closure $cartCsrf = null) - { + public function __construct( + private \Closure $port, + private ?\Closure $cartCsrf = null, + private ?\Closure $cartSummary = null, + ) { } public function __invoke(Request $request): ?PageView @@ -46,6 +53,42 @@ private function cartCsrf(Request $request): string return $this->cartCsrf !== null ? ($this->cartCsrf)($request) : ''; } + /** + * The visitor's cart summary (count + total) for the header pill, or null. Section-page only. + * + * @return array{count:int,total:string}|null + */ + private function cartSummary(Request $request): ?array + { + return $this->cartSummary !== null ? ($this->cartSummary)($request) : null; + } + + /** + * The item just added (from `?added={sku}`), for the flash + the card's "added" + * state — resolved through the port so a bogus SKU yields null (never reflected), + * and only for an active item. The returned sku/name are the CANONICAL values + * from the catalog (not the raw query), safe to compare and escape-on-render. + * Null when there's no `added`, no match, or Inventory is absent. + * + * @return array{sku:string,name:string}|null + */ + private function added(Request $request): ?array + { + $sku = trim((string) ($request->query('added') ?? '')); + if ($sku === '') { + return null; + } + $item = (($this->port)())?->get($sku); + return $item === null ? null : ['sku' => $item['sku_code'], 'name' => $item['name']]; + } + + /** A validated notice code from `?notice=`, or null — never reflects raw input. */ + private function notice(Request $request): ?string + { + $n = (string) ($request->query('notice') ?? ''); + return in_array($n, self::NOTICES, true) ? $n : null; + } + /** The listing at `/shop` — filters from the query, always a page (never 404). */ private function listing(Request $request): PageView { @@ -72,8 +115,11 @@ private function listing(Request $request): PageView 'q' => is_string($filters['q']) ? $filters['q'] : '', 'sort' => is_string($filters['sort']) ? $filters['sort'] : '', ], - 'available' => $port !== null, - 'cart_csrf' => $this->cartCsrf($request), + 'available' => $port !== null, + 'cart_csrf' => $this->cartCsrf($request), + 'cart_summary' => $this->cartSummary($request), + 'added' => $this->added($request), + 'notice' => $this->notice($request), ], ['title' => 'Shop', 'description' => 'Browse our range.']); } @@ -85,8 +131,11 @@ private function product(Request $request, string $sku): ?PageView return null; } return new PageView('shop-product', [ - 'item' => $item, - 'cart_csrf' => $this->cartCsrf($request), + 'item' => $item, + 'cart_csrf' => $this->cartCsrf($request), + 'cart_summary' => $this->cartSummary($request), + 'added' => $this->added($request), + 'notice' => $this->notice($request), ], ['title' => $item['name'], 'description' => $item['description'] ?? '', 'og_type' => 'product']); } diff --git a/templates/shop-cart.php b/templates/shop-cart.php index 46c28a6..73980b6 100644 --- a/templates/shop-cart.php +++ b/templates/shop-cart.php @@ -7,11 +7,22 @@ * @var array{lines:list>,total:string,count:int} $cart * @var string $csrf * @var bool $available + * @var ?string $notice */ +$notices = [ + 'unavailable' => 'That item is unavailable right now.', + 'expired' => 'Your session expired — please try again.', + 'empty' => 'Your cart is empty.', + 'stock' => 'Sorry, an item just went out of stock — please review your cart.', +]; ?>

Your cart

+ +

+ +

The cart is unavailable right now.

diff --git a/templates/shop-index.php b/templates/shop-index.php index 83928f6..c004ea4 100644 --- a/templates/shop-index.php +++ b/templates/shop-index.php @@ -15,7 +15,15 @@ * @var int $total * @var bool $available * @var string $cart_csrf + * @var array{sku:string,name:string}|null $added the just-added item (flash), or null + * @var ?string $notice a validated notice code, or null */ +$notices = [ + 'unavailable' => 'That item is unavailable right now.', + 'expired' => 'Your session expired — please try again.', + 'empty' => 'Your cart is empty.', + 'stock' => 'Sorry, that item just went out of stock.', +]; $labels = ['in_stock' => 'In stock', 'low' => 'Low stock', 'out' => 'Out of stock']; $sorts = ['featured' => 'Featured', 'name' => 'Name', 'price_asc' => 'Price: low to high', 'price_desc' => 'Price: high to low']; // Preserve the active filters when building a pagination link. @@ -41,11 +49,20 @@ .sf-avail.in_stock{color:#137333}.sf-avail.low{color:#b06000}.sf-avail.out{opacity:.6} .sf-pager{display:flex;gap:1rem;justify-content:center;align-items:center;margin:2rem 0 0} .sf-empty{padding:3rem 1rem;text-align:center;opacity:.7} +.sf-flash{margin:0 0 1.25rem;padding:.75rem 1rem;border-radius:.5rem;border:1px solid rgba(128,128,128,.3)} +.sf-flash-ok{border-color:rgba(19,115,51,.4);background:rgba(19,115,51,.08)} +.sf-flash-warn{border-color:rgba(176,96,0,.4);background:rgba(176,96,0,.08)}

Shop

+ +

Added to your cart.

+ +

+ +