From ca21ca8f4091bf6101cc189227ea32e559e85435 Mon Sep 17 00:00:00 2001 From: DanMat Date: Sun, 30 Aug 2026 23:14:12 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20public=20cart=20=E2=80=94=20/cart=20pag?= =?UTF-8?q?e=20+=20add/update=20actions=20(ADR=200026)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storefront's public cart face (PR③ of checkout). Themed private /cart section (no-store) + POST /ext/shop/cart/{add,update} action routes (ADR 0017) driving Commerce's CartPort — never its tables. Add-to-cart forms on the product listing + detail templates; a shop-cart template with live per-line prices + update/remove. Security (ADR 0026): the cart is an opaque server-random cookie token (HttpOnly+SameSite=Lax+Secure); viewing never mints a cart; a pre-existing cart's mutation requires its per-cart CSRF token (a first bootstrap add is covered by SameSite=Lax); server-side price throughout; degrades to a redirect when Commerce is absent. Adds nimbuscms/commerce as a soft dependency (the CartPort contract, ADR 0019). PHPStan max, 12 tests, cs-fixer green. Co-Authored-By: Claude Opus 4.8 --- composer.json | 9 ++- src/StorefrontCart.php | 123 +++++++++++++++++++++++++++++ src/StorefrontPlugin.php | 23 +++++- src/StorefrontResolver.php | 21 +++-- templates/shop-cart.php | 47 +++++++++++ templates/shop-index.php | 9 +++ templates/shop-product.php | 9 +++ tests/StorefrontCartTest.php | 148 +++++++++++++++++++++++++++++++++++ 8 files changed, 379 insertions(+), 10 deletions(-) create mode 100644 src/StorefrontCart.php create mode 100644 templates/shop-cart.php create mode 100644 tests/StorefrontCartTest.php diff --git a/composer.json b/composer.json index 89b8a99..d4d71b2 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "nimbuscms/storefront", - "description": "A public, themed storefront for NimbusCMS: renders the Inventory item master as a shoppable catalog (categories, filter, sort, search, availability). An official plugin.", + "description": "A public, themed storefront for NimbusCMS: renders the Inventory item master as a shoppable catalog (categories, filter, sort, search, availability), with a cart + checkout via Commerce. An official plugin.", "type": "nimbuscms-plugin", "keywords": [ "nimbuscms", @@ -21,7 +21,8 @@ "php": ">=8.2", "ext-json": "*", "nimbuscms/nimbus": "dev-main", - "nimbuscms/inventory": "dev-main" + "nimbuscms/inventory": "dev-main", + "nimbuscms/commerce": "dev-main" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.95", @@ -36,6 +37,10 @@ { "type": "vcs", "url": "https://github.com/NimbusCMS/plugin-inventory" + }, + { + "type": "vcs", + "url": "https://github.com/NimbusCMS/plugin-commerce" } ], "autoload": { diff --git a/src/StorefrontCart.php b/src/StorefrontCart.php new file mode 100644 index 0000000..4cfc9c3 --- /dev/null +++ b/src/StorefrontCart.php @@ -0,0 +1,123 @@ +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, + ], ['title' => 'Your cart'], 200, true); + } + + // --- actions (POST /ext) -------------------------------------------- + + /** 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')); + }); + } + + /** POST update: {sku, qty} (qty 0 removes). */ + public function update(Request $request): Response + { + return $this->mutating($request, function (CartPort $port, string $token) use ($request): void { + $port->setQty($token, (string) ($request->input('sku') ?? ''), (string) ($request->input('qty') ?? '0')); + }); + } + + // --- 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. + * + * @param \Closure(CartPort,string):void $do + */ + private function mutating(Request $request, \Closure $do): Response + { + $port = ($this->cart)(); + if ($port === null) { + return Response::redirect('/shop'); + } + $incoming = $request->cookie(self::COOKIE); + $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'); + } + + 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. + } + + return Response::redirect('/cart')->withCookie(self::COOKIE, $token, self::COOKIE_TTL); + } + + /** + * The visitor's existing cart meta, or null — WITHOUT minting one (viewing a + * page must never create a cart row). Returns null when there's no cookie. + * + * @return array{token:string,csrf:string}|null + */ + public function existing(Request $request, ?CartPort $port): ?array + { + $token = $request->cookie(self::COOKIE); + 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 71b222c..607d055 100644 --- a/src/StorefrontPlugin.php +++ b/src/StorefrontPlugin.php @@ -4,8 +4,11 @@ namespace NimbusCMS\Storefront; +use Nimbus\Http\Request; +use Nimbus\Http\Response; use Nimbus\Plugin\Plugin; use Nimbus\Plugin\PluginContext; +use NimbusCMS\Commerce\CartPort; use NimbusCMS\Inventory\CatalogReadPort; /** @@ -31,9 +34,23 @@ public function register(PluginContext $context): void // installed, so the resolver degrades gracefully. $port = static fn (): ?CatalogReadPort => $context->services()->get(CatalogReadPort::class); - // A themed public section at /shop, with this plugin's default templates as - // the theme-overridable fallback. - $context->pages()->register('shop', new StorefrontResolver($port), dirname(__DIR__) . '/templates'); + // The cart, driven through Commerce's CartPort (ADR 0026) — null when + // Commerce is absent, so a browse-only storefront still works. + $cartPort = static fn (): ?CartPort => $context->services()->get(CartPort::class); + $cart = new StorefrontCart($cartPort); + $templates = dirname(__DIR__) . '/templates'; + + // 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 themed public sections (ADR 0023): the catalog at /shop, and the cart. + $context->pages()->register('shop', new StorefrontResolver($port, $cartCsrf), $templates); + $context->pages()->register('cart', $cart->cartSection(...), $templates); + + // The cart mutations — public POST actions (ADR 0017), CSRF-guarded, that + // redirect back to /cart. + $context->routes()->post('shop', '/cart/add', static fn (Request $r, array $p): Response => $cart->add($r)); + $context->routes()->post('shop', '/cart/update', static fn (Request $r, array $p): Response => $cart->update($r)); // Teach an agent what the storefront is (ADR 0013). $context->skills()->register('Storefront', Guide::text()); diff --git a/src/StorefrontResolver.php b/src/StorefrontResolver.php index 3125962..0be5599 100644 --- a/src/StorefrontResolver.php +++ b/src/StorefrontResolver.php @@ -26,15 +26,24 @@ final class StorefrontResolver /** The handle this section is mounted at, and its URL prefix. */ private const HANDLE = 'shop'; - /** @param \Closure():?CatalogReadPort $port resolved per request; null when Inventory is absent */ - public function __construct(private \Closure $port) + /** + * @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 + */ + public function __construct(private \Closure $port, private ?\Closure $cartCsrf = null) { } public function __invoke(Request $request): ?PageView { $sku = $this->skuFromPath($request->path); - return $sku === null ? $this->listing($request) : $this->product($sku); + return $sku === null ? $this->listing($request) : $this->product($request, $sku); + } + + /** The current cart's CSRF token for add-to-cart forms (empty when no cart yet). */ + private function cartCsrf(Request $request): string + { + return $this->cartCsrf !== null ? ($this->cartCsrf)($request) : ''; } /** The listing at `/shop` — filters from the query, always a page (never 404). */ @@ -64,18 +73,20 @@ private function listing(Request $request): PageView 'sort' => is_string($filters['sort']) ? $filters['sort'] : '', ], 'available' => $port !== null, + 'cart_csrf' => $this->cartCsrf($request), ], ['title' => 'Shop', 'description' => 'Browse our range.']); } /** A product page at `/shop/{sku}`, or null (→ themed 404) when not found/active. */ - private function product(string $sku): ?PageView + private function product(Request $request, string $sku): ?PageView { $item = (($this->port)())?->get($sku); if ($item === null) { return null; } return new PageView('shop-product', [ - 'item' => $item, + 'item' => $item, + 'cart_csrf' => $this->cartCsrf($request), ], ['title' => $item['name'], 'description' => $item['description'] ?? '', 'og_type' => 'product']); } diff --git a/templates/shop-cart.php b/templates/shop-cart.php new file mode 100644 index 0000000..46c28a6 --- /dev/null +++ b/templates/shop-cart.php @@ -0,0 +1,47 @@ +>,total:string,count:int} $cart + * @var string $csrf + * @var bool $available + */ +?> +
+

