From ceab5e47e9a5cb77dd274cba0e62cbace5c827fc Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 21 Jan 2026 20:33:09 +0800 Subject: [PATCH 01/12] Add token-based security for cart loading - Add secure token validation to load-cart action - Carts with email/addresses require valid token or owner authentication - Carts without sensitive data can load without token - Add email challenge flow for unauthenticated cart recovery - Register commerce_cart_recovery system message for recovery emails - Add cartLinkExpiry setting (default 24 hours) - Add getLoadCartUrl() to Carts service for generating secure URLs --- src/Plugin.php | 20 +++ src/controllers/CartController.php | 151 ++++++++++++++++++++++- src/elements/Order.php | 11 +- src/models/Settings.php | 11 +- src/services/Carts.php | 27 ++++ src/templates/_cart/email-challenge.twig | 83 +++++++++++++ src/templates/_cart/email-sent.twig | 60 +++++++++ src/translations/en/commerce.php | 6 + 8 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 src/templates/_cart/email-challenge.twig create mode 100644 src/templates/_cart/email-sent.twig diff --git a/src/Plugin.php b/src/Plugin.php index 7100d1a27e..ca4ac5e7a7 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -719,6 +719,12 @@ function(RegisterEmailMessagesEvent $event) { 'subject' => Craft::t('commerce', 'Your Order PDF Download Link'), 'body' => $this->_getDefaultPdfDownloadMessage(), ], + [ + 'key' => 'commerce_cart_recovery', + 'heading' => Craft::t('commerce', 'Cart Recovery Link'), + 'subject' => Craft::t('commerce', 'Your Cart Recovery Link'), + 'body' => $this->_getDefaultCartRecoveryMessage(), + ], ]); } ); @@ -1106,4 +1112,18 @@ private function _getDefaultPdfDownloadMessage(): string "**Please note:** This link will expire for security purposes.\n\n" . "Thank you!"; } + + /** + * Returns the default message body for the cart recovery email. + * + * @return string + */ + private function _getDefaultCartRecoveryMessage(): string + { + return "Hello,\n\n" . + "You requested a link to recover your shopping cart. Click the link below to continue shopping:\n\n" . + "[Recover My Cart]({{ link }})\n\n" . + "**Please note:** This link will expire for security purposes.\n\n" . + "Thank you!"; + } } diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index e2e93f0e85..0d829a3747 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -25,6 +25,7 @@ use yii\base\Exception; use yii\base\InvalidConfigException; use yii\mutex\Mutex; +use craft\web\View; use yii\web\BadRequestHttpException; use yii\web\HttpException; use yii\web\NotFoundHttpException; @@ -356,16 +357,15 @@ public function actionLoadCart(): ?Response { $carts = Plugin::getInstance()->getCarts(); $number = $this->request->getParam('number'); + $token = $this->request->getParam('token'); $loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? ''; $redirect = UrlHelper::siteUrl($loadCartRedirectUrl); if (!$number) { $error = Craft::t('commerce', 'A cart number must be specified.'); - if ($this->request->getAcceptsJson()) { return $this->asFailure($error); } - $this->setFailFlash($error); return $this->request->getIsGet() ? $this->redirect($redirect) : null; } @@ -374,18 +374,55 @@ public function actionLoadCart(): ?Response if (!$cart) { $error = Craft::t('commerce', 'Unable to retrieve cart.'); - if ($this->request->getAcceptsJson()) { return $this->asFailure($error); } - $this->setFailFlash($error); return $this->request->getIsGet() ? $this->redirect($redirect) : null; } - // If we have a cart, use the site for that cart for the URL redirect. - $redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId); + // Carts without email or addresses don't need token validation + $hasEmail = (bool)$cart->getEmail(); + $hasAddresses = $cart->billingAddressId || $cart->shippingAddressId; + + if ($hasEmail || $hasAddresses) { + $currentUser = Craft::$app->getUser()->getIdentity(); + $hasValidToken = false; + + // Check token if provided + if ($token) { + $tokenData = Craft::$app->getTokens()->getTokenRoute($token); + + if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) { + Craft::$app->getSession()->setError(Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.')); + return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + } + + if (isset($tokenData[1]['expiresAt'])) { + $now = (new \DateTime())->getTimestamp(); + if ($now > $tokenData[1]['expiresAt']) { + return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + } + } + + $hasValidToken = true; + } + + // Check permissions if no valid token + if (!$hasValidToken) { + if ($currentUser) { + $isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id; + if (!$isCartCustomer) { + return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + } + } else { + return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + } + } + } + // Load the cart (existing logic) + $redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId); $carts->forgetCart(); $carts->setSessionCartNumber($number); @@ -774,4 +811,106 @@ private function _setAddresses(): void } } } + + /** + * Renders the cart email challenge template. + */ + private function renderCartEmailChallenge(Order $cart, string $cartNumber): Response + { + return $this->renderTemplate('commerce/_cart/email-challenge', [ + 'cart' => $cart, + 'cartNumber' => $cartNumber, + ], View::TEMPLATE_MODE_CP); + } + + /** + * Displays the email challenge form for cart recovery. + * @since 4.x + */ + public function actionEmailChallenge(): Response + { + $number = $this->request->getQueryParam('number'); + + if (!$number) { + throw new BadRequestHttpException('Cart number required'); + } + + $cart = Order::find()->number($number)->isCompleted(false)->one(); + + if (!$cart || !$cart->getEmail()) { + throw new HttpException(404, 'Cart not found'); + } + + return $this->renderCartEmailChallenge($cart, $number); + } + + /** + * Handles the email challenge form submission for cart recovery. + * @since 4.x + */ + public function actionCartChallenge(): Response + { + $this->requirePostRequest(); + + $cartNumberHash = $this->request->getBodyParam('cartNumberHash'); + + if (!$cartNumberHash) { + throw new BadRequestHttpException('Cart number hash is required'); + } + + $cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash); + + if ($cartNumber === false) { + throw new BadRequestHttpException('Invalid cart number hash'); + } + + $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); + + if (!$cart) { + throw new HttpException(404, 'Cart not found'); + } + + $loadCartUrl = Plugin::getInstance()->getCarts()->getLoadCartUrl($cart); + + if (!Craft::$app->getMailer()->composeFromKey('commerce_cart_recovery', [ + 'link' => $loadCartUrl, + 'cart' => $cart, + ])->setTo($cart->email)->send()) { + Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.')); + return $this->renderCartEmailChallenge($cart, $cartNumber); + } + + Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A cart recovery link has been sent to {email}', ['email' => $cart->getMaskedEmail()])); + + return $this->redirect(UrlHelper::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash])); + } + + /** + * Displays success page after cart recovery email is sent. + * @since 4.x + */ + public function actionCartSent(): Response + { + $cartNumberHash = $this->request->getQueryParam('hash'); + + if (!$cartNumberHash) { + throw new BadRequestHttpException('Hash parameter required'); + } + + $cartNumber = Craft::$app->getSecurity()->validateData($cartNumberHash); + + if ($cartNumber === false) { + throw new HttpException(400, 'Invalid hash parameter'); + } + + $cart = Order::find()->number($cartNumber)->isCompleted(false)->one(); + + if (!$cart) { + throw new HttpException(404, 'Cart not found'); + } + + return $this->renderTemplate('commerce/_cart/email-sent', [ + 'email' => $cart->getMaskedEmail(), + ], View::TEMPLATE_MODE_CP); + } } diff --git a/src/elements/Order.php b/src/elements/Order.php index 86e701fa98..41660c6519 100644 --- a/src/elements/Order.php +++ b/src/elements/Order.php @@ -2252,9 +2252,9 @@ public function getPdfUrl(string $option = null, string $pdfHandle = null, bool } /** - * Returns the URL to the cart’s load action url + * Returns the URL to the cart's load action url with a secure token. * - * @return string|null The URL to the order’s load cart URL, or null if the cart is an order + * @return string|null The URL to the order's load cart URL, or null if the cart is an order * @noinspection PhpUnused */ public function getLoadCartUrl(): ?string @@ -2263,12 +2263,7 @@ public function getLoadCartUrl(): ?string return null; } - $path = 'commerce/cart/load-cart'; - - $params = []; - $params['number'] = $this->number; - - return UrlHelper::actionUrl($path, $params); + return Plugin::getInstance()->getCarts()->getLoadCartUrl($this); } /** diff --git a/src/models/Settings.php b/src/models/Settings.php index 938fe71602..757dad3f30 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -215,13 +215,22 @@ class Settings extends Model /** * @var string|null Default URL to be loaded after using the [load cart controller action](orders-carts.md#loading-a-cart). * - * If `null` (default), Craft’s default [`siteUrl`](config4:siteUrl) will be used. + * If `null` (default), Craft's default [`siteUrl`](config4:siteUrl) will be used. * * @group Cart * @since 3.1 */ public ?string $loadCartRedirectUrl = null; + /** + * @var int How long (in seconds) a cart recovery link should remain valid before expiring. + * Default is 86400 (24 hours). + * + * @group Cart + * @since 4.x + */ + public int $cartLinkExpiry = 86400; + /** * @var string How Commerce should handle minimum total price for an order. * diff --git a/src/services/Carts.php b/src/services/Carts.php index d61258659b..400a7b5a3d 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -18,6 +18,7 @@ use craft\helpers\DateTimeHelper; use craft\helpers\Db; use craft\helpers\StringHelper; +use craft\helpers\UrlHelper; use DateTime; use Throwable; use yii\base\Component; @@ -309,6 +310,32 @@ public function setSessionCartNumber(string $cartNumber): void } } + /** + * Returns a URL to load a cart with a secure token. + * + * @param Order $cart The cart to generate the load URL for + * @return string The URL with secure token + * @since 4.x + */ + public function getLoadCartUrl(Order $cart): string + { + $linkExpiry = Plugin::getInstance()->getSettings()->cartLinkExpiry; + $expiryTimestamp = (new \DateTime())->add(new \DateInterval('PT' . $linkExpiry . 'S'))->getTimestamp(); + + $token = Craft::$app->getTokens()->createToken([ + 'commerce/cart/load-cart', + [ + 'cartNumber' => $cart->number, + 'expiresAt' => $expiryTimestamp, + ], + ]); + + return UrlHelper::siteUrl('actions/commerce/cart/load-cart', [ + 'number' => $cart->number, + 'token' => $token, + ]); + } + /** * Restores previous cart for the current user if their current cart is empty. * Ideally this is only used when a user logs in. diff --git a/src/templates/_cart/email-challenge.twig b/src/templates/_cart/email-challenge.twig new file mode 100644 index 0000000000..fbf986a1bc --- /dev/null +++ b/src/templates/_cart/email-challenge.twig @@ -0,0 +1,83 @@ +{% extends '_layouts/basecp.twig' %} +{% import '_includes/forms.twig' as forms %} +{% set title = "Recover Cart"|t('commerce') %} +{% set bodyClass = 'login' %} + +{% set hasLogo = CraftEdition >= CraftPro and craft.rebrand.isLogoUploaded %} + +{% if hasLogo %} + {% set logo = craft.rebrand.logo %} +{% endif %} + +{% block body %} +
+
+ {% if hasLogo %} +

+ {{ tag('img', { + id: 'login-logo', + src: logo.url, + alt: systemName, + width: logo.width, + height: logo.height, + }) }} +

+ {% endif %} + + {% if craft.app.session.hasFlash('error') %} +
+ {{ craft.app.session.getFlash('error') }} +
+ {% endif %} + +
+ {% tag 'form' with { + 'action': url(''), + method: 'post', + 'accept-charset': 'UTF-8', + } %} +

{{ 'Expired Link'|t('commerce') }}

+

{{ 'A cart recovery link will be sent to {email}'|t('commerce', {email: cart.getMaskedEmail()}) }}

+ + {{ csrfInput() }} + {{ actionInput('commerce/cart/cart-challenge') }} + {{ hiddenInput('cartNumberHash', craft.app.security.hashData(cartNumber)) }} + + {{ forms.submitButton({ + class: ['fullwidth', 'last'], + label: 'Send'|t('app'), + spinner: true, + }) }} + {% endtag %} + +
+ +
+
+{% endblock %} + +{% css %} +main { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} + +#recovercart { + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +@media (max-width: 768px) { + #recovercart { + max-width: 100%; + } +} + +.confirmInfo{ + text-wrap: balance; +} +{% endcss %} diff --git a/src/templates/_cart/email-sent.twig b/src/templates/_cart/email-sent.twig new file mode 100644 index 0000000000..a8cc76964c --- /dev/null +++ b/src/templates/_cart/email-sent.twig @@ -0,0 +1,60 @@ +{% extends '_layouts/basecp.twig' %} +{% set title = "Email Sent"|t('app') %} +{% set bodyClass = 'login' %} + +{% set hasLogo = CraftEdition >= CraftPro and craft.rebrand.isLogoUploaded %} + +{% if hasLogo %} + {% set logo = craft.rebrand.logo %} +{% endif %} + +{% block body %} +
+
+ {% if hasLogo %} +

+ {{ tag('img', { + id: 'login-logo', + src: logo.url, + alt: systemName, + width: logo.width, + height: logo.height, + }) }} +

+ {% endif %} + +
+

{{ 'Link Sent'|t('commerce') }}

+

+ {{ 'A cart recovery link has been sent to {email}'|t('commerce', {email: email}) }} +

+
+
+
+{% endblock %} + +{% css %} +main { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 1rem; +} + +#emailsent { + width: 100%; + max-width: 400px; + margin: 0 auto; +} + +@media (max-width: 768px) { + #emailsent { + max-width: 100%; + } +} + +.sentInfo { + text-wrap: balance; +} +{% endcss %} diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index b2551bac49..5808bfafa2 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -11,6 +11,8 @@ '(of original price)' => '(of original price)', '(off original price)' => '(off original price)', 'A cart number must be specified.' => 'A cart number must be specified.', + 'A cart recovery link has been sent to {email}' => 'A cart recovery link has been sent to {email}', + 'A cart recovery link will be sent to {email}' => 'A cart recovery link will be sent to {email}', 'A friendly reference number will be generated based on this format when a cart is completed and becomes an order. For example {ex1}, or
{ex2}. The result of this format must be unique.' => 'A friendly reference number will be generated based on this format when a cart is completed and becomes an order. For example {ex1}, or
{ex2}. The result of this format must be unique.', 'A new download link has been sent to {email}' => 'A new download link has been sent to {email}', 'A new download link will be sent to {email}' => 'A new download link will be sent to {email}', @@ -128,6 +130,7 @@ 'Card Number' => 'Card Number', 'Card' => 'Card', 'Cart forgotten.' => 'Cart forgotten.', + 'Cart Recovery Link' => 'Cart Recovery Link', 'Cart updated.' => 'Cart updated.', 'Cart' => 'Cart', 'Carts' => 'Carts', @@ -799,6 +802,7 @@ 'Recalculate order' => 'Recalculate order', 'Recent Orders' => 'Recent Orders', 'Recipient' => 'Recipient', + 'Recover Cart' => 'Recover Cart', 'Reduce price' => 'Reduce price', 'Reduce the price by a fixed amount' => 'Reduce the price by a fixed amount', 'Reduce the price by a percentage of the original price' => 'Reduce the price by a percentage of the original price', @@ -980,6 +984,7 @@ 'The address provided is outside the store’s market.' => 'The address provided is outside the store’s market.', 'The amount of discount that is applied to the whole order. This amount is spread across line items in order of highest price to lowest price, until the discount is used up.' => 'The amount of discount that is applied to the whole order. This amount is spread across line items in order of highest price to lowest price, until the discount is used up.', 'The base discount can only discount items in the cart to down to zero until it is used up, it can not make the order negative.' => 'The base discount can only discount items in the cart to down to zero until it is used up, it can not make the order negative.', + 'The cart recovery link is invalid. Please request a new one.' => 'The cart recovery link is invalid. Please request a new one.', 'The conversion rate that will be used when converting an amount to this currency. For example, if an item costs {amount1}, a conversion rate of {rate} would result in {amount2} in the alternate currency.' => 'The conversion rate that will be used when converting an amount to this currency. For example, if an item costs {amount1}, a conversion rate of {rate} would result in {amount2} in the alternate currency.', 'The countries that orders are allowed to be placed from.' => 'The countries that orders are allowed to be placed from.', 'The download link is invalid. Please request a new one.' => 'The download link is invalid. Please request a new one.', @@ -1183,6 +1188,7 @@ 'You must be signed in to create a payment source.' => 'You must be signed in to create a payment source.', 'You must be signed in to set a primary payment source.' => 'You must be signed in to set a primary payment source.', 'You must make a payment to complete the order.' => 'You must make a payment to complete the order.', + 'Your Cart Recovery Link' => 'Your Cart Recovery Link', 'Your Order PDF Download Link' => 'Your Order PDF Download Link', 'Your order is empty' => 'Your order is empty', 'ZIP file' => 'ZIP file', From 0f6e621d757fafea0394b3869971e71c72296599 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 21 Jan 2026 21:11:13 +0800 Subject: [PATCH 02/12] Cleanup --- src/controllers/CartController.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 0d829a3747..773fed42ff 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -421,7 +421,8 @@ public function actionLoadCart(): ?Response } } - // Load the cart (existing logic) + // Set the token to null on the request so it will not be added to the redirect URL that is generated + $this->request->setToken(null); $redirect = UrlHelper::siteUrl(path: $loadCartRedirectUrl, siteId: $cart->orderSiteId); $carts->forgetCart(); $carts->setSessionCartNumber($number); @@ -880,8 +881,6 @@ public function actionCartChallenge(): Response return $this->renderCartEmailChallenge($cart, $cartNumber); } - Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A cart recovery link has been sent to {email}', ['email' => $cart->getMaskedEmail()])); - return $this->redirect(UrlHelper::actionUrl('commerce/cart/cart-sent', ['hash' => $cartNumberHash])); } From a5a24af3bc0971ee01073d7d7dc421ede232dced Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 21 Jan 2026 21:12:08 +0800 Subject: [PATCH 03/12] Cleanup --- src/controllers/CartController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 773fed42ff..6f170f9664 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -20,12 +20,12 @@ use craft\errors\ElementNotFoundException; use craft\errors\MissingComponentException; use craft\helpers\UrlHelper; +use craft\web\View; use Illuminate\Support\Collection; use Throwable; use yii\base\Exception; use yii\base\InvalidConfigException; use yii\mutex\Mutex; -use craft\web\View; use yii\web\BadRequestHttpException; use yii\web\HttpException; use yii\web\NotFoundHttpException; From 03f55010f323d10205d5f6ff696ac832c89d737b Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 21 Jan 2026 21:29:48 +0800 Subject: [PATCH 04/12] Backwards compatible but more secure number generation --- src/services/Carts.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Carts.php b/src/services/Carts.php index 400a7b5a3d..e839f0f489 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -216,7 +216,7 @@ public function forgetCart(): void */ public function generateCartNumber(): string { - return md5(uniqid((string)mt_rand(), true)); + return bin2hex(random_bytes(16)); } /** From 65b7a0acc16d24815a6302ee2bfaad467454fffa Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Tue, 12 May 2026 20:28:44 +0800 Subject: [PATCH 05/12] WIP --- CHANGELOG-WIP.md | 22 +++++++++++++++ src/controllers/CartController.php | 35 ++++++++++++++++-------- src/controllers/OrdersController.php | 26 ++++++++++++++++++ src/elements/actions/CopyLoadCartUrl.php | 26 ++++++++++-------- src/models/Settings.php | 4 +-- src/services/Carts.php | 13 ++++----- src/templates/_cart/email-challenge.twig | 2 +- 7 files changed, 93 insertions(+), 35 deletions(-) create mode 100644 CHANGELOG-WIP.md diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 0000000000..e57ec03699 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,22 @@ +# Release Notes for Craft Commerce 4.11 + +### Store Management + +- Cart load URLs are now generated with time-limited security tokens, requiring a valid token or authenticated cart ownership to load a cart. +- Anonymous users attempting to load a cart with an expired or missing token are now shown a cart recovery form, which sends a new recovery link to the cart's email address. +- Added a new `commerce_cart_recovery` system message for customizing cart recovery emails. +- The "Share cart…" element action now generates a secure tokenized URL. + +### Administration + +- Added the `cartLoadUrlExpiry` setting, for controlling how long cart load links remain valid (default: 7 days). + +### Extensibility + +- Added `craft\commerce\controllers\OrdersController::actionGetLoadCartUrl()`. +- Added `craft\commerce\controllers\CartController::actionEmailChallenge()`. +- Added `craft\commerce\controllers\CartController::actionCartChallenge()`. +- Added `craft\commerce\controllers\CartController::actionCartSent()`. +- Added `craft\commerce\services\Carts::getLoadCartUrl()`. +- `craft\commerce\elements\Order::getLoadCartUrl()` now returns a secure tokenized URL. +- `commerce/cart/load-cart` now returns JSON responses for `Accept: application/json` requests, including a `challengeUrl` on failure. diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 6f170f9664..a294de6609 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -394,15 +394,13 @@ public function actionLoadCart(): ?Response $tokenData = Craft::$app->getTokens()->getTokenRoute($token); if (!$tokenData || !isset($tokenData[1]['cartNumber']) || $tokenData[1]['cartNumber'] !== $number) { - Craft::$app->getSession()->setError(Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.')); - return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); - } - - if (isset($tokenData[1]['expiresAt'])) { - $now = (new \DateTime())->getTimestamp(); - if ($now > $tokenData[1]['expiresAt']) { - return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + $error = Craft::t('commerce', 'The cart recovery link is invalid. Please request a new one.'); + $challengeUrl = UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]); + if ($this->request->getAcceptsJson()) { + return $this->asFailure($error, ['challengeUrl' => $challengeUrl]); } + $this->setFailFlash($error); + return $this->redirect($challengeUrl); } $hasValidToken = true; @@ -410,13 +408,26 @@ public function actionLoadCart(): ?Response // Check permissions if no valid token if (!$hasValidToken) { + $challengeUrl = UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number]); if ($currentUser) { $isCartCustomer = $cart->getCustomer() && $cart->getCustomer()->id === $currentUser->id; if (!$isCartCustomer) { - return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + if ($this->request->getAcceptsJson()) { + return $this->asFailure( + Craft::t('commerce', 'You do not have permission to load this cart.'), + ['challengeUrl' => $challengeUrl] + ); + } + return $this->redirect($challengeUrl); } } else { - return $this->redirect(UrlHelper::actionUrl('commerce/cart/email-challenge', ['number' => $number])); + if ($this->request->getAcceptsJson()) { + return $this->asFailure( + Craft::t('commerce', 'You must be logged in or provide a valid token to load this cart.'), + ['challengeUrl' => $challengeUrl] + ); + } + return $this->redirect($challengeUrl); } } } @@ -826,7 +837,7 @@ private function renderCartEmailChallenge(Order $cart, string $cartNumber): Resp /** * Displays the email challenge form for cart recovery. - * @since 4.x + * @since 4.12 */ public function actionEmailChallenge(): Response { @@ -847,7 +858,7 @@ public function actionEmailChallenge(): Response /** * Handles the email challenge form submission for cart recovery. - * @since 4.x + * @since 4.12 */ public function actionCartChallenge(): Response { diff --git a/src/controllers/OrdersController.php b/src/controllers/OrdersController.php index d686d1417b..ef24deb508 100644 --- a/src/controllers/OrdersController.php +++ b/src/controllers/OrdersController.php @@ -64,6 +64,7 @@ use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\HttpException; +use yii\web\NotFoundHttpException; use yii\web\Response; /** @@ -757,6 +758,31 @@ public function actionCreateCustomer(): Response return $this->asSuccess(data: compact('user')); } + /** + * Returns a secure load-cart URL (with token) for the given cart number. + * Intended for CP use via the "Share cart" element action. + * + * @throws BadRequestHttpException + * @throws NotFoundHttpException + * @since 4.12 + */ + public function actionGetLoadCartUrl(): Response + { + $this->requireAcceptsJson(); + $this->requirePermission('commerce-manageOrders'); + + $number = $this->request->getRequiredParam('number'); + $cart = Order::find()->number($number)->isCompleted(false)->one(); + + if (!$cart) { + throw new NotFoundHttpException('Cart not found.'); + } + + return $this->asSuccess(data: [ + 'url' => Plugin::getInstance()->getCarts()->getLoadCartUrl($cart), + ]); + } + /** * @throws BadRequestHttpException * @throws InvalidConfigException diff --git a/src/elements/actions/CopyLoadCartUrl.php b/src/elements/actions/CopyLoadCartUrl.php index 7fdbb413ec..250d1de2d1 100644 --- a/src/elements/actions/CopyLoadCartUrl.php +++ b/src/elements/actions/CopyLoadCartUrl.php @@ -40,29 +40,31 @@ public function getTriggerLabel(): string public function getTriggerHtml(): ?string { $type = Json::encode(static::class); + $actionUrl = Json::encode(UrlHelper::actionUrl(‘commerce/orders/get-load-cart-url’)); - $url = UrlHelper::actionUrl('commerce/cart/load-cart', ['number' => '{number}']); - $js = << { - var url = "$url"; new Craft.ElementActionTrigger({ - type: $type, + type: %s, batch: false, - validateSelection: function(\$selectedItems) + validateSelection: function($selectedItems) { - return !!\$selectedItems.find('.element').data('number'); + return !!$selectedItems.find(‘.element’).data(‘number’); }, - activate: function(\$selectedItems) + activate: function($selectedItems) { - Craft.ui.createCopyTextPrompt({ - label: Craft.t('commerce', 'Copy the URL'), - instructions: Craft.t('commerce', 'This URL will load the cart into the user’s session, making it the active cart.'), - value: url.replace("{number}", \$selectedItems.find('.element').data('number')), + var number = $selectedItems.find(‘.element’).data(‘number’); + Craft.sendActionRequest(‘GET’, %s, {params: {number: number}}).then(function(response) { + Craft.ui.createCopyTextPrompt({ + label: Craft.t(‘commerce’, ‘Copy the URL’), + instructions: Craft.t(‘commerce’, "This URL will load the cart into the user’s session, making it the active cart."), + value: response.data.url, + }); }); } }); })(); -JS; +JS, $type, $actionUrl); Craft::$app->getView()->registerJs($js); return null; diff --git a/src/models/Settings.php b/src/models/Settings.php index 757dad3f30..b529b75a48 100644 --- a/src/models/Settings.php +++ b/src/models/Settings.php @@ -224,12 +224,12 @@ class Settings extends Model /** * @var int How long (in seconds) a cart recovery link should remain valid before expiring. - * Default is 86400 (24 hours). + * Default is 604800 (7 days). * * @group Cart * @since 4.x */ - public int $cartLinkExpiry = 86400; + public int $cartLoadUrlExpiry = 604800; /** * @var string How Commerce should handle minimum total price for an order. diff --git a/src/services/Carts.php b/src/services/Carts.php index e839f0f489..b0d32d987f 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -319,18 +319,15 @@ public function setSessionCartNumber(string $cartNumber): void */ public function getLoadCartUrl(Order $cart): string { - $linkExpiry = Plugin::getInstance()->getSettings()->cartLinkExpiry; - $expiryTimestamp = (new \DateTime())->add(new \DateInterval('PT' . $linkExpiry . 'S'))->getTimestamp(); + $linkExpiry = Plugin::getInstance()->getSettings()->cartLoadUrlExpiry; + $expiryDate = DateTimeHelper::currentUTCDateTime()->add(DateTimeHelper::secondsToInterval($linkExpiry)); $token = Craft::$app->getTokens()->createToken([ 'commerce/cart/load-cart', - [ - 'cartNumber' => $cart->number, - 'expiresAt' => $expiryTimestamp, - ], - ]); + ['cartNumber' => $cart->number], + ], expiryDate: $expiryDate); - return UrlHelper::siteUrl('actions/commerce/cart/load-cart', [ + return UrlHelper::actionUrl('commerce/cart/load-cart', [ 'number' => $cart->number, 'token' => $token, ]); diff --git a/src/templates/_cart/email-challenge.twig b/src/templates/_cart/email-challenge.twig index fbf986a1bc..5fefc8ecaf 100644 --- a/src/templates/_cart/email-challenge.twig +++ b/src/templates/_cart/email-challenge.twig @@ -41,7 +41,7 @@ {{ csrfInput() }} {{ actionInput('commerce/cart/cart-challenge') }} - {{ hiddenInput('cartNumberHash', craft.app.security.hashData(cartNumber)) }} + {{ hiddenInput('cartNumberHash', cartNumber|hash) }} {{ forms.submitButton({ class: ['fullwidth', 'last'], From 16b50a8d379b332450d51108db29cce6ca02ad65 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Thu, 14 May 2026 13:33:02 +0800 Subject: [PATCH 06/12] translations --- src/translations/en/commerce.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 5808bfafa2..6fc4ae15a9 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -1185,6 +1185,8 @@ 'You currently have no emails configured to select for this status.' => 'You currently have no emails configured to select for this status.', 'You have selected an option that will be removed in the next release of Craft Commerce. Use the “Per Item Discount” options to take a percentage off the whole order, or change this option to “Value”' => 'You have selected an option that will be removed in the next release of Craft Commerce. Use the “Per Item Discount” options to take a percentage off the whole order, or change this option to “Value”', 'You must set up at least one gateway that supports subscriptions first.' => 'You must set up at least one gateway that supports subscriptions first.', + 'You do not have permission to load this cart.' => 'You do not have permission to load this cart.', + 'You must be logged in or provide a valid token to load this cart.' => 'You must be logged in or provide a valid token to load this cart.', 'You must be signed in to create a payment source.' => 'You must be signed in to create a payment source.', 'You must be signed in to set a primary payment source.' => 'You must be signed in to set a primary payment source.', 'You must make a payment to complete the order.' => 'You must make a payment to complete the order.', From 01d9492880c93e4d3fa6a31fc81c904698177f33 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 17:19:00 +0800 Subject: [PATCH 07/12] no need --- .ddev/config.yaml | 256 ---------------------------------------------- 1 file changed, 256 deletions(-) delete mode 100644 .ddev/config.yaml diff --git a/.ddev/config.yaml b/.ddev/config.yaml deleted file mode 100644 index ad50e7e009..0000000000 --- a/.ddev/config.yaml +++ /dev/null @@ -1,256 +0,0 @@ -name: commerce -type: php -docroot: "" -php_version: "8.0" -webserver_type: nginx-fpm -router_http_port: "80" -router_https_port: "443" -xdebug_enabled: false -additional_hostnames: [] -additional_fqdns: [] -database: - type: mariadb - version: "10.4" -nfs_mount_enabled: false -mutagen_enabled: false -use_dns_when_possible: true -composer_version: "2" -web_environment: [] -nodejs_version: "16" - -# Key features of ddev's config.yaml: - -# name: # Name of the project, automatically provides -# http://projectname.ddev.site and https://projectname.ddev.site - -# type: # drupal6/7/8, backdrop, typo3, wordpress, php - -# docroot: # Relative path to the directory containing index.php. - -# php_version: "7.4" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1" - -# You can explicitly specify the webimage but this -# is not recommended, as the images are often closely tied to ddev's' behavior, -# so this can break upgrades. - -# webimage: # nginx/php docker image. - -# database: -# type: # mysql, mariadb -# version: # database version, like "10.3" or "8.0" -# Note that mariadb_version or mysql_version from v1.18 and earlier -# will automatically be converted to this notation with just a "ddev config --auto" - -# router_http_port: # Port to be used for http (defaults to port 80) -# router_https_port: # Port for https (defaults to 443) - -# xdebug_enabled: false # Set to true to enable xdebug and "ddev start" or "ddev restart" -# Note that for most people the commands -# "ddev xdebug" to enable xdebug and "ddev xdebug off" to disable it work better, -# as leaving xdebug enabled all the time is a big performance hit. - -# xhprof_enabled: false # Set to true to enable xhprof and "ddev start" or "ddev restart" -# Note that for most people the commands -# "ddev xhprof" to enable xhprof and "ddev xhprof off" to disable it work better, -# as leaving xhprof enabled all the time is a big performance hit. - -# webserver_type: nginx-fpm # or apache-fpm - -# timezone: Europe/Berlin -# This is the timezone used in the containers and by PHP; -# it can be set to any valid timezone, -# see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -# For example Europe/Dublin or MST7MDT - -# composer_root: -# Relative path to the composer root directory from the project root. This is -# the directory which contains the composer.json and where all Composer related -# commands are executed. - -# composer_version: "2" -# You can set it to "" or "2" (default) for Composer v2 or "1" for Composer v1 -# to use the latest major version available at the time your container is built. -# It is also possible to select a minor version for example "2.2" which will -# install the latest release of that branch. Alternatively, an explicit Composer -# version may be specified, for example "1.0.22". Finally, it is also possible -# to use one of the key words "stable", "preview" or "snapshot" see Composer -# documentation. -# To reinstall Composer after the image was built, run "ddev debug refresh". - -# nodejs_version: "16" -# change from the default system Node.js version to another supported version, like 12, 14, 17, 18. -# Note that you can use 'ddev nvm' or nvm inside the web container to provide nearly any -# Node.js version, including v6, etc. - -# additional_hostnames: -# - somename -# - someothername -# would provide http and https URLs for "somename.ddev.site" -# and "someothername.ddev.site". - -# additional_fqdns: -# - example.com -# - sub1.example.com -# would provide http and https URLs for "example.com" and "sub1.example.com" -# Please take care with this because it can cause great confusion. - -# upload_dir: custom/upload/dir -# would set the destination path for ddev import-files to /custom/upload/dir -# When mutagen is enabled this path is bind-mounted so that all the files -# in the upload_dir don't have to be synced into mutagen - -# working_dir: -# web: /var/www/html -# db: /home -# would set the default working directory for the web and db services. -# These values specify the destination directory for ddev ssh and the -# directory in which commands passed into ddev exec are run. - -# omit_containers: [db, dba, ddev-ssh-agent] -# Currently only these containers are supported. Some containers can also be -# omitted globally in the ~/.ddev/global_config.yaml. Note that if you omit -# the "db" container, several standard features of ddev that access the -# database container will be unusable. In the global configuration it is also -# possible to omit ddev-router, but not here. - -# nfs_mount_enabled: false -# Great performance improvement but requires host configuration first. -# See https://ddev.readthedocs.io/en/stable/users/performance/#using-nfs-to-mount-the-project-into-the-container - -# mutagen_enabled: false -# Performance improvement using mutagen asynchronous updates. -# See https://ddev.readthedocs.io/en/latest/users/performance/#using-mutagen - -# fail_on_hook_fail: False -# Decide whether 'ddev start' should be interrupted by a failing hook - -# host_https_port: "59002" -# The host port binding for https can be explicitly specified. It is -# dynamic unless otherwise specified. -# This is not used by most people, most people use the *router* instead -# of the localhost port. - -# host_webserver_port: "59001" -# The host port binding for the ddev-webserver can be explicitly specified. It is -# dynamic unless otherwise specified. -# This is not used by most people, most people use the *router* instead -# of the localhost port. - -# host_db_port: "59002" -# The host port binding for the ddev-dbserver can be explicitly specified. It is dynamic -# unless explicitly specified. - -# phpmyadmin_port: "8036" -# phpmyadmin_https_port: "8037" -# The PHPMyAdmin ports can be changed from the default 8036 and 8037 - -# host_phpmyadmin_port: "8036" -# The phpmyadmin (dba) port is not normally bound on the host at all, instead being routed -# through ddev-router, but it can be specified and bound. - -# mailhog_port: "8025" -# mailhog_https_port: "8026" -# The MailHog ports can be changed from the default 8025 and 8026 - -# host_mailhog_port: "8025" -# The mailhog port is not normally bound on the host at all, instead being routed -# through ddev-router, but it can be bound directly to localhost if specified here. - -# webimage_extra_packages: [php7.4-tidy, php-bcmath] -# Extra Debian packages that are needed in the webimage can be added here - -# dbimage_extra_packages: [telnet,netcat] -# Extra Debian packages that are needed in the dbimage can be added here - -# use_dns_when_possible: true -# If the host has internet access and the domain configured can -# successfully be looked up, DNS will be used for hostname resolution -# instead of editing /etc/hosts -# Defaults to true - -# project_tld: ddev.site -# The top-level domain used for project URLs -# The default "ddev.site" allows DNS lookup via a wildcard -# If you prefer you can change this to "ddev.local" to preserve -# pre-v1.9 behavior. - -# ngrok_args: --basic-auth username:pass1234 -# Provide extra flags to the "ngrok http" command, see -# https://ngrok.com/docs#http or run "ngrok http -h" - -# disable_settings_management: false -# If true, ddev will not create CMS-specific settings files like -# Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php -# In this case the user must provide all such settings. - -# You can inject environment variables into the web container with: -# web_environment: -# - SOMEENV=somevalue -# - SOMEOTHERENV=someothervalue - -# no_project_mount: false -# (Experimental) If true, ddev will not mount the project into the web container; -# the user is responsible for mounting it manually or via a script. -# This is to enable experimentation with alternate file mounting strategies. -# For advanced users only! - -# bind_all_interfaces: false -# If true, host ports will be bound on all network interfaces, -# not just the localhost interface. This means that ports -# will be available on the local network if the host firewall -# allows it. - -# default_container_timeout: 120 -# The default time that ddev waits for all containers to become ready can be increased from -# the default 120. This helps in importing huge databases, for example. - -#web_extra_exposed_ports: -#- name: nodejs -# container_port: 3000 -# http_port: 2999 -# https_port: 3000 -#- name: something -# container_port: 4000 -# https_port: 4000 -# http_port: 3999 -# Allows a set of extra ports to be exposed via ddev-router -# The port behavior on the ddev-webserver must be arranged separately, for example -# using web_extra_daemons. -# For example, with a web app on port 3000 inside the container, this config would -# expose that web app on https://.ddev.site:9999 and http://.ddev.site:9998 -# web_extra_exposed_ports: -# - container_port: 3000 -# http_port: 9998 -# https_port: 9999 - -#web_extra_daemons: -#- name: "http-1" -# command: "/var/www/html/node_modules/.bin/http-server -p 3000" -# directory: /var/www/html -#- name: "http-2" -# command: "/var/www/html/node_modules/.bin/http-server /var/www/html/sub -p 3000" -# directory: /var/www/html - -# override_config: false -# By default, config.*.yaml files are *merged* into the configuration -# But this means that some things can't be overridden -# For example, if you have 'nfs_mount_enabled: true'' you can't override it with a merge -# and you can't erase existing hooks or all environment variables. -# However, with "override_config: true" in a particular config.*.yaml file, -# 'nfs_mount_enabled: false' can override the existing values, and -# hooks: -# post_start: [] -# or -# web_environment: [] -# or -# additional_hostnames: [] -# can have their intended affect. 'override_config' affects only behavior of the -# config.*.yaml file it exists in. - -# Many ddev commands can be extended to run tasks before or after the -# ddev command is executed, for example "post-start", "post-import-db", -# "pre-composer", "post-composer" -# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more -# information on the commands that can be extended and the tasks you can define -# for them. Example: -#hooks: From a7bf2d41f7c9fda5d9e88e381d6f5aa7131dd38f Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 17:25:42 +0800 Subject: [PATCH 08/12] Cleanup --- src/controllers/CartController.php | 2 +- src/elements/actions/CopyLoadCartUrl.php | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 0cd2d49a60..e1a16df429 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -12,6 +12,7 @@ use Craft; use craft\base\Element; use craft\commerce\elements\Order; +use craft\commerce\filters\IpRateLimitIdentity; use craft\commerce\helpers\LineItem as LineItemHelper; use craft\commerce\models\LineItem; use craft\commerce\Plugin; @@ -19,7 +20,6 @@ use craft\elements\User; use craft\errors\ElementNotFoundException; use craft\errors\MissingComponentException; -use craft\filters\IpRateLimitIdentity; use craft\helpers\UrlHelper; use craft\web\View; use Illuminate\Support\Collection; diff --git a/src/elements/actions/CopyLoadCartUrl.php b/src/elements/actions/CopyLoadCartUrl.php index 250d1de2d1..23486ce695 100644 --- a/src/elements/actions/CopyLoadCartUrl.php +++ b/src/elements/actions/CopyLoadCartUrl.php @@ -40,33 +40,32 @@ public function getTriggerLabel(): string public function getTriggerHtml(): ?string { $type = Json::encode(static::class); - $actionUrl = Json::encode(UrlHelper::actionUrl(‘commerce/orders/get-load-cart-url’)); + $actionUrl = Json::encode(UrlHelper::actionUrl('commerce/orders/get-load-cart-url')); - $js = sprintf(<<<’JS’ + $jsTemplate = <<<'JS' (() => { new Craft.ElementActionTrigger({ type: %s, batch: false, validateSelection: function($selectedItems) { - return !!$selectedItems.find(‘.element’).data(‘number’); + return !!$selectedItems.find('.element').data('number'); }, activate: function($selectedItems) { - var number = $selectedItems.find(‘.element’).data(‘number’); - Craft.sendActionRequest(‘GET’, %s, {params: {number: number}}).then(function(response) { + var number = $selectedItems.find('.element').data('number'); + Craft.sendActionRequest('GET', %s, {params: {number: number}}).then(function(response) { Craft.ui.createCopyTextPrompt({ - label: Craft.t(‘commerce’, ‘Copy the URL’), - instructions: Craft.t(‘commerce’, "This URL will load the cart into the user’s session, making it the active cart."), + label: Craft.t('commerce', 'Copy the URL'), + instructions: Craft.t('commerce', "This URL will load the cart into the user's session, making it the active cart."), value: response.data.url, }); }); } }); })(); -JS, $type, $actionUrl); - - Craft::$app->getView()->registerJs($js); +JS; + Craft::$app->getView()->registerJs(sprintf($jsTemplate, $type, $actionUrl)); return null; } } From 899cd765ee63e6d39d93be8d5f64a5e218419219 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 17:34:05 +0800 Subject: [PATCH 09/12] Fix tests --- tests/unit/controllers/CartControllerRateLimitTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/controllers/CartControllerRateLimitTest.php b/tests/unit/controllers/CartControllerRateLimitTest.php index 89abd21f6b..ec1c0966ff 100644 --- a/tests/unit/controllers/CartControllerRateLimitTest.php +++ b/tests/unit/controllers/CartControllerRateLimitTest.php @@ -11,7 +11,7 @@ use craft\commerce\controllers\CartController; use craft\commerce\elements\Variant; use craft\commerce\Plugin; -use craft\filters\IpRateLimitIdentity; +use craft\commerce\filters\IpRateLimitIdentity; use craft\test\TestCase; use craft\web\Request; use craftcommercetests\fixtures\ProductFixture; From 4fa4a429baed5427aeff10bcbbf607534146a63a Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 18:09:18 +0800 Subject: [PATCH 10/12] Add commerce rate limiter --- src/filters/IpRateLimitIdentity.php | 59 +++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/filters/IpRateLimitIdentity.php diff --git a/src/filters/IpRateLimitIdentity.php b/src/filters/IpRateLimitIdentity.php new file mode 100644 index 0000000000..2c0a86b050 --- /dev/null +++ b/src/filters/IpRateLimitIdentity.php @@ -0,0 +1,59 @@ + + * @since 4.11.2 + */ +class IpRateLimitIdentity extends BaseObject implements RateLimitInterface +{ + public int $limit; + public int $window; + public string $keyPrefix; + public string $ip; + + /** + * @inheritdoc + */ + public function getRateLimit($request, $action): array + { + return [$this->limit, $this->window]; + } + + /** + * @inheritdoc + */ + public function loadAllowance($request, $action): array + { + $key = $this->getCacheKey($action); + $data = Craft::$app->getCache()->get($key); + return $data !== false ? $data : [$this->limit, time()]; + } + + /** + * @inheritdoc + */ + public function saveAllowance($request, $action, $allowance, $timestamp): void + { + $key = $this->getCacheKey($action); + Craft::$app->getCache()->set($key, [$allowance, $timestamp], $this->window); + } + + private function getCacheKey(Action $action): string + { + return sprintf('%s:%s:%s', $this->keyPrefix, $action->getUniqueId(), $this->ip); + } +} From f6f312a3a30d9db6d59ea74ce8e0100f4ac717a0 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 20 May 2026 20:55:58 +0800 Subject: [PATCH 11/12] Cleanup --- tests/unit/controllers/CartControllerRateLimitTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/controllers/CartControllerRateLimitTest.php b/tests/unit/controllers/CartControllerRateLimitTest.php index ec1c0966ff..57c97cb12c 100644 --- a/tests/unit/controllers/CartControllerRateLimitTest.php +++ b/tests/unit/controllers/CartControllerRateLimitTest.php @@ -10,8 +10,8 @@ use Craft; use craft\commerce\controllers\CartController; use craft\commerce\elements\Variant; -use craft\commerce\Plugin; use craft\commerce\filters\IpRateLimitIdentity; +use craft\commerce\Plugin; use craft\test\TestCase; use craft\web\Request; use craftcommercetests\fixtures\ProductFixture; From 9c9bbb446d418695c0e49f96ba40b062f6cdc634 Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Wed, 27 May 2026 20:54:42 +0800 Subject: [PATCH 12/12] Fix tokens --- src/controllers/CartController.php | 2 +- src/controllers/DownloadsController.php | 2 +- src/services/Carts.php | 10 ++++++++-- src/services/Pdfs.php | 10 ++++++++-- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index e1a16df429..c3c5a7f84e 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -392,7 +392,7 @@ public function actionLoadCart(): ?Response { $carts = Plugin::getInstance()->getCarts(); $number = $this->request->getParam('number'); - $token = $this->request->getParam('token'); + $token = $this->request->getParam('tokenNumber'); $loadCartRedirectUrl = Plugin::getInstance()->getSettings()->loadCartRedirectUrl ?? ''; $redirect = UrlHelper::siteUrl($loadCartRedirectUrl); diff --git a/src/controllers/DownloadsController.php b/src/controllers/DownloadsController.php index bad5cb6e32..87cc5095ce 100644 --- a/src/controllers/DownloadsController.php +++ b/src/controllers/DownloadsController.php @@ -82,7 +82,7 @@ public function actionPdf(): Response $pdfHandle = $this->request->getQueryParam('pdfHandle'); $option = $this->request->getQueryParam('option', ''); $inline = (bool) $this->request->getQueryParam('inline', false); - $token = $this->request->getQueryParam('token'); + $token = $this->request->getQueryParam('tokenNumber'); if (!$number) { throw new BadRequestHttpException('Order number required'); diff --git a/src/services/Carts.php b/src/services/Carts.php index b0d32d987f..60a8b1abe9 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -327,10 +327,16 @@ public function getLoadCartUrl(Order $cart): string ['cartNumber' => $cart->number], ], expiryDate: $expiryDate); - return UrlHelper::actionUrl('commerce/cart/load-cart', [ + $request = Craft::$app->getRequest(); + $isCpRequest = $request->getIsCpRequest(); + $request->setIsCpRequest(false); + $url = UrlHelper::actionUrl('commerce/cart/load-cart', [ 'number' => $cart->number, - 'token' => $token, + 'tokenNumber' => $token, ]); + $request->setIsCpRequest($isCpRequest); + + return $url; } /** diff --git a/src/services/Pdfs.php b/src/services/Pdfs.php index cbdabbb8e2..69e3905119 100644 --- a/src/services/Pdfs.php +++ b/src/services/Pdfs.php @@ -462,7 +462,7 @@ public function getPdfUrl(Order $order, string $option = null, string $pdfHandle // Build the URL parameters $params = [ 'number' => $order->number, - 'token' => $token, + 'tokenNumber' => $token, ]; if ($pdfHandle !== null) { @@ -477,7 +477,13 @@ public function getPdfUrl(Order $order, string $option = null, string $pdfHandle $params['inline'] = true; } - return UrlHelper::siteUrl('actions/commerce/downloads/pdf', $params); + $request = Craft::$app->getRequest(); + $isCpRequest = $request->getIsCpRequest(); + $request->setIsCpRequest(false); + $url = UrlHelper::actionUrl('commerce/downloads/pdf', $params); + $request->setIsCpRequest($isCpRequest); + + return $url; } /**