Skip to content

Commit c35a129

Browse files
authored
Merge pull request #4164 from craftcms/4.10
4.10
2 parents 3f8fab8 + 645eaae commit c35a129

24 files changed

Lines changed: 702 additions & 81 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,23 @@
11
# Release Notes for Craft Commerce
22

3+
## Unreleased
4+
5+
### Store Management
6+
- PDF download URLs are now generated with time-limited security tokens.
7+
- Anonymous users attempting to download a PDF with an expired or missing token are now shown an email verification form.
8+
- Added a new system message for customizing PDF download emails.
9+
10+
### Administration
11+
- Added the “Link Duration” setting to PDF settings.
12+
13+
### Extensibility
14+
- Added `craft\commerce\controllers\DownloadsController::actionEmailChallenge()`.
15+
- Added `craft\commerce\controllers\DownloadsController::actionPdfChallenge()`.
16+
- Added `craft\commerce\controllers\DownloadsController::actionPdfSent()`.
17+
- Added `craft\commerce\elements\Order::getMaskedEmail()`.
18+
- Added `craft\commerce\models\Pdf::$linkExpiry`.
19+
- Added `craft\commerce\services\Pdfs::getPdfUrl()` now generates secure tokenized URLs with expiry timestamps.
20+
321
## 4.9.4 - 2025-10-15
422

523
- Fixed a SQL error that could occur when deleting a shipping method.

src/Plugin.php

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
use craft\events\RegisterCacheOptionsEvent;
110110
use craft\events\RegisterComponentTypesEvent;
111111
use craft\events\RegisterElementExportersEvent;
112+
use craft\events\RegisterEmailMessagesEvent;
112113
use craft\events\RegisterGqlEagerLoadableFields;
113114
use craft\events\RegisterGqlQueriesEvent;
114115
use craft\events\RegisterGqlSchemaComponentsEvent;
@@ -132,6 +133,7 @@
132133
use craft\services\Gql;
133134
use craft\services\ProjectConfig;
134135
use craft\services\Sites;
136+
use craft\services\SystemMessages;
135137
use craft\services\UserPermissions;
136138
use craft\utilities\ClearCaches;
137139
use craft\web\Application;
@@ -704,6 +706,22 @@ function(DefineBehaviorsEvent $event) {
704706
});
705707

706708
Event::on(Purchasable::class, Elements::EVENT_BEFORE_RESTORE_ELEMENT, [$this->getPurchasables(), 'beforeRestorePurchasableHandler']);
709+
710+
// Register system message for PDF download emails
711+
Event::on(
712+
SystemMessages::class,
713+
SystemMessages::EVENT_REGISTER_MESSAGES,
714+
function(RegisterEmailMessagesEvent $event) {
715+
$event->messages = array_merge($event->messages, [
716+
[
717+
'key' => 'commerce_pdf_download',
718+
'heading' => Craft::t('commerce', 'Order PDF Download Link'),
719+
'subject' => Craft::t('commerce', 'Your Order PDF Download Link'),
720+
'body' => $this->_getDefaultPdfDownloadMessage(),
721+
],
722+
]);
723+
}
724+
);
707725
}
708726

709727
/**
@@ -1074,4 +1092,18 @@ private function _registerTemplateHooks(): void
10741092
Craft::$app->getView()->hook('cp.users.edit.content', [$this->getCustomers(), 'addEditUserCommerceTabContent']);
10751093
}
10761094
}
1095+
1096+
/**
1097+
* Returns the default message body for the PDF download email.
1098+
*
1099+
* @return string
1100+
*/
1101+
private function _getDefaultPdfDownloadMessage(): string
1102+
{
1103+
return "Hello,\n\n" .
1104+
"You requested a PDF download for your order. Click the link below to download your PDF:\n\n" .
1105+
"[Download PDF]({{ link }})\n\n" .
1106+
"**Please note:** This link will expire for security purposes.\n\n" .
1107+
"Thank you!";
1108+
}
10771109
}

src/controllers/DownloadsController.php

Lines changed: 233 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
namespace craft\commerce\controllers;
99