Your cart

+ + +

The cart is unavailable right now.

+ +

Your cart is empty. Browse the shop.

+ +
+ +
+
/
+
+ + + + + +
+
+
+ + + + +
+
+ +
+ +
+

Total

+ Checkout +
+ +
diff --git a/templates/shop-index.php b/templates/shop-index.php index e008f0b..83928f6 100644 --- a/templates/shop-index.php +++ b/templates/shop-index.php @@ -14,6 +14,7 @@ * @var int $pages * @var int $total * @var bool $available + * @var string $cart_csrf */ $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']; @@ -94,6 +95,14 @@ / ' . $e($it['unit']) . '' : '' ?> + +
+ + + + +
+ diff --git a/templates/shop-product.php b/templates/shop-product.php index ffd56dd..3a7cbee 100644 --- a/templates/shop-product.php +++ b/templates/shop-product.php @@ -7,6 +7,7 @@ * @var callable(?int):?array{url:string,alt:?string} $media * @var string $cspNonce * @var array $item + * @var string $cart_csrf */ $labels = ['in_stock' => 'In stock', 'low' => 'Low stock', 'out' => 'Out of stock']; $img = $media($item['image_media_id']); @@ -33,6 +34,14 @@

/ ' . $e($item['unit']) . '' : '' ?>

