PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.26
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.26
1.6.5 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 All 48 releases
fluent-cart / app / Services / FileSystem / Drivers / S3 / S3ConnectionVerify.php

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

804 lines 30.2 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');
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 return new \WP_Error('s3_update_failed', 'Failed to update S3 Public Access Block: ' . $errorMsg);
443 }
444
445 public function checkObjectOwnership()
446 {
447 $originalRequestUrl = $this->requestUrl;
448 $originalHttpMethod = $this->httpMethod;
449 $originalContentMD5 = $this->contentMD5;
450
451 // Set up for GET request (read ownership controls)
452 $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls');
453 $this->httpMethod = 'GET';
454 $this->contentMD5 = null; // No body for GET request
455
456 $this->signature = $this->generateSignature();
457 $headers = $this->getHeaders();
458
459 $response = wp_remote_request($this->requestUrl, [
460 'method' => $this->httpMethod,
461 'headers' => $headers
462 ]);
463
464 if (is_wp_error($response)) {
465 return $response;
466 }
467
468 // Restore original values
469 $this->requestUrl = $originalRequestUrl;
470 $this->httpMethod = $originalHttpMethod;
471 $this->contentMD5 = $originalContentMD5;
472
473 $responseCode = wp_remote_retrieve_response_code($response);
474
475 if ($responseCode >= 200 && $responseCode < 300) {
476 $body = wp_remote_retrieve_body($response);
477 $xml = simplexml_load_string($body);
478
479 // AWS response format: <OwnershipControls><Rule><ObjectOwnership>VALUE</ObjectOwnership></Rule></OwnershipControls>
480 if ($xml && isset($xml->Rule->ObjectOwnership)) {
481 $ownership = (string)$xml->Rule->ObjectOwnership;
482 // BucketOwnerEnforced = Enforced (ACLs disabled)
483 // BucketOwnerPreferred = ACLs enabled (usually)
484 return $ownership === 'BucketOwnerEnforced';
485 }
486 } else if ($responseCode == 404) {
487 // If ownership controls are not found, it implies legacy behavior (ObjectWriter), which means ACLs are enabled (Not Enforced).
488 return false;
489 }
490
491 return new \WP_Error('ownership_check_failed', 'Could not check object ownership settings');
492 }
493
494 public function getBucketLocation()
495 {
496 $originalRequestUrl = $this->requestUrl;
497 $originalHttpMethod = $this->httpMethod;
498 $originalContentMD5 = $this->contentMD5;
499
500 // S3 API: GET /?location
501 // This request must be signed, but standard auth works.
502 // It returns LocationConstraint XML.
503
504 $this->requestUrl = $this->getBucketRequestUrl('/?location');
505
506 $this->httpMethod = 'GET';
507 $this->contentMD5 = null;
508
509 $this->signature = $this->generateSignature();
510 $headers = $this->getHeaders();
511
512 $response = wp_remote_request($this->requestUrl, [
513 'method' => $this->httpMethod,
514 'headers' => $headers
515 ]);
516
517 // Restore
518 $this->requestUrl = $originalRequestUrl;
519 $this->httpMethod = $originalHttpMethod;
520 $this->contentMD5 = $originalContentMD5;
521
522 $responseCode = wp_remote_retrieve_response_code($response);
523
524 if ($responseCode >= 200 && $responseCode < 300) {
525 $body = wp_remote_retrieve_body($response);
526 $xml = simplexml_load_string($body);
527 if ($xml) {
528 $location = (string)$xml ?: self::DEFAULT_REGION;
529
530 return S3InputValidator::isValidRegion($location) ? $location : null;
531 }
532 }
533
534 return null; // Could not determine
535 }
536
537 public function setObjectOwnership(bool $enforce)
538 {
539 $originalRequestUrl = $this->requestUrl;
540 $originalHttpMethod = $this->httpMethod;
541
542 $this->requestUrl = $this->getBucketRequestUrl('/?ownershipControls');
543 $this->httpMethod = 'PUT';
544
545 // BucketOwnerEnforced = Enforced (ACLs disabled)
546 // BucketOwnerPreferred = ACLs enabled
547 $setting = $enforce ? 'BucketOwnerEnforced' : 'BucketOwnerPreferred';
548
549 $this->payloadBody = '<OwnershipControls xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Rule><ObjectOwnership>' . $setting . '</ObjectOwnership></Rule></OwnershipControls>';
550
551 // Set Content-MD5 property for signature generation
552 $this->contentMD5 = base64_encode(md5($this->payloadBody, true));
553
554 $this->signature = $this->generateSignature();
555 $headers = $this->getHeaders();
556
557 // Add Content-Type header
558 $headers['Content-Type'] = 'application/xml';
559
560 $response = wp_remote_request($this->requestUrl, [
561 'method' => $this->httpMethod,
562 'headers' => $headers,
563 'body' => $this->payloadBody
564 ]);
565
566 if (is_wp_error($response)) {
567 return $response;
568 }
569
570 $this->requestUrl = $originalRequestUrl;
571 $this->httpMethod = $originalHttpMethod;
572 $this->payloadBody = '';
573 $this->contentMD5 = null; // Reset MD5 after PUT operation
574
575 $responseCode = wp_remote_retrieve_response_code($response);
576
577 if ($responseCode >= 200 && $responseCode < 300) {
578 return true;
579 }
580
581 $body = wp_remote_retrieve_body($response);
582 $xml = simplexml_load_string($body);
583 $errorMsg = $xml && isset($xml->Message) ? (string)$xml->Message : $body;
584 return new \WP_Error('s3_update_failed_ownership', 'Failed to update S3 Object Ownership: ' . $errorMsg);
585 }
586
587 private static function validateRequestInputs(string $region, string $bucket = '')
588 {
589 $regionValidation = S3InputValidator::validateRegion($region);
590 if (is_wp_error($regionValidation)) {
591 return $regionValidation;
592 }
593
594 if ($bucket !== '') {
595 $bucketValidation = S3InputValidator::validateBucket($bucket);
596 if (is_wp_error($bucketValidation)) {
597 return $bucketValidation;
598 }
599 }
600
601 return true;
602 }
603
604 private function getServiceListUrl(): string
605 {
606 $host = $this->region === self::DEFAULT_REGION
607 ? 's3.amazonaws.com'
608 : "s3.{$this->region}.amazonaws.com";
609
610 return 'https://' . $host . '/?list-type=2&encoding-type=url&max-keys=1';
611 }
612
613 private function getBucketBaseUrlForRegion(string $region): string
614 {
615 if ($this->bucket === '') {
616 return $this->getServiceListUrl();
617 }
618
619 if (strpos($this->bucket, '.') !== false) {
620 $host = $region === self::DEFAULT_REGION ? 's3.amazonaws.com' : "s3.{$region}.amazonaws.com";
621 return 'https://' . $host . '/' . $this->bucket . '/';
622 }
623
624 $host = $region === self::DEFAULT_REGION
625 ? "{$this->bucket}.s3.amazonaws.com"
626 : "{$this->bucket}.s3.{$region}.amazonaws.com";
627
628 return 'https://' . $host;
629 }
630
631 private function getBucketRequestUrl(string $querySuffix = '?max-keys=1'): string
632 {
633 return $this->getBucketBaseUrlForRegion($this->region) . $querySuffix;
634 }
635
636 private function retryWithRegion(string $detectedRegion, string $querySuffix)
637 {
638 $regionValidation = S3InputValidator::validateRegion($detectedRegion);
639 if (is_wp_error($regionValidation)) {
640 return $regionValidation;
641 }
642
643 if ($this->regionRetryCount >= self::MAX_REGION_RETRIES) {
644 return new \WP_Error(
645 'region_retry_limit',
646 __('Unable to determine the correct S3 region after multiple attempts. Please verify the bucket region and try again.', 'fluent-cart')
647 );
648 }
649
650 $this->regionRetryCount++;
651 $this->region = $detectedRegion;
652 $this->requestUrl = $this->bucket ? $this->getBucketRequestUrl($querySuffix) : $this->getServiceListUrl();
653
654 return true;
655 }
656
657 private function generateSignature()
658 {
659 return hash_hmac(
660 $this->hashAlgorithm,
661 $this->createStringToSign(),
662 $this->getSigningKey()
663 );
664 }
665
666 private function createStringToSign(): string
667 {
668 $hash = hash($this->hashAlgorithm, $this->createCanonicalUrl());
669 return "AWS4-HMAC-SHA256\n{$this->timeStamp}\n{$this->getScope()}\n{$hash}";
670 }
671
672 private function createCanonicalUrl(): string
673 {
674 $canonicalQuery = "encoding-type=url&list-type=2&max-keys=1";
675
676 // Logic to construct query params correctly for different requests
677 // If verify() was called with a bucket, we set requestUrl with ?max-keys=1 on the root of the bucket path
678 // But if we are checking public access block, the query is different.
679 // We need to handle this based on the current requestUrl or context.
680 // Simplified approach: rely on the fact that for the main check, it IS max-keys=1.
681
682 if ($this->bucket) {
683 // For bucket check (GET /?max-keys=1)
684 if (strpos($this->requestUrl, 'max-keys=1') !== false) {
685 $canonicalQuery = "max-keys=1";
686 } else if (strpos($this->requestUrl, 'publicAccessBlock') !== false) {
687 $canonicalQuery = "publicAccessBlock=";
688 } else if (strpos($this->requestUrl, 'ownershipControls') !== false) {
689 $canonicalQuery = "ownershipControls=";
690 } else if (strpos($this->requestUrl, 'location') !== false) {
691 $canonicalQuery = "location=";
692 }
693 }
694
695 // Build canonical headers - MUST be sorted alphabetically by header name
696 $canonicalHeaders = "";
697
698 if ($this->contentMD5) {
699 $canonicalHeaders .= "content-md5:{$this->contentMD5}\n";
700 }
701
702 $canonicalHeaders .= "host:{$this->getHost()}\n";
703 $canonicalHeaders .= "x-amz-content-sha256:{$this->getContentHash()}\n";
704 $canonicalHeaders .= "x-amz-date:{$this->timeStamp}\n";
705
706 if ($this->sessionToken) {
707 $canonicalHeaders .= "x-amz-security-token:{$this->sessionToken}\n";
708 }
709
710 // Signed headers - MUST match the order of canonical headers (alphabetically sorted)
711 $signedHeaders = "";
712 if ($this->contentMD5) {
713 $signedHeaders .= "content-md5;";
714 }
715 $signedHeaders .= "host;x-amz-content-sha256;x-amz-date";
716
717 if ($this->sessionToken) {
718 $signedHeaders .= ";x-amz-security-token";
719 }
720
721 $path = "/"; // Default path
722 if ($this->isPathStyle()) {
723 $path = "/{$this->bucket}/";
724 }
725
726 // Canonical request format per AWS Signature v4:
727 // HTTPMethod\n
728 // CanonicalURI\n
729 // CanonicalQueryString\n
730 // CanonicalHeaders\n
731 // SignedHeaders\n
732 // HashedPayload
733 $payload = "$this->httpMethod\n" .
734 "{$path}\n" .
735 "{$canonicalQuery}\n" .
736 "{$canonicalHeaders}\n" .
737 "{$signedHeaders}\n" .
738 $this->getContentHash();
739
740 return $payload;
741 }
742
743 private function getHost(): string
744 {
745 // Parse host from the request URL to support virtual hosted style buckets
746 return parse_url($this->requestUrl, PHP_URL_HOST) ?: "s3.amazonaws.com";
747 }
748
749 private function getContentHash(): string
750 {
751 return hash($this->hashAlgorithm, $this->payloadBody);
752 }
753
754 private function getScope(): string
755 {
756 return "{$this->date}/{$this->region}/s3/aws4_request";
757 }
758
759 private function getSigningKey()
760 {
761 $dateKey = hash_hmac($this->hashAlgorithm, $this->date, "AWS4{$this->secretKey}", true);
762 $regionKey = hash_hmac($this->hashAlgorithm, $this->region, $dateKey, true);
763 $serviceKey = hash_hmac($this->hashAlgorithm, 's3', $regionKey, true);
764 return hash_hmac($this->hashAlgorithm, 'aws4_request', $serviceKey, true);
765 }
766
767 private function getHeaders(): array
768 {
769 $headers = [
770 "x-amz-content-sha256" => $this->getContentHash(),
771 'x-amz-date' => $this->timeStamp,
772 ];
773
774 if ($this->contentMD5) {
775 $headers['content-md5'] = $this->contentMD5;
776 }
777
778 if ($this->sessionToken) {
779 $headers['x-amz-security-token'] = $this->sessionToken;
780 }
781
782 $signedHeaders = "";
783
784 if ($this->contentMD5) {
785 $signedHeaders .= "content-md5;";
786 }
787
788 $signedHeaders .= "host;x-amz-content-sha256;x-amz-date";
789
790 if ($this->sessionToken) {
791 $signedHeaders .= ";x-amz-security-token";
792 }
793
794 $headers['Authorization'] = "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders={$signedHeaders}, Signature={$this->signature}";
795
796 return $headers;
797 }
798
799 private function isPathStyle()
800 {
801 return $this->bucket && strpos($this->bucket, '.') !== false;
802 }
803 }
804