PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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
← All changes | app/Services/FileSystem/Drivers/S3/S3ConnectionVerify.php +700 -37 1.3.20 → 1.6.5 View file →
@@ -3,8 +3,11 @@
3 3 namespace FluentCart\App\Services\FileSystem\Drivers\S3;
4 4
5 5 class S3ConnectionVerify
6 6 {
7 + private const DEFAULT_REGION = 'us-east-1';
8 + private const MAX_REGION_RETRIES = 2;
9 +
7 10 private $date;
8 11 private $timeStamp;
9 12 private string $accessKey;
10 13 private string $bucket;
@@ -13,73 +16,647 @@
13 16 private string $region;
14 17 private string $secretKey;
15 18 private string $signature;
16 19 private string $requestUrl;
20 + private ?string $sessionToken = null;
21 + private string $payloadBody = '';
22 + private ?string $contentMD5 = null;
23 + private int $regionRetryCount = 0;
17 24
18 - public static function verify(string $secret, string $accessKey)
25 + public static function verify(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1')
19 26 {
20 - $self = new static($secret, $accessKey);
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 +
21 39 return $self->testConnection();
22 40 }
23 41
24 - public function __construct(string $secret, string $accessKey)
42 + public static function checkBucketExistence(string $secret, string $accessKey, ?string $sessionToken = null, string $bucket = '', string $region = 'us-east-1')
25 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 + {
26 103 $this->secretKey = $secret;
27 104 $this->accessKey = $accessKey;
28 - $this->region = 'us-east-1';
105 + $this->sessionToken = $sessionToken;
106 + $this->region = $region;
29 107 $this->bucket = '';
30 108
31 109 $this->httpMethod = "GET";
32 110 $this->timeStamp = gmdate('Ymd\THis\Z');
33 111 $this->date = substr($this->timeStamp, 0, 8);
34 - $this->requestUrl = "https://s3.amazonaws.com/?list-type=2&encoding-type=url&max-keys=1";
112 + $this->requestUrl = $this->getServiceListUrl();
35 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)
36 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);
37 231 }
38 232
39 - public function testConnection()
233 + public function testBucketConnection()
40 234 {
41 235 add_filter('http_request_timeout', function () {
42 236 return 30;
43 237 });
44 238
239 + // Regenerate signature because request params might have changed
240 + $this->signature = $this->generateSignature();
241 + $headers = $this->getHeaders();
242 +
45 243 $response = wp_remote_request($this->requestUrl, [
46 244 'method' => $this->httpMethod,
47 - 'headers' => $this->getHeaders()
245 + 'headers' => $headers
48 246 ]);
49 247
50 248 $responseCode = wp_remote_retrieve_response_code($response);
51 249
52 - if ($responseCode == '200') {
250 + // Success check
251 + if ($responseCode >= 200 && $responseCode < 300) {
53 252 return [
54 - 'message' => __('Successfully verified S3 connection!', 'fluent-cart'),
55 - 'code' => $responseCode
253 + 'message' => __('Successfully verified bucket!', 'fluent-cart'),
254 + 'code' => $responseCode,
255 + 'region' => $this->region
56 256 ];
57 - } else {
58 - $error_message = __('Invalid S3 credentials', 'fluent-cart');
257 + }
59 258
60 - $responseBody = wp_remote_retrieve_body($response);
61 - if (!empty($responseBody)) {
62 - $xml = simplexml_load_string($responseBody);
63 - if ($xml && isset($xml->Code)) {
64 - $code = (string)$xml->Code;
65 - $message = (string)$xml->Message;
66 - $error_message = $message ?: $error_message;
259 + // Check for Region Mismatch (301 PermanentRedirect or 400 with specific headers)
260 + $detectedRegion = wp_remote_retrieve_header($response, 'x-amz-bucket-region');
67 261
68 - if(str_contains($error_message,'User:')) {
69 - $error_message = __('Your IAM user does not have permission to use S3 buckets', 'fluent-cart');
70 - }else{
71 - $error_message = __('Invalid S3 credentials', 'fluent-cart');
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;
72 278 }
73 279 }
74 280 }
281 + }
75 282
76 283
77 -
78 - return new \WP_Error($responseCode, $error_message);
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();
79 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);
80 335 }
81 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 +
82 659 private function generateSignature()
83 660 {
84 661 return hash_hmac(
85 662 $this->hashAlgorithm,
@@ -97,26 +674,84 @@
97 674 private function createCanonicalUrl(): string
98 675 {
99 676 $canonicalQuery = "encoding-type=url&list-type=2&max-keys=1";
100 677
101 - return "$this->httpMethod\n" .
102 - "/\n" .
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" .
103 737 "{$canonicalQuery}\n" .
104 - "host:{$this->getHost()}\n" .
105 - "x-amz-content-sha256:{$this->getContentHash()}\n" .
106 - "x-amz-date:{$this->timeStamp}\n\n" .
107 - "host;x-amz-content-sha256;x-amz-date\n" .
108 - "{$this->getContentHash()}";
738 + "{$canonicalHeaders}\n" .
739 + "{$signedHeaders}\n" .
740 + $this->getContentHash();
741 +
742 + return $payload;
109 743 }
110 744
111 745 private function getHost(): string
112 746 {
113 - return "s3.amazonaws.com";
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";
114 749 }
115 750
116 751 private function getContentHash(): string
117 752 {
118 - return hash($this->hashAlgorithm, "");
753 + return hash($this->hashAlgorithm, $this->payloadBody);
119 754 }
120 755
121 756 private function getScope(): string
122 757 {
@@ -132,11 +767,39 @@
132 767 }
133 768
134 769 private function getHeaders(): array
135 770 {
136 - return [
771 + $headers = [
137 772 "x-amz-content-sha256" => $this->getContentHash(),
138 773 'x-amz-date' => $this->timeStamp,
139 - 'Authorization' => "AWS4-HMAC-SHA256 Credential={$this->accessKey}/{$this->date}/{$this->region}/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature={$this->signature}"
140 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;
141 799 }
142 -}
800 +
801 + private function isPathStyle()
802 + {
803 + return $this->bucket && strpos($this->bucket, '.') !== false;
804 + }
805 +}