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..6f170f9664 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -20,6 +20,7 @@ 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; @@ -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,56 @@ 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])); + } + } + } + + // 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); @@ -774,4 +812,104 @@ 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); + } + + 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..e839f0f489 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; @@ -215,7 +216,7 @@ public function forgetCart(): void */ public function generateCartNumber(): string { - return md5(uniqid((string)mt_rand(), true)); + return bin2hex(random_bytes(16)); } /** @@ -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',