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: 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/Plugin.php b/src/Plugin.php index 0f597bd478..1a44e6fb64 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 98833f1df2..c3c5a7f84e 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,8 +20,8 @@ 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; use Throwable; use yii\base\Exception; @@ -391,16 +392,15 @@ public function actionLoadCart(): ?Response { $carts = Plugin::getInstance()->getCarts(); $number = $this->request->getParam('number'); + $token = $this->request->getParam('tokenNumber'); $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; } @@ -409,18 +409,67 @@ 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) { + $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; + } + + // 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) { + 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 { + 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); + } + } + } + + // 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); @@ -809,4 +858,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.12 + */ + 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.12 + */ + 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/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/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/Order.php b/src/elements/Order.php index 112f2ab9b3..4966047a7c 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/elements/actions/CopyLoadCartUrl.php b/src/elements/actions/CopyLoadCartUrl.php index 7fdbb413ec..23486ce695 100644 --- a/src/elements/actions/CopyLoadCartUrl.php +++ b/src/elements/actions/CopyLoadCartUrl.php @@ -40,31 +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')); - $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; - - Craft::$app->getView()->registerJs($js); + Craft::$app->getView()->registerJs(sprintf($jsTemplate, $type, $actionUrl)); return null; } } 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); + } +} diff --git a/src/models/Settings.php b/src/models/Settings.php index 938fe71602..b529b75a48 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 604800 (7 days). + * + * @group Cart + * @since 4.x + */ + 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 22de51b79b..60a8b1abe9 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,35 @@ 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()->cartLoadUrlExpiry; + $expiryDate = DateTimeHelper::currentUTCDateTime()->add(DateTimeHelper::secondsToInterval($linkExpiry)); + + $token = Craft::$app->getTokens()->createToken([ + 'commerce/cart/load-cart', + ['cartNumber' => $cart->number], + ], expiryDate: $expiryDate); + + $request = Craft::$app->getRequest(); + $isCpRequest = $request->getIsCpRequest(); + $request->setIsCpRequest(false); + $url = UrlHelper::actionUrl('commerce/cart/load-cart', [ + 'number' => $cart->number, + 'tokenNumber' => $token, + ]); + $request->setIsCpRequest($isCpRequest); + + return $url; + } + /** * 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/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; } /** diff --git a/src/templates/_cart/email-challenge.twig b/src/templates/_cart/email-challenge.twig new file mode 100644 index 0000000000..5fefc8ecaf --- /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', cartNumber|hash) }} + + {{ 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..6fc4ae15a9 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.', @@ -1180,9 +1185,12 @@ '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.', + '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', diff --git a/tests/unit/controllers/CartControllerRateLimitTest.php b/tests/unit/controllers/CartControllerRateLimitTest.php index 89abd21f6b..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\filters\IpRateLimitIdentity; use craft\commerce\Plugin; -use craft\filters\IpRateLimitIdentity; use craft\test\TestCase; use craft\web\Request; use craftcommercetests\fixtures\ProductFixture;