Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 142 additions & 47 deletions src/StorefrontCart.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)
{
Expand All @@ -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);
}

Expand All @@ -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);
}

Expand All @@ -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
{
Expand All @@ -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 = [
Expand All @@ -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) {
Expand All @@ -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<array<string,mixed>>,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;
}

/**
Expand All @@ -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;
}
Expand Down
7 changes: 6 additions & 1 deletion src/StorefrontPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
65 changes: 57 additions & 8 deletions src/StorefrontResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
{
Expand All @@ -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.']);
}

Expand All @@ -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']);
}

Expand Down
Loading
Loading