PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 trunk 1.2.0 All 47 releases
fluent-cart / app / Services / FileSystem / Drivers / S3 / S3ConnectionVerify.php

S3ConnectionVerify.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.6.4, at app/Services/FileSystem/Drivers/S3/S3ConnectionVerify.php

806 lines 30.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\FileSystem\Drivers\S3;
4
5 class S3ConnectionVerify
6 {
7 private const DEFAULT_REGION = 'us-east-1';
8 private const MAX_REGION_RETRIES = 2;
9
10 private $date;
11 private $timeStamp;
12 private string $accessKey;
13 private string $bucket;
14 private string $hashAlgorithm = 'sha256';
15 private string $httpMethod;
16 private string $region;
17 private string $secretKey;
18 private string $signature;
19 private string $requestUrl;
20 private ?string $sessionToken = null;
21 private string $payloadBody = '';
22 private ?string $contentMD5 = null;
23 private int $regionRetryCount = 0;
24
25 public static function verify(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1')
26 {
27 $validation = self::validateRequestInputs($region, $bucket);
28 if (is_wp_error($validation)) {
29 return $validation;
30 }
31
32 $self = new static($secret, $accessKey, $sessionToken, $region);
33 if ($bucket) {
34 $self->bucket = $bucket;
35 $self->requestUrl = $self->getBucketRequestUrl('?max-keys=1');
36 $self->httpMethod = 'GET';
37 }
38
39 return $self->testConnection();
40 }
41
42 public static function checkBucketExistence(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1')
43 {
44 $validation = self::validateRequestInputs($region, $bucket);
45 if (is_wp_error($validation)) {
46 return $validation;
47 }
48
49 $self = new static($secret, $accessKey, $sessionToken, $region);
50 $self->bucket = $bucket;
51 $self->requestUrl = $self->getBucketRequestUrl('?max-keys=1');
52 $self->httpMethod = 'GET';
53
54 // Use a simplified test connection that doesn't check public access settings
55 return $self->testBucketConnection();
56 }
57
58 public static function updatePublicAccessBlock(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', bool $enable = true, string $region = 'us-east-1')
59 {
60 $validation = self::validateRequestInputs($region, $bucket);
61 if (is_wp_error($validation)) {
62 return $validation;
63 }
64
65 $self = new static($secret, $accessKey, $sessionToken, $region);
66 $self->bucket = $bucket;
67 return $self->setPublicAccessBlock($enable);
68 }
69
70 public static function updateObjectOwnership(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', bool $enforce = true, string $region = 'us-east-1')
71 {
72 $validation = self::validateRequestInputs($region, $bucket);
73 if (is_wp_error($validation)) {
74 return $validation;
75 }
76
77 $self = new static($secret, $accessKey, $sessionToken, $region);
78 $self->bucket = $bucket;
79 return $self->setObjectOwnership($enforce);
80 }
81
82 public static function checkSecuritySettings(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1')
83 {
84 $validation = self::validateRequestInputs($region, $bucket);
85 if (is_wp_error($validation)) {
86 return $validation;
87 }
88
89 $self = new static($secret, $accessKey, $sessionToken, $region);
90 $self->bucket = $bucket;
91
92 $publicAccess = $self->checkPublicAccessBlock();
93 $objectOwnership = $self->checkObjectOwnership();
94
95 return [
96 'block_public_access' => !is_wp_error($publicAccess) ? $publicAccess : false,
97 'object_ownership' => !is_wp_error($objectOwnership) ? $objectOwnership : false
98 ];
99 }
100
101 public function __construct(string $secret, string $accessKey, ?string $sessionToken = null, string $region = 'us-east-1')
102 {
103 $this->secretKey = $secret;
104 $this->accessKey = $accessKey;
105 $this->sessionToken = $sessionToken;
106 $this->region = $region;
107 $this->bucket = '';
108
109 $this->httpMethod = "GET";
110 $this->timeStamp = gmdate('Ymd\THis\Z');
111 $this->date = substr($this->timeStamp, 0, 8);
112 $this->requestUrl = $this->getServiceListUrl();
113
114 // Signature generation happens in testConnection now or we regenerate it there if we change params
115 }
116
117 public function testConnection()
118 {
119 add_filter('http_request_timeout', function () {
120 return 30;
121 });
122
123 // Regenerate signature because request params might have changed (if bucket was set)
124 $this->signature = $this->generateSignature();
125
126 // Ensure headers are generated with new info
127 $headers = $this->getHeaders();
128
129 $response = wp_remote_request($this->requestUrl, [
130 'method' => $this->httpMethod,
131 'headers' => $headers
132 ]);
133
134 $responseCode = wp_remote_retrieve_response_code($response);
135
136 // Success check
137 if ($responseCode >= 200 && $responseCode < 300) {
138 $data = [
139 'message' => __('Successfully verified S3 connection!', 'fluent-cart'),
140 'code' => $responseCode,
141 'region' => $this->region
142 ];
143
144 if ($this->bucket) {
145 // Check Public Access Block
146 $publicAccess = $this->checkPublicAccessBlock();
147 if (!is_wp_error($publicAccess)) {
148 $data['block_public_access'] = $publicAccess;
149 }
150
151 // Check Object Ownership
152 $objectOwnership = $this->checkObjectOwnership();
153 if (!is_wp_error($objectOwnership)) {
154 $data['object_ownership'] = $objectOwnership;
155 }
156 }
157
158 return $data;
159 }
160
161 // Check for Region Mismatch (301 PermanentRedirect or 400 with specific headers)
162 $detectedRegion = wp_remote_retrieve_header($response, 'x-amz-bucket-region');
163 if (($responseCode == 301 || $responseCode == 400) && $detectedRegion && $detectedRegion !== $this->region) {
164 $retryResult = $this->retryWithRegion($detectedRegion, '?max-keys=1');
165 if (is_wp_error($retryResult)) {
166 return $retryResult;
167 }
168
169 return $this->testConnection();
170 }
171
172 // Error Handling
173 $error_message = __('Invalid S3 credentials', 'fluent-cart');
174 $errorCode = 'invalid_credentials';
175
176 $responseBody = wp_remote_retrieve_body($response);
177
178 if (!empty($responseBody)) {
179 $xml = simplexml_load_string($responseBody);
180 if ($xml) {
181 $awsCode = isset($xml->Code) ? (string)$xml->Code : '';
182 $awsMessage = isset($xml->Message) ? (string) $xml->Message : '';
183
184 // Check specific AWS error codes
185 if ($awsCode === 'NoSuchBucket') {
186 $msg = $awsMessage ?: sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket);
187 return new \WP_Error('bucket_not_found', $msg);
188 }
189
190 if ($awsCode === 'PermanentRedirect') {
191 $error_message = $awsMessage ?: __('The bucket you are attempting to access must be addressed using the specified endpoint.', 'fluent-cart');
192 if ($detectedRegion) {
193 $error_message .= ' ' . sprintf(__('Correct Region: %s', 'fluent-cart'), $detectedRegion);
194 }
195 return new \WP_Error('region_mismatch', $error_message);
196 }
197
198 if ($awsCode === 'AccessDenied' || $responseCode == 403) {
199 // If we are checking credentials only (no bucket), generic invalid strings
200 if (!$this->bucket) {
201 $error_message = $awsMessage ?: __('Invalid S3 credentials', 'fluent-cart');
202 } else {
203 $error_message = $awsMessage ?: sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket);
204 $errorCode = 'bucket_forbidden';
205 }
206 }
207
208 // Use AWS message if available and we haven't set a custom one
209 if ($awsMessage && $error_message === __('Invalid S3 credentials', 'fluent-cart') && $errorCode === 'invalid_credentials') {
210 $error_message = $awsMessage;
211 }
212
213 if (strpos($error_message, 'User:') !== false) {
214 $error_message = __('Your IAM user does not have permission to use S3 buckets', 'fluent-cart');
215 }
216 }
217 }
218
219 // If we didn't get XML or couldn't parse it, fallback to status codes
220 if ($this->bucket) {
221 if ($responseCode == 404) {
222 return new \WP_Error('bucket_not_found', sprintf(__('Media cannot be offloaded because a bucket with the configured name (%s) does not exist.', 'fluent-cart'), $this->bucket));
223 }
224 if ($responseCode == 403 && $errorCode === 'invalid_credentials') {
225 // Use more specific error if we haven't already
226 return new \WP_Error('bucket_forbidden', sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket));
227 }
228 }
229
230 return new \WP_Error($responseCode, $error_message);
231 }
232
233 public function testBucketConnection()
234 {
235 add_filter('http_request_timeout', function () {
236 return 30;
237 });
238
239 // Regenerate signature because request params might have changed
240 $this->signature = $this->generateSignature();
241 $headers = $this->getHeaders();
242
243 $response = wp_remote_request($this->requestUrl, [
244 'method' => $this->httpMethod,
245 'headers' => $headers
246 ]);
247
248 $responseCode = wp_remote_retrieve_response_code($response);
249
250 // Success check
251 if ($responseCode >= 200 && $responseCode < 300) {
252 return [
253 'message' => __('Successfully verified bucket!', 'fluent-cart'),
254 'code' => $responseCode,
255 'region' => $this->region
256 ];
257 }
258
259 // Check for Region Mismatch (301 PermanentRedirect or 400 with specific headers)
260 $detectedRegion = wp_remote_retrieve_header($response, 'x-amz-bucket-region');
261
262 // If header is missing or we are in a redirect loop, try GetBucketLocation API
263 if (($responseCode == 301 || $responseCode == 400) && (!$detectedRegion || $detectedRegion !== $this->region)) {
264 // Try explicit GetBucketLocation call
265 $locationRegion = $this->getBucketLocation();
266 if ($locationRegion && $locationRegion !== $this->region) {
267 $detectedRegion = $locationRegion;
268 }
269
270 // Fallback: Try HEAD request (unsigned) which often returns region header even on 400/403
271 if (!$detectedRegion) {
272 $headUrl = $this->getBucketBaseUrlForRegion(self::DEFAULT_REGION);
273 $headResponse = wp_remote_head($headUrl);
274 if (!is_wp_error($headResponse)) {
275 $headRegion = wp_remote_retrieve_header($headResponse, 'x-amz-bucket-region');
276 if ($headRegion) {
277 $detectedRegion = $headRegion;
278 }
279 }
280 }
281 }
282
283
284 if (($responseCode == 301 || $responseCode == 400) && $detectedRegion && $detectedRegion !== $this->region) {
285 $retryResult = $this->retryWithRegion($detectedRegion, '?max-keys=1');
286 if (is_wp_error($retryResult)) {
287 return $retryResult;
288 }
289
290 return $this->testBucketConnection();
291 }
292
293 // Error Handling reuse
294 $error_message = __('Invalid S3 credentials', 'fluent-cart');
295 $errorCode = 'invalid_credentials';
296
297 $responseBody = wp_remote_retrieve_body($response);
298
299 if (!empty($responseBody)) {
300 $xml = simplexml_load_string($responseBody);
301 if ($xml) {
302 $awsCode = isset($xml->Code) ? (string)$xml->Code : '';
303 $awsMessage = isset($xml->Message) ? (string) $xml->Message : '';
304
305 if ($awsCode === 'NoSuchBucket') {
306 $msg = $awsMessage ?: sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket);
307 return new \WP_Error('bucket_not_found', $msg);
308 }
309
310 if ($awsCode === 'PermanentRedirect') {
311 // Ensure we display the endpoint message if we couldn't auto-redirect
312 $error_message = $awsMessage ?: __('The bucket you are attempting to access must be addressed using the specified endpoint.', 'fluent-cart');
313 // We should ideally tell the user the correct region if we know it
314 if ($detectedRegion) {
315 $error_message .= ' ' . sprintf(__('Correct Region: %s', 'fluent-cart'), $detectedRegion);
316 }
317 return new \WP_Error('region_mismatch', $error_message);
318 }
319
320 if ($awsCode === 'AccessDenied' || $responseCode == 403) {
321 $error_message = $awsMessage ?: sprintf(__('Access forbidden to the configured bucket (%s). Check permissions.', 'fluent-cart'), $this->bucket);
322 }
323
324 if ($awsMessage && $error_message === __('Invalid S3 credentials', 'fluent-cart')) {
325 $error_message = $awsMessage;
326 }
327 }
328 }
329
330 if ($responseCode == 404) {
331 return new \WP_Error('bucket_not_found', sprintf(__('Bucket (%s) does not exist.', 'fluent-cart'), $this->bucket));
332 }
333
334 return new \WP_Error($responseCode, $error_message);
335 }
336
337 public function checkPublicAccessBlock()
338 {
339 // Save current state
340 $originalRequestUrl = $this->requestUrl;
341 $originalHttpMethod = $this->httpMethod;
342 $originalContentMD5 = $this->contentMD5;
343
344 // Set up for GET request (read public access block)
345 $this->requestUrl = $this->getBucketRequestUrl('/?publicAccessBlock');
346 $this->httpMethod = 'GET';
347 $this->contentMD5 = null; // No body for GET request
348
349 // Regenerate signature because request params changed
350 $this->signature = $this->generateSignature();
351
352 // Generate headers with new signature
353 $headers = $this->getHeaders();
354
355 $response = wp_remote_request($this->requestUrl, [
356 'method' => $this->httpMethod,
357 'headers' => $headers
358 ]);
359
360 if (is_wp_error($response)) {
361 return $response;
362 }
363
364 // Restore original values
365 $this->requestUrl = $originalRequestUrl;
366 $this->httpMethod = $originalHttpMethod;
367 $this->contentMD5 = $originalContentMD5;
368
369 $responseCode = wp_remote_retrieve_response_code($response);
370
371 if ($responseCode >= 200 && $responseCode < 300) {
372 $body = wp_remote_retrieve_body($response);
373 $xml = simplexml_load_string($body);
374
375 // AWS returns root element <PublicAccessBlockConfiguration>
376 // $xml IS the configuration object, not a wrapper containing it.
377 if ($xml) {
378 // Check if all block settings are enabled
379 $blockPublicAcls = (string)$xml->BlockPublicAcls === 'true';
380 $ignorePublicAcls = (string)$xml->IgnorePublicAcls === 'true';
381 $blockPublicPolicy = (string)$xml->BlockPublicPolicy === 'true';
382 $restrictPublicBuckets = (string)$xml->RestrictPublicBuckets === 'true';
383
384 // If any is true, we can consider it as having some blocking, but usually "Block All" means all are true.
385 return $blockPublicAcls && $ignorePublicAcls && $blockPublicPolicy && $restrictPublicBuckets;
386 }
387 } else if ($responseCode == 404) {
388 // 404 on ?publicAccessBlock means no configuration exists, so it's disabled.
389 return false;
390 }
391
392 return new \WP_Error('public_access_check_failed', __('Could not check public access settings', 'fluent-cart'));
393 }
394
395 public function setPublicAccessBlock(bool $enable)
396 {
397 // Save current requestUrl and method
398 $originalRequestUrl = $this->requestUrl;
399 $originalHttpMethod = $this->httpMethod;
400
401 $this->requestUrl = $this->getBucketRequestUrl('/?publicAccessBlock');
402 $this->httpMethod = 'PUT';
403
404 $setting = $enable ? 'true' : 'false';
405 $this->payloadBody = <<<XML
406 <PublicAccessBlockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
407 <BlockPublicAcls>{$setting}</BlockPublicAcls>
408 <IgnorePublicAcls>{$setting}</IgnorePublicAcls>
409 <BlockPublicPolicy>{$setting}</BlockPublicPolicy>
410 <RestrictPublicBuckets>{$setting}</RestrictPublicBuckets>
411 </PublicAccessBlockConfiguration>
412 XML;
413
414 $this->signature = $this->generateSignature();
415 $headers = $this->getHeaders();
416 $headers['Content-Type'] = 'application/xml';
417
418 $response = wp_remote_request($this->requestUrl, [
419 'method' => $this->httpMethod,
420 'headers' => $headers,
421 'body' => $this->payloadBody
422 ]);
423
424 if (is_wp_error($response)) {
425 return $response;
426 }
427
428 // Restore
429 $this->requestUrl = $originalRequestUrl;
430 $this->httpMethod = $originalHttpMethod;
431 $this->payloadBody = '';
432
433 $responseCode = wp_remote_retrieve_response_code($response);
434
435 if ($responseCode >= 200 && $responseCode < 300) {
436 return true;
437 }
438
439 $body = wp_remote_retrieve_body($response);
440 $xml = simplexml_load_string($body);
441 $errorMsg = $xml && isset($xml->Message) ? (string)$xml->Message : $body;
442 /* translators: %1$s: error message from S3 API */
443 return new \WP_Error('s3_update_failed', sprintf(__('Failed to update S3 Public Access Block: %1$s', 'fluent-cart'), $errorMsg));
444 }
445
446 public function checkObjectOwnership()
447 {
448 $originalRequestUrl = $this->requestUrl;
449 $originalHttpMethod = $this->httpMethod;
450 $originalContentMD5 = $this->contentMD5;
451
452 // Set up for GET request (read ownership controls)
453 $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls');
454 $this->httpMethod = 'GET';
455 $this->contentMD5 = null; // No body for GET request
456
457 $this->signature = $this->generateSignature();
458 $headers = $this->getHeaders();
459
460 $response = wp_remote_request($this->requestUrl, [
461 'method' => $this->httpMethod,
462 'headers' => $headers
463 ]);
464
465 if (is_wp_error($response)) {
466 return $response;
467 }
468
469 // Restore original values
470 $this->requestUrl = $originalRequestUrl;
471 $this->httpMethod = $originalHttpMethod;
472 $this->contentMD5 = $originalContentMD5;
473
474 $responseCode = wp_remote_retrieve_response_code($response);
475
476 if ($responseCode >= 200 && $responseCode < 300) {
477 $body = wp_remote_retrieve_body($response);
478 $xml = simplexml_load_string($body);
479
480 // AWS response format: <OwnershipControls><Rule><ObjectOwnership>VALUE</ObjectOwnership></Rule></OwnershipControls>
481 if ($xml && isset($xml->Rule->ObjectOwnership)) {
482 $ownership = (string)$xml->Rule->ObjectOwnership;
483 // BucketOwnerEnforced = Enforced (ACLs disabled)
484 // BucketOwnerPreferred = ACLs enabled (usually)
485 return $ownership === 'BucketOwnerEnforced';
486 }
487 } else if ($responseCode == 404) {
488 // If ownership controls are not found, it implies legacy behavior (ObjectWriter), which means ACLs are enabled (Not Enforced).
489 return false;
490 }
491
492 return new \WP_Error('ownership_check_failed', __('Could not check object ownership settings', 'fluent-cart'));
493 }
494
495 public function getBucketLocation()
496 {
497 $originalRequestUrl = $this->requestUrl;
498 $originalHttpMethod = $this->httpMethod;
499 $originalContentMD5 = $this->contentMD5;
500
501 // S3 API: GET /?location
502 // This request must be signed, but standard auth works.
503 // It returns LocationConstraint XML.
504
505 $this->requestUrl = $this->getBucketRequestUrl('/?location');
506
507 $this->httpMethod = 'GET';
508 $this->contentMD5 = null;
509
510 $this->signature = $this->generateSignature();
511 $headers = $this->getHeaders();
512
513 $response = wp_remote_request($this->requestUrl, [
514 'method' => $this->httpMethod,
515 'headers' => $headers
516 ]);
517
518 // Restore
519 $this->requestUrl = $originalRequestUrl;
520 $this->httpMethod = $originalHttpMethod;
521 $this->contentMD5 = $originalContentMD5;
522
523 $responseCode = wp_remote_retrieve_response_code($response);
524
525 if ($responseCode >= 200 && $responseCode < 300) {
526 $body = wp_remote_retrieve_body($response);
527 $xml = simplexml_load_string($body);
528 if ($xml) {
529 $location = (string)$xml ?: self::DEFAULT_REGION;
530
531 return S3InputValidator::isValidRegion($location) ? $location : null;
532 }
533 }
534
535 return null; // Could not determine
536 }
537
538 public function setObjectOwnership(bool $enforce)
539 {
540 $originalRequestUrl = $this->requestUrl;
541 $originalHttpMethod = $this->httpMethod;
542
543 $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls');
544 $this->httpMethod = 'PUT';
545
546 // BucketOwnerEnforced = Enforced (ACLs disabled)
547 // BucketOwnerPreferred = ACLs enabled
548 $setting = $enforce ? 'BucketOwnerEnforced' : 'BucketOwnerPreferred';
549
550 $this->payloadBody = '<OwnershipControls xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ObjectOwnership>' . $setting . '</ObjectOwnership></Rule></OwnershipControls>';
551
552 // Set Content-MD5 property for signature generation
553 $this->contentMD5 = base64_encode(md5($this->payloadBody, true));
554
555 $this->signature = $this->generateSignature();
556 $headers = $this->getHeaders();
557
558 // Add Content-Type header
559 $headers['Content-Type'] = 'application/xml';
560
561 $response = wp_remote_request($this->requestUrl, [
562 'method' => $this->httpMethod,
563 'headers' => $headers,
564 'body' => $this->payloadBody
565 ]);
566
567 if (is_wp_error($response)) {
568 return $response;
569 }
570
571 $this->requestUrl = $originalRequestUrl;
572 $this->httpMethod = $originalHttpMethod;
573 $this->payloadBody = '';
574 $this->contentMD5 = null; // Reset MD5 after PUT operation
575
576 $responseCode = wp_remote_retrieve_response_code($response);
577
578 if ($responseCode >= 200 && $responseCode < 300) {
579 return true;
580 }
581
582 $body = wp_remote_retrieve_body($response);
583 $xml = simplexml_load_string($body);
584 $errorMsg = $xml && isset($xml->Message) ? (string)$xml->Message : $body;
585 /* translators: %1$s: error message from S3 API */
586 return new \WP_Error('s3_update_failed_ownership', sprintf(__('Failed to update S3 Object Ownership: %1$s', 'fluent-cart'), $errorMsg));
587 }
588
589 private static function validateRequestInputs(string $region, string $bucket = '')
590 {
591 $regionValidation = S3InputValidator::validateRegion($region);
592 if (is_wp_error($regionValidation)) {
593 return $regionValidation;
594 }
595
596 if ($bucket !== '') {
597 $bucketValidation = S3InputValidator::validateBucket($bucket);
598 if (is_wp_error($bucketValidation)) {
599 return $bucketValidation;
600 }
601 }
602
603 return true;
604 }
605
606 private function getServiceListUrl(): string
607 {
608 $host = $this->region === self::DEFAULT_REGION
609 ? 's3.amazonaws.com'
610 : "s3.{$this->region}.amazonaws.com";
611
612 return 'https://' . $host . '/?list-type=2&encoding-type=url&max-keys=1';
613 }
614
615 private function getBucketBaseUrlForRegion(string $region): string
616 {
617 if ($this->bucket === '') {
618 return $this->getServiceListUrl();
619 }
620
621 if (strpos($this->bucket, '.') !== false) {
622 $host = $region === self::DEFAULT_REGION ? 's3.amazonaws.com' : "s3.{$region}.amazonaws.com";
623 return 'https://' . $host . '/' . $this->bucket . '/';
624 }
625
626 $host = $region === self::DEFAULT_REGION
627 ? "{$this->bucket}.s3.amazonaws.com"
628 : "{$this->bucket}.s3.{$region}.amazonaws.com";
629
630 return 'https://' . $host;
631 }
632
633 private function getBucketRequestUrl(string $querySuffix = '?max-keys=1'): string
634 {
635 return $this->getBucketBaseUrlForRegion($this->region) . $querySuffix;
636 }
637
638 private function retryWithRegion(string $detectedRegion, string $querySuffix)
639 {
640 $regionValidation = S3InputValidator::validateRegion($detectedRegion);
641 if (is_wp_error($regionValidation)) {
642 return $regionValidation;
643 }
644
645 if ($this->regionRetryCount >= self::MAX_REGION_RETRIES) {
646 return new \WP_Error(
647 'region_retry_limit',
648 __('Unable to determine the correct S3 region after multiple attempts. Please verify the bucket region and try again.', 'fluent-cart')
649 );
650 }
651
652 $this->regionRetryCount++;
653 $this->region = $detectedRegion;
654 $this->requestUrl = $this->bucket ? $this->getBucketRequestUrl($querySuffix) : $this->getServiceListUrl();
655
656 return true;
657 }
658
659 private function generateSignature()
660 {
661 return hash_hmac(
662 $this->hashAlgorithm,
663 $this->createStringToSign(),
664 $this->getSigningKey()
665 );
666 }
667
668 private function createStringToSign(): string
669 {
670 $hash = hash($this->hashAlgorithm, $this->createCanonicalUrl());
671 return "AWS4-HMAC-SHA256\n{$this->timeStamp}\n{$this->getScope()}\n{$hash}";
672 }
673
674 private function createCanonicalUrl(): string
675 {
676 $canonicalQuery = "encoding-type=url&list-type=2&max-keys=1";
677
678 // Logic to construct query params correctly for different requests
679 // If verify() was called with a bucket, we set requestUrl with ?max-keys=1 on the root of the bucket path
680 // But if we are checking public access block, the query is different.
681 // We need to handle this based on the current requestUrl or context.
682 // Simplified approach: rely on the fact that for the main check, it IS max-keys=1.
683
684 if ($this->bucket) {
685 // For bucket check (GET /?max-keys=1)
686 if (strpos($this->requestUrl, 'max-keys=1') !== false) {
687 $canonicalQuery = "max-keys=1";
688 } else if (strpos($this->requestUrl, 'publicAccessBlock') !== false) {
689 $canonicalQuery = "publicAccessBlock=";
690 } else if (strpos($this->requestUrl, 'ownershipControls') !== false) {
691 $canonicalQuery = "ownershipControls=";
692 } else if (strpos($this->requestUrl, 'location') !== false) {
693 $canonicalQuery = "location=";
694 }
695 }
696
697 // Build canonical headers - MUST be sorted alphabetically by header name
698 $canonicalHeaders = "";
699
700 if ($this->contentMD5) {
701 $canonicalHeaders .= "content-md5:{$this->contentMD5}\n";
702 }
703
704 $canonicalHeaders .= "host:{$this->getHost()}\n";
705 $canonicalHeaders .= "x-amz-content-sha256:{$this->getContentHash()}\n";
706 $canonicalHeaders .= "x-amz-date:{$this->timeStamp}\n";
707
708 if ($this->sessionToken) {
709 $canonicalHeaders .= "x-amz-security-token:{$this->sessionToken}\n";
710 }
711
712 // Signed headers - MUST match the order of canonical headers (alphabetically sorted)
713 $signedHeaders = "";
714 if ($this->contentMD5) {
715 $signedHeaders .= "content-md5;";
716 }
717 $signedHeaders .= "host;x-amz-content-sha256;x-amz-date";
718
719 if ($this->sessionToken) {
720 $signedHeaders .= ";x-amz-security-token";
721 }
722
723 $path = "/"; // Default path
724 if ($this->isPathStyle()) {
725 $path = "/{$this->bucket}/";
726 }
727
728 // Canonical request format per AWS Signature v4:
729 // HTTPMethod\n
730 // CanonicalURI\n
731 // CanonicalQueryString\n
732 // CanonicalHeaders\n
733 // SignedHeaders\n
734 // HashedPayload
735 $payload = "$this->httpMethod\n" .
736 "{$path}\n" .
737 "{$canonicalQuery}\n" .
738 "{$canonicalHeaders}\n" .
739 "{$signedHeaders}\n" .
740 $this->getContentHash();
741
742 return $payload;
743 }
744
745 private function getHost(): string
746 {
747 // Parse host from the request URL to support virtual hosted style buckets
748 return parse_url($this->requestUrl, PHP_URL_HOST) ?: "s3.amazonaws.com";
749 }
750
751 private function getContentHash(): string
752 {
753 return hash($this->hashAlgorithm, $this->payloadBody);
754 }
755
756 private function getScope(): string
757 {
758 return "{$this->date}/{$this->region}/s3/aws4_request";
759 }
760
761 private function getSigningKey()
762 {
763 $dateKey = hash_hmac($this->hashAlgorithm, $this->date, "AWS4{$this->secretKey}", true);
764 $regionKey = hash_hmac($this->hashAlgorithm, $this->region, $dateKey, true);
765 $serviceKey = hash_hmac($this->hashAlgorithm, 's3', $regionKey, true);
766 return hash_hmac($this->hashAlgorithm, 'aws4_request', $serviceKey, true);
767 }
768
769 private function getHeaders(): array
770 {
771 $headers = [
772 "x-amz-content-sha256" => $this->getContentHash(),
773 'x-amz-date' => $this->timeStamp,
774 ];
775
776 if ($this->contentMD5) {
777 $headers['content-md5'] = $this->contentMD5;
778 }
779
780 if ($this->sessionToken) {
781 $headers['x-amz-security-token'] = $this->sessionToken;
782 }
783
784 $signedHeaders = "";
785
786 if ($this->contentMD5) {
787 $signedHeaders .= "content-md5;";
788 }
789
790 $signedHeaders .= "host;x-amz-content-sha256;x-amz-date";
791
792 if ($this->sessionToken) {
793 $signedHeaders .= ";x-amz-security-token";
794 }
795
796 $headers['Authorization'] = "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$signedHeaders}, Signature={$this->signature}";
797
798 return $headers;
799 }
800
801 private function isPathStyle()
802 {
803 return $this->bucket && strpos($this->bucket, '.') !== false;
804 }
805 }
806