+ +
+ + + + +
+

diff --git a/tests/StorefrontCartTest.php b/tests/StorefrontCartTest.php new file mode 100644 index 0000000..ad4d977 --- /dev/null +++ b/tests/StorefrontCartTest.php @@ -0,0 +1,148 @@ +port = new FakeCartPort(); + $port = $this->port; + $this->cart = new StorefrontCart(static fn (): CartPort => $port); + } + + /** @param array $post */ + private function request(string $method, array $post = [], ?string $cookie = null): Request + { + $cookies = $cookie === null ? [] : [StorefrontCart::COOKIE => $cookie]; + return new Request($method, '/', [], $post, [], [], null, '', $cookies); + } + + public function test_the_cart_page_is_private_and_empty_without_a_cookie(): void + { + $view = $this->cart->cartSection($this->request('GET')); + self::assertInstanceOf(PageView::class, $view); + self::assertSame('shop-cart', $view->template); + self::assertTrue($view->private, 'the cart is no-store'); + self::assertSame(0, $view->data['cart']['count']); + self::assertSame([], $this->port->minted, 'viewing without a cookie mints no cart'); + } + + public function test_a_first_add_bootstraps_a_cart_sets_the_cookie_and_adds(): void + { + $res = $this->cart->add($this->request('POST', ['sku' => 'apple', 'qty' => '2'])); + + self::assertSame(302, $res->status); + self::assertSame('/cart', $res->headers['Location'] ?? null); + self::assertStringContainsString(StorefrontCart::COOKIE . '=', $res->headers['Set-Cookie'] ?? ''); + self::assertStringContainsString('HttpOnly', $res->headers['Set-Cookie'] ?? ''); + self::assertStringContainsString('SameSite=Lax', $res->headers['Set-Cookie'] ?? ''); + self::assertSame([['apple', '2']], $this->port->added, 'the item was added to the fresh cart'); + } + + public function test_a_pre_existing_cart_mutation_requires_the_csrf_token(): void + { + $token = $this->port->seed('the-csrf-secret'); + + // Wrong/absent token → no add. + $this->cart->add($this->request('POST', ['sku' => 'apple', 'qty' => '1', '_cart_csrf' => 'wrong'], $token)); + self::assertSame([], $this->port->added, 'a pre-existing cart rejects a bad CSRF token'); + + // Correct token → add. + $this->cart->add($this->request('POST', ['sku' => 'apple', 'qty' => '1', '_cart_csrf' => 'the-csrf-secret'], $token)); + self::assertSame([['apple', '1']], $this->port->added); + } + + public function test_update_removes_via_qty_zero(): void + { + $token = $this->port->seed('sec'); + $this->cart->update($this->request('POST', ['sku' => 'apple', 'qty' => '0', '_cart_csrf' => 'sec'], $token)); + self::assertSame([['apple', '0']], $this->port->setQ); + } + + public function test_without_commerce_a_mutation_just_redirects(): void + { + $cart = new StorefrontCart(static fn (): ?CartPort => null); + $res = $cart->add($this->request('POST', ['sku' => 'apple', 'qty' => '1'])); + self::assertSame(302, $res->status); + self::assertSame('/shop', $res->headers['Location'] ?? null); + } +} + +/** A minimal CartPort double that records what the storefront asked it to do. */ +final class FakeCartPort implements CartPort +{ + /** @var array token => csrf */ + private array $carts = []; + /** @var list tokens minted this test */ + public array $minted = []; + /** @var list */ + public array $added = []; + /** @var list */ + public array $setQ = []; + + /** Seed a pre-existing cart, returning its token. */ + public function seed(string $csrf): string + { + $token = 'existing-token'; + $this->carts[$token] = $csrf; + return $token; + } + + public function getOrCreate(?string $token): array + { + if ($token !== null && isset($this->carts[$token])) { + return ['token' => $token, 'csrf' => $this->carts[$token]]; + } + $new = 'minted-' . bin2hex(random_bytes(4)); + $this->carts[$new] = 'csrf-' . $new; + $this->minted[] = $new; + return ['token' => $new, 'csrf' => $this->carts[$new]]; + } + + public function csrfOk(string $token, ?string $submitted): bool + { + return is_string($submitted) && isset($this->carts[$token]) && hash_equals($this->carts[$token], $submitted); + } + + public function add(string $token, string $sku, string $qty): void + { + $this->added[] = [$sku, $qty]; + } + + public function setQty(string $token, string $sku, string $qty): void + { + $this->setQ[] = [$sku, $qty]; + } + + public function remove(string $token, string $sku): void + { + } + + public function contents(string $token): array + { + return ['lines' => [], 'total' => '0.00', 'count' => 0]; + } + + public function checkout(string $token, array $customer): string + { + return 'ORD-TEST'; + } +}