1010
use Craft;
11+
use craft\commerce\elements\Order;
1112
use craft\commerce\helpers\Locale;
1213
use craft\commerce\Plugin;
13-
use HttpInvalidParamException;
14+
use craft\helpers\UrlHelper;
15+
use craft\web\View;
1416
use Throwable;
1517
use yii\base\Exception;
1618
use yii\base\InvalidCallException;
19+
use yii\web\BadRequestHttpException;
1720
use yii\web\HttpException;
1821
use yii\web\RangeNotSatisfiableHttpException;
1922
use yii\web\Response;
@@ -26,6 +29,47 @@
2629
*/
2730
class DownloadsController extends BaseFrontEndController
2831
{
32+
/**
33+
* Renders the email challenge template with the provided parameters.
34+
*
35+
* @param Order $order The order to display
36+
* @param string $orderNumber The order number
37+
* @param string|null $pdfHandle The PDF handle
38+
* @param string $option The PDF option
39+
* @param bool $inline Whether to display inline
40+
* @param array $errors Optional errors to display
41+
* @param string|null $email Optional email value to pre-fill
42+
* @return Response
43+
* @since 4.9.5
44+
*/
45+
private function renderEmailChallenge(
46+
Order $order,
47+
string $orderNumber,
48+
?string $pdfHandle,
49+
string $option,
50+
bool $inline,
51+
array $errors = [],
52+
?string $email = null,
53+
): Response {
54+
$params = [
55+
'order' => $order,
56+
'orderNumber' => $orderNumber,
57+
'pdfHandle' => $pdfHandle,
58+
'option' => $option,
59+
'inline' => $inline,
60+
];
61+
62+
if (!empty($errors)) {
63+
$params['errors'] = $errors;
64+
}
65+
66+
if ($email !== null) {
67+
$params['email'] = $email;
68+
}
69+
70+
return $this->renderTemplate('commerce/_downloads/email-challenge', $params, View::TEMPLATE_MODE_CP);
71+
}
72+
2973
/**
3074
* @throws HttpException
3175
* @throws Throwable
@@ -38,9 +82,10 @@ public function actionPdf(): Response
3882
$pdfHandle = $this->request->getQueryParam('pdfHandle');
3983
$option = $this->request->getQueryParam('option', '');
4084
$inline = (bool) $this->request->getQueryParam('inline', false);
85+
$token = $this->request->getQueryParam('token');
4186

4287
if (!$number) {
43-
throw new HttpInvalidParamException('Order number required');
88+
throw new BadRequestHttpException('Order number required');
4489
}
4590

4691
$order = Plugin::getInstance()->getOrders()->getOrderByNumber($number);
@@ -49,6 +94,77 @@ public function actionPdf(): Response
4994
throw new HttpException(404, 'Order not found');
5095
}
5196

97+
// Don't allow PDF downloads for carts without an email
98+
if (!$order->getEmail()) {
99+
throw new HttpException(404, 'Order not found');
100+
}
101+
102+
$currentUser = Craft::$app->getUser()->getIdentity();
103+
$hasValidToken = false;
104+
105+
// Check if token is provided and valid (works for anyone, logged in or not)
106+
if ($token) {
107+
$tokenData = Craft::$app->getTokens()->getTokenRoute($token);
108+
109+
// Validate token structure and order number
110+
if (!$tokenData || !isset($tokenData[1]['orderNumber']) || $tokenData[1]['orderNumber'] !== $number) {
111+
// Invalid token - redirect to challenge form with error
112+
Craft::$app->getSession()->setError(Craft::t('commerce', 'The download link is invalid. Please request a new one.'));
113+
return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [
114+
'number' => $number,
115+
'pdfHandle' => $pdfHandle,
116+
'option' => $option,
117+
'inline' => $inline,
118+
]));
119+
}
120+
121+
// Check if token has expired based on the timestamp in the token data
122+
if (isset($tokenData[1]['expiresAt'])) {
123+
$expiresAt = $tokenData[1]['expiresAt'];
124+
$now = (new \DateTime())->getTimestamp();
125+
126+
if ($now > $expiresAt) {
127+
// Token expired - redirect to email challenge form
128+
return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [
129+
'number' => $number,
130+
'pdfHandle' => $pdfHandle,
131+
'option' => $option,
132+
'inline' => $inline,
133+
]));
134+
}
135+
}
136+
137+
// Token is valid
138+
$hasValidToken = true;
139+
}
140+
141+
// Check user permissions if no valid token
142+
if (!$hasValidToken) {
143+
if ($currentUser) {
144+
// Check if user is the order customer, admin, or has permission to manage orders
145+
$isOrderCustomer = $order->getCustomer() && $order->getCustomer()->id === $currentUser->id;
146+
$hasPermission = $currentUser->admin || $order->canView($currentUser);
147+
148+
if (!($isOrderCustomer || $hasPermission)) {
149+
// Logged-in user without permission - redirect to email challenge form
150+
return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [
151+
'number' => $number,
152+
'pdfHandle' => $pdfHandle,
153+
'option' => $option,
154+
'inline' => $inline,
155+
]));
156+
}
157+
} else {
158+
// Anonymous user without valid token - redirect to email challenge form
159+
return $this->redirect(UrlHelper::actionUrl('commerce/downloads/email-challenge', [
160+
'number' => $number,
161+
'pdfHandle' => $pdfHandle,
162+
'option' => $option,
163+
'inline' => $inline,
164+
]));
165+
}
166+
}
167+
52168
if ($pdfHandle) {
53169
$pdf = Plugin::getInstance()->getPdfs()->getPdfByHandle($pdfHandle);
54170

@@ -84,4 +200,119 @@ public function actionPdf(): Response
84200
'inline' => $inline,
85201
]);
86202
}
203+
204+
/**
205+
* Displays the email challenge form for anonymous users trying to download an order PDF
206+
*
207+
* @return Response
208+
* @throws HttpException
209+
*/
210+
public function actionEmailChallenge(): Response
211+
{
212+
$number = $this->request->getQueryParam('number');
213+
$pdfHandle = $this->request->getQueryParam('pdfHandle');
214+
$option = $this->request->getQueryParam('option', '');
215+
$inline = (bool) $this->request->getQueryParam('inline', false);
216+
217+
if (!$number) {
218+
throw new BadRequestHttpException('Order number required');
219+
}
220+
221+
$order = Plugin::getInstance()->getOrders()->getOrderByNumber($number);
222+
223+
if (!$order) {
224+
throw new HttpException(404, 'Order not found');
225+
}
226+
227+
// Don't allow PDF downloads for carts without an email
228+
if (!$order->getEmail()) {
229+
throw new HttpException(404, 'Order not found');
230+
}
231+
232+
return $this->renderEmailChallenge($order, $number, $pdfHandle, $option, $inline);
233+
}
234+
235+
/**
236+
* Handles the email challenge form submission for anonymous users trying to download an order PDF
237+
*
238+
* @throws HttpException
239+
* @throws Exception
240+
*/
241+
public function actionPdfChallenge(): Response
242+
{
243+
$this->requirePostRequest();
244+
245+
$orderNumberHash = $this->request->getBodyParam('orderNumberHash');
246+
$pdfHandle = $this->request->getBodyParam('pdfHandle');
247+
$option = $this->request->getBodyParam('option', '');
248+
$inline = (bool) $this->request->getBodyParam('inline', false);
249+
250+
if (!$orderNumberHash) {
251+
throw new BadRequestHttpException('Order number hash is required');
252+
}
253+
254+
// Validate the order number hash
255+
$orderNumber = Craft::$app->getSecurity()->validateData($orderNumberHash);
256+
257+
if ($orderNumber === false) {
258+
throw new BadRequestHttpException('Invalid order number hash');
259+
}
260+
261+
$order = Plugin::getInstance()->getOrders()->getOrderByNumber($orderNumber);
262+
263+
if (!$order) {
264+
throw new HttpException(404, 'Order not found');
265+
}
266+
267+
// Build the download URL with the token using the Pdfs service
268+
$downloadUrl = Plugin::getInstance()->getPdfs()->getPdfUrl($order, $option, $pdfHandle, $inline);
269+
270+
// Send email using system message
271+
$systemMessage = Craft::$app->getSystemMessages()->getMessage('commerce_pdf_download', $order->getOrderSite()->language);
272+
273+
if (!Craft::$app->getMailer()->composeFromKey('commerce_pdf_download', [
274+
'link' => $downloadUrl,
275+
'order' => $order,
276+
])->setTo($order->email)->send()) {
277+
Craft::$app->getSession()->setError(Craft::t('commerce', 'Failed to send email. Please try again.'));
278+
return $this->renderEmailChallenge($order, $orderNumber, $pdfHandle, $option, $inline);
279+
}
280+
281+
Craft::$app->getSession()->setNotice(Craft::t('commerce', 'A new download link has been sent to {email}', ['email' => $order->getMaskedEmail()]));
282+
283+
// Redirect to success page to prevent duplicate submissions on refresh
284+
return $this->redirect(UrlHelper::actionUrl('commerce/downloads/pdf-sent', ['hash' => $orderNumberHash]));
285+
}
286+
287+
/**
288+
* Displays the success page after email challenge is completed
289+
*
290+
* @return Response
291+
* @throws HttpException
292+
*/
293+
public function actionPdfSent(): Response
294+
{
295+
$orderNumberHash = $this->request->getQueryParam('hash');
296+
297+
if (!$orderNumberHash) {
298+
throw new BadRequestHttpException('Hash parameter required');
299+
}
300+
301+
// Validate and extract the order number from the hash
302+
$orderNumber = Craft::$app->getSecurity()->validateData($orderNumberHash);
303+
304+
if ($orderNumber === false) {
305+
throw new HttpException(400, 'Invalid hash parameter');
306+
}
307+
308+
$order = Plugin::getInstance()->getOrders()->getOrderByNumber($orderNumber);
309+
310+
if (!$order) {
311+
throw new HttpException(404, 'Order not found');
312+
}
313+
314+
return $this->renderTemplate('commerce/_downloads/email-sent', [
315+
'email' => $order->getMaskedEmail(),
316+
], View::TEMPLATE_MODE_CP);
317+
}
87318
}

0 commit comments

Comments
 (